Skip to content

feat(layout): forest layout patch β€” apply targeted changes without pull#784

Open
Gawtier wants to merge 7 commits into
mainfrom
feature/prd-555-forest-layout-pull
Open

feat(layout): forest layout patch β€” apply targeted changes without pull#784
Gawtier wants to merge 7 commits into
mainfrom
feature/prd-555-forest-layout-pull

Conversation

@Gawtier

@Gawtier Gawtier commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds forest layout patch β€” a new command that sends a JSON-Patch array directly to an environment without requiring a pull first
  • Removes add /folders/- from the whitelist (server rejects it with a business rule error; the correct path is /folders/<mainId>/children/-)

Motivation

The existing pull β†’ diff β†’ apply cycle always fetches the full layout before writing. For the forest-layout AI skill, this is unnecessary for pure creation: adding a workspace, a workflow shell, or a dashboard doesn't require knowing the current state. This command exposes the underlying PATCH /api/:domain endpoint directly.

Usage

# From a file
forest layout patch ops.json --env Production --team Operations

# From stdin
echo '{"layout":[{"op":"add","path":"/workspaces/-","value":{...}}]}' \
  | forest layout patch --env Production

# Dry-run (validate + display, no network call)
forest layout patch ops.json --env Production --dry-run

Input format β€” a JSON object keyed by domain:

{
  "layout":    [{ "op": "add",     "path": "/workspaces/-",                 "value": {...} }],
  "workflows": [{ "op": "replace", "path": "/workflows/<id>/isVisible",     "value": false }],
  "folders":   [{ "op": "add",     "path": "/folders/<mainId>/children/-",  "value": {...} }]
}

Behaviours

  • Whitelist validation before any network call β€” catches bad paths early with a clear error
  • Sequential atomic PATCHes per domain (layout β†’ folders β†’ workflows), consistent with apply
  • Error scoped to the failing domain β€” explainApiError receives only the ops for the domain that failed, so path correlation is accurate
  • TTY detection β€” clear usage error when stdin is interactive and no file is given
  • --dry-run β€” prints ops without sending (scope resolution is also skipped)

/folders/- whitelist removal

Validated against the deployed server: add /folders/- is always rejected with "Cannot create a folder without adding it to the main folder". The correct path is /folders/<mainId>/children/-. Removing the entry from the whitelist surfaces this constraint as a client-side error before sending anything.

Testing

Validated live against BaaSDemo/Development:

  • Collection scalar edits (displayName, icon, columns)
  • Workspace creation with cross-referencing components (selectARecord + sourceWorkspaceComponentId)
  • Workflow shell creation
  • Sections replace
  • Folder child add
  • Segment add (premium scopes)
  • Inbox add (premium inbox)
  • Dashboard + chart add (premium multipleDashboards)
  • Multi-domain patch (layout + workflows in one call)

Fixes PRD-592

Note

Add forest layout pull, diff, and apply commands for targeted layout patching

  • Introduces three new CLI commands under forest layout: pull fetches the live rendering and writes a forest-layout.yml; diff shows what changes would be applied; apply computes a JSON-Patch plan and pushes it to the target environment/team without a full pull.
  • Adds a BPMN compilation pipeline (workflow-bpmn.ts) that converts declarative workflow step graphs to BPMN XML, compares against stored S3 artifacts, and uploads only changed versions.
  • Implements a rule-driven diff engine (diff.ts, patch-rules.ts) that produces validated, ordered JSON-Patch operations per domain (layout, folders, workflows) and pre-validates ops against a server whitelist.
  • Adds scope resolution (scope-resolver.ts) with default-selection logic (development environment, Operations team) and a ScopeError for actionable failures.
  • Adds YAML (de)serialization (yaml-file.ts) with a structured forest header and tolerant parsing, using the new yaml dependency.
  • Risk: apply sends atomic per-domain PATCH requests to the server; a mid-run failure leaves domains applied up to the failure point with no rollback.
πŸ“Š Macroscope summarized 36db700. 15 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

πŸ—‚οΈ Filtered Issues

No issues evaluated.

Gautier Delache and others added 7 commits June 18, 2026 11:49
Declarative layout-as-code for the CLI: pull the live rendering of an
environment/team into a versionable forest-layout.yml, diff a local file
against the live layout, and apply the changes as an array of JSON-Patch
operations to the existing PATCH /api/:domain endpoint.

- Pure engine (no IO): rendering->canonical mapper, identity-based diff
  (match by id then name, idempotent, add->replace->remove), a mirror of
  the server patch whitelist that pre-validates every op, and YAML
  (de)serialization with a scope header.
- HTTP adapter and scope resolver reuse the toolbelt's existing
  authenticator / environment / team managers (no new auth).
- oclif commands under `forest layout` extending AbstractAuthenticatedCommand.
- 41 unit tests covering the mapper, diff, whitelist, YAML round-trip and
  scope resolver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…) via patch

Extends the layout commands from edit-only to create+edit, across every
patchable domain in one pull/diff/apply round.

- Add path: keep the client-supplied `id` (generate a uuid only when absent)
  instead of stripping it β€” the server requires it on add, and a stable id
  makes re-apply / cross-env copy idempotent.
- Per-type add-value completeness: fill the write-required fields the rendering
  read omits so an add validates server-side (segments need
  defaultSortingFieldName/defaultSortingFieldOrder/isVisible/position/columns/
  hasColumnsConfiguration + filter|query; dashboards need charts; workflow
  shells need segmentIds/isVisible/position).
- Multi-domain pull/diff/apply: layout + folders + workflows in a single round
  (new sync orchestrator); apply sends one atomic PATCH per domain.
- Workflows domain wired end to end: create/edit a workflow shell incl. the
  bpmnAwsS3Identifier link (the BPMN artifact authoring stays out of scope).

Validated live on a real env: creating a segment + dashboard + workflow from
minimal input applies (3 PATCHes), round-trips on re-pull, and reverts cleanly.

Not in this change: workspaces/inboxes modeling and name<->id resolution
inside complex values (tracked as follow-ups).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the layout engine to the two remaining top-level rendering elements.

- Mapper: capture workspaces (with their components; component `options` kept
  opaque) and inboxes (with collection/segment references) from the rendering.
- Diff rules: workspaces (name/icon/position + components add/remove/edit of
  name/displaySettings/visibility) and inboxes (premium-gated; editable
  scalars, with type/dispatchRule/sortingFields/collectionId/segmentId as
  add-only). Component/inbox `options`-style values ride opaquely on add.
- addDefaults fill the write-required fields the read omits (workspace icon +
  collectionId/components/position; inbox position/canUsersReassign).

Validated live: pull round-trips 12 workspaces (250 components) + 6 inboxes
idempotently; created a workspace, edited an inbox, and created an inbox
(type=workflow), each verified on re-pull and reverted cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…load)

Closes the last gap: a workflow's internal elements (steps) can now be
declared in the file and pushed, not just the shell.

- workflow-bpmn.ts: compile a declarative `steps` graph (read/update/action/
  condition/guidance/escalation/mcp/load-related/end) into BPMN XML β€” ported
  and verified against the exact request trace the Forest UI makes.
- LayoutManager: `uploadWorkflowBpmn` mirrors the UI β€” POST
  generate-presigned-request (forest-rendering-id header), multipart-upload the
  BPMN to the presigned S3 URL, read `x-amz-version-id`.
- apply: when a workflow carries `steps`, after the shell patch it compiles +
  uploads the BPMN and links the version via `bpmnAwsS3Identifier`. `steps` is
  stripped from the shell value (authoring input, not a server field). diff
  shows the planned compile/upload.

Validated live: created a workflow with a 3-step graph (read β†’ guidance β†’ end)
β€” shell created, BPMN compiled + uploaded to S3, `bpmnAwsS3Identifier` linked,
verified on re-pull, reverted cleanly. 67 unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…elds as removals

Two idempotency fixes surfaced by re-applying a file:

- Workflow BPMN: before uploading, compile the step graph and byte-compare it
  against the version currently stored (download via the presigned GET URL).
  Unchanged graphs are skipped β€” no new S3 version, no `bpmnAwsS3Identifier`
  patch. `diff`/`apply` only report a (re)upload when the BPMN actually differs.
  Relies on the compiler being deterministic + S3 storing the object verbatim.
- Scalar diff: a field absent from the local file now means "leave alone"
  (partial-authoring / patch semantics) instead of "remove" β€” which removes the
  misleading "this field cannot be removed" warnings when a hand-authored item
  omits server-managed fields. Clear a value explicitly with `null`.

Validated live: applying a workflow-with-steps file twice β€” first run creates
the shell + uploads the BPMN; second run is a clean no-op (no re-upload, no
warnings, "Nothing to apply"). 68 unit tests (compiler determinism covered).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… place

Addresses review on PR #776 (D1, T1):
- D1: matchItems() resolves a local item to a remote one by id, then by name,
  so a hand-authored item that omits its id updates the existing element instead
  of being treated as add + remove (silent data loss). Guards against ambiguous
  matches; header comment corrected.
- T1: unit tests for the orchestration core β€” sync.planWorkflowBpmn idempotency
  (unchanged / changed / new / unreadable) and stepWorkflows; plus a diff
  regression test for the name-only edit case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses Macroscope review on #777:
- Wrap the presigned S3 BPMN upload in try/catch -> toLayoutApiError, so S3
  failures surface as LayoutApiError like every other network call in the class
  (and are caught by the apply command handler) instead of a raw superagent error.
- Remove the unused `removable` flag from ScalarRule (and CHART_PROPS): it was
  dead (diffScalarLike treats an absent field as "leave alone"). Emitting a remove
  on omission, as the bot suggested, would reintroduce silent deletion and break
  partial-authoring. Clearing a scalar is done via an explicit null. Added a test
  locking the leave-alone behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jun 20, 2026

Copy link
Copy Markdown

PRD-592

PRD-555

if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
const first = parsed.errors[0];

return first.detail || first.message || JSON.stringify(body);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟒 Low layout/errors.ts:29

first.detail throws a TypeError when parsed.errors[0] is null or undefined, causing malformed API responses to crash the error handler instead of returning a detail string. Consider using optional chaining (first?.detail and first?.message) to fall back to JSON.stringify(body).

Suggested change
return first.detail || first.message || JSON.stringify(body);
return first?.detail || first?.message || JSON.stringify(body);
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/services/layout/errors.ts around line 29:

`first.detail` throws a `TypeError` when `parsed.errors[0]` is `null` or `undefined`, causing malformed API responses to crash the error handler instead of returning a detail string. Consider using optional chaining (`first?.detail` and `first?.message`) to fall back to `JSON.stringify(body)`.

Evidence trail:
src/services/layout/errors.ts lines 20-36 at REVIEWED_COMMIT: `extractDetail(body: unknown)` casts body to a typed shape without runtime validation. Line 26 checks `Array.isArray(parsed.errors) && parsed.errors.length > 0` but doesn't guard against nullish array elements. Line 27 `const first = parsed.errors[0]` can be null/undefined for inputs like `{errors: [null]}`. Line 29 `first.detail` would throw TypeError. The function is called from `toLayoutApiError` (line 47) with `body` sourced from HTTP response bodies (`superagentError.response?.body`), making the trigger condition reachable with malformed API responses.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants