Skip to content

Latest commit

 

History

History
760 lines (682 loc) · 44.9 KB

File metadata and controls

760 lines (682 loc) · 44.9 KB

Zigflow Studio — Design Doc

Zigflow Studio — a drag-and-drop editor for Zigflow workflows.

Naming. The product is called Zigflow Studio. Refer to it as "Zigflow Studio" or "Studio" throughout the UI and docs — never "Zigflow Editor" or "the editor". (Some prose below still says "the editor" as a common noun; treat "Zigflow Studio"/"Studio" as the canonical name going forward.)

  • Stack: SvelteKit (adapter-node), TypeScript, SvelteFlow (@xyflow/svelte)
  • Source of truth: the generated Zigflow YAML. The graph is a view of it, never an independent model.
  • Scope of this doc: the PoC. Sections marked (future) are decided in shape but explicitly not built yet.

This document exists so that decisions made once don't need to be re-derived in every session or every tool (chat, Claude Code, a new contributor). Where a decision has a rationale that isn't obvious, the rationale is included — future-you (or future-Claude) should be able to tell why, not just what.


1. What Zigflow is, and why that shapes the editor

Zigflow is a declarative DSL that compiles YAML into Temporal workflows. It's a deliberate subset of the Open Workflow Specification (OWS) — formerly "Serverless Workflow," renamed as of mid-2026; use "Open Workflow Specification" in written docs, either name is fine in conversation.

Canonical references:

  • Schema: https://zigflow.dev/schema.yaml / https://zigflow.dev/schema.json (pinned versions under /schemas/<version>/)
  • AI-friendly reference: https://zigflow.dev/llms.txt
  • MCP server (read-only, useful for validation during development): https://mcp.zigflow.dev

1.1 The critical structural fact: this is a tree, not a graph

A workflow is:

document: { dsl, taskQueue, workflowType, version, ... }
do: [ { stepName: { <one of 11 task types> } }, ... ]

do is positional — array order is execution order, not free-form edges. Of the 11 task types (call, do, for, fork, listen, raise, run, set, switch, try, wait):

  • do, for, fork, try nest child task lists inline (do.do, for.do, fork.branches, try.try / try.catch.do). These are genuinely tree-shaped and get a "drill into sub-canvas" UI, matching Zigflow's own editor (breadcrumb: Order Processing › Switch › Electronic Branch).
  • switch does not nest anything. Each case is { when, then } — no nested do. then is a flow directive: continue (default), exit, end, or the name of another task in the same scope (a goto). The branches you see in Zigflow's UI under a Switch are ordinary sibling tasks elsewhere in the same list, reached via jump — not children of the Switch node.
  • Any task's then can do this same goto, not just Switch's. In practice the editor only exposes authoring a goto for Switch (its whole purpose is branching), but must preserve an arbitrary then: taskName on any other task if one is present in loaded YAML, even without a dedicated editor for it.

Consequence: the editor does not treat SvelteFlow as a general graph tool with a "compile graph → tree" step. That direction is lossy and hard to keep valid. Instead:

  • The canonical model is one in-memory ZigflowWorkflow tree.
  • SvelteFlow renders a projection of whichever single scope (root, or inside a Fork/For/Try) the user has drilled into — see §3.
  • Edits (add/remove/rename/reorder task) are direct mutations of the tree at a resolved scope path. There is no separate graph representation to reconcile.
  • Switch is rendered as an annotation (then: taskName in its inspector, optionally a dashed "goto" edge overlay for visualization), not a container with branches inside it.

1.2 Always multi-workflow

Zigflow's do task supports two root-level shapes: a single implicit workflow (document.workflowType names it), or multiple named workflows (do: [{ workflow1: { do: [...] } }, { workflow2: { do: [...] } }], where document.workflowType is ignored and each name is unique in scope).

Decision: the editor always uses the multi-workflow shape, even for a single workflow — do: [{ myWorkflow: { do: [...] } }] is valid and functionally identical to the single-workflow form. This removes an entire mode/branch from the editor: there's no "does every top-level task happen to be bare do" ambiguity to detect or guard against, and no separate single-vs-multi UI path.

Sync rule: document.workflowType is derived, not user-edited. On every save, set it to the name of the first entry in the root do list. If the user renames the first workflow, or reorders so a different one is first, workflowType follows. Renaming a workflow that isn't first has no effect on workflowType.

No special handling for an empty project (do: [], or do: [{ w: [] }]) — these are valid workflows that just don't do anything yet. Do not add defensive UI to prevent or warn about this; per project philosophy (§7), avoid getting in the user's way for cases the schema itself allows.


2. Data model

2.1 Types

src/lib/types/zigflow.ts defines ZigflowWorkflow, ZigflowDocumentMeta, Task (a union of the 11 task-kind interfaces), TaskList, NamedTask ({ [name: string]: Task }), and shared building blocks (Duration, RuntimeExpression, FlowDirective, Schema).

These mirror the OWS spec almost exactly. In an environment with package registry access, generate/import these from @open-workflow-specification/sdk-typescript rather than hand-maintain them — they were hand-written only where that wasn't available. Nothing in the graph/mutation/store layers should depend on how the types are produced, only on their shape, so swapping the source is a types-only change.

2.2 Zigflow vs. OWS: the one divergence

Per zigflow/zigflow PR #340 ([BREAKING]: replace name and namespace with taskQueue and workflowType), Zigflow's document renames OWS's fields:

OWS Zigflow
namespace taskQueue
name workflowType

Nothing else — not the task tree, not any other document field — diverges. src/lib/mapper/document.ts provides toOwsDocument() / fromOwsDocument(), a pure bidirectional rename, used only at boundaries where OWS-shaped tooling is needed. The app's internal model, storage format, and API always use Zigflow's field names — the mapper is not on the hot path.

2.3 Node identity: __zigflow_id

Zigflow's schema already reserves metadata.__zigflow_id on every task ("system-generated... should not be modified by users") — the editor uses this directly rather than inventing its own convention. It's how a task's identity survives rename/reorder/reload across an editing session.

ensureTaskIds(workflow) walks the whole tree once and assigns an id to any task missing one. Call it:

  • Once, right after a workflow is loaded from disk (covers hand-authored or third-party YAML with no ids).
  • Once, right after any tree mutation that introduces new tasks with pre-seeded children (e.g. a new Try/Catch node's placeholder steps — see §4.4) so a freshly added container can be drilled into immediately.

3. Graph ↔ tree projection

src/lib/graph/:

  • model.tsFlowNode / FlowEdge / FlowGraph types; a ScopePath (array of { taskId, label, field } steps) locating which TaskList is currently on screen. Empty path = the workflow root do list.
  • scope.tsresolveScope(workflow, path) walks a ScopePath from the root and returns the target TaskList plus a setter closure. Also siblingNames() (for Switch then dropdowns) and findById(). A step's field is load-bearing specifically for try, which owns two child lists (try and catch.do) that taskId alone cannot tell apart; for do/for/fork there is a single child list, so field is redundant but harmless.
  • treeToGraph.ts — pure projection: one scope's TaskList → SvelteFlow nodes + edges. Three layouts:
    • sequential (do/for/try bodies): vertical stack, array-order position, solid edges between consecutive nodes. A for body is entered with field: 'do', the same as a plain do — a for task carries both for (config) and do (body), and only the do list is a drill-in scope. Task classification therefore checks for before do, so a loop is not misread as a plain do.
    • parallel (fork.branches): horizontal lanes, no edges between them (they run concurrently, not in sequence).
    • independent (the root scope, empty ScopePath): vertical stack of unconnected cards — no edges and no connection handles. Top-level workflows are independent (§1.2): array order only feeds the workflowType sync rule, never execution order, so any edge or connector dot between them would wrongly imply a pipeline. Handles are suppressed via a showHandles flag on the node data (false only for this layout). goto edges can't arise here anyway — Switch can't appear at root under the root-restricted-to-do rule (§6).
    • Plus derived, informational goto edges for Switch cases whose then names a sibling task — dashed, never written back to the tree (sequential/parallel only; never at root). These are derived for Switch cases only. §1.1 notes any task's then can be a goto; such a then is preserved verbatim in the tree (it stays authoritative), but only Switch's is drawn as an edge — a non-Switch goto has no visual.
  • mutations.ts — the only functions that change the tree: renameTask, updateTaskBody, addTask, removeTask, moveTask, ensureTaskIds, syncWorkflowType. All operate on a resolved TaskList reference from resolveScope. These are pure array/object operations, so they re-render the canvas only when applied to a reactive tree: the editor holds the loaded workflow as reactive state (Svelte 5 $state) at the page/store level, not as a plain loaded value. Reactivity is a property of where a mutation is applied (the reactive proxy) — the functions themselves are unchanged and stay plain, testable tree operations.
  • defaults.ts — a minimal, schema-valid starting body for each task kind, used when a node is added from the palette. Note: try/catch need a seeded placeholder step in both try and catch.do (unlike do/for, which allow an empty list) because the schema requires minItems: 1 there — an empty Try node would fail validation the instant it's created.

Reordering is explicit (move up/down controls on each node), not free XY dragging — node position is auto-laid-out from array order and has no independent meaning, so there's nothing for free dragging to mean. This matches Zigflow's own UI, which shows a fixed vertical chain with a "+ Add Node" affordance rather than a freeform canvas.


4. Validation

Single source of truth: src/lib/schema/validate.ts compiles the bundled zigflow.schema.yaml with Ajv (2020-12 dialect) and exports validateWorkflow(doc). This is the only validation path in the app — called from the save API route, and available to any client-side pre-check that wants it. There is deliberately no separate, hand-maintained set of "UI validation rules" to keep in sync with the schema.

The bundled schema is always sourced from the Zigflow CLI (zigflow schema -o yaml, via the update-schema script), never from the public website, and with no fallback. The CLI is the final authority on what a workflow must satisfy; bundling from any other source risks drift between "whatever the editor validates against" and "whatever the CLI/runtime actually enforces", which is precisely what this single-source rule exists to prevent. If the CLI is unavailable, update-schema fails loudly rather than refreshing from a second source — the devcontainer and GitHub Action install the CLI. Note this covers pure JSON-Schema-expressible rules only — jq expression syntax, determinism rules (uuid/timestamp/now only valid inside set), and task-name-uniqueness-within-scope are additional Zigflow CLI (zigflow validate) checks not expressible as JSON Schema, and are out of scope for this editor's validation layer. The CLI remains the final authority before a workflow actually runs.

One narrow exception is enforced at the mutation layer rather than here: renameTask and addTask (§3) reject a name that already exists in the exact TaskList being written to. It isn't schema-expressible, but the editor's own resolution logic (resolveScope/findById/siblingNames, §3) looks tasks up by name within a scope, so a duplicate name in one scope actively breaks that logic — unlike the sparse-but-valid states §7 leaves alone. The guard is deliberately narrow (exact name, exact list); all broader uniqueness and semantic checks still defer to zigflow validate.


5. Storage & persistence

5.1 PoC: plain fs

src/lib/server/workflowStore.ts defines a WorkflowStore interface (list, load, save, remove, exists); FsWorkflowStore is the only implementation, reading/writing <ZIGFLOW_STORAGE_DIR>/<project>/workflow.yaml. Persistence is entirely the operator's concern (e.g. a Kubernetes PVC behind that env var) — the app has no opinion about it beyond the interface. This is the seam a future S3-backed store, or a store that also triggers a Git push on publish, plugs into without touching API routes.

5.2 YAML parsing

src/lib/yaml/serialize.ts uses yaml (eemeli/yaml), not js-yaml — chosen specifically because yaml also exposes a Document/CST API that preserves comments, key order, and formatting. Today this module does plain parse-to-object / stringify-from-object (no more capable than js-yaml would be), but if lossless round-tripping becomes a requirement, upgrading is a one-file change rather than a library swap.

5.3 History (future)

Not built in the PoC. Agreed shape for when it is: one sibling file per project, map[isoTimestamp]: workflow (a plain object keyed by ISO 8601 timestamp string, value = the full workflow at that point). ISO 8601 keys sort correctly as plain strings, so no separate ordering field or timestamp parsing is needed to list history chronologically. save() is the seam this hangs off. Retention/pruning policy is undecided and doesn't need to be until this is actually built.

5.4 Publish / GitOps (future)

Not built in the PoC — "Save" only ever writes to disk; it does not touch document.version (that's a Publish-time concern only). When built: Publish pushes to a Git repo/branch configured via app config (a config file, populated by the platform's Kubernetes setup — not env vars, per current preference), gated behind a commit-message dialog. GitOps is expected to be the primary strategy, possibly the only one, though the WorkflowStore-style seam should allow a future S3 (or similar) alternative.

5.5 Testing a workflow (future)

Not built in the PoC. When a "Test" button exists, its production execution path dispatches each run to a separate container (e.g. a Kubernetes Job), never the app's own server process. Arbitrary, user-submitted workflow execution must stay isolated from the serving container.

The zigflow CLI binary present in the production Docker image is a convenience for non-Kubernetes deployments and manual debugging — it is not the Test feature's actual execution path. Nobody reading the Dockerfile later should assume the serving container runs user workflows in-process.

5.6 Explicitly out of scope

No auth, no multi-user conflict detection, no locking. Last write wins. This is intentional — a platform team's own auth layer (e.g. Dex) sits in front of this app; it doesn't belong here unless/until there are paying customers who need it.


6. UI structure

  • Project list (/workflows) — list existing workflows, create a new one. / redirects here.
  • Display naming — one rule, to stop the project-name mixup. What a human is shown as a "name" is never the on-disk project/directory name; that is a storage/routing identifier, not a display name.
    • A workflow project's display name is document.title if present, otherwise document.workflowType (both live on ZigflowDocumentMeta). Never the on-disk directory name. That directory name does still govern URLs (/workflows/[name]/...), so it may still need to appear somewhere (e.g. a subtitle or secondary detail), just never as the primary displayed name. GET /api/workflows returns this resolved as { name, displayName } per workflow (routing name + display name); the project list renders displayName as the link text and shows name as a muted caption only when the two differ. The editor header (and the browser tab <title>) follows the same rule — it shows the resolved display name, not data.name; the routing/directory name is surfaced as a "Directory" row in the read-only details sidebar (its acceptable secondary-detail home).
    • An individual task in any do list — including a top-level workflow, which is itself just a task under the root do (§1.2) — is always referred to by its own name: the string key of its { [name]: Task } entry (§2.1), never any other property. This is already how the breadcrumb and canvas label tasks; it is stated here as the general rule so future work doesn't reintroduce the project-name mixup.
  • Editor (/workflows/[name]/[...scope]) — three-pane layout:
    • Left: two stacked sections. Top (primary, most of the height): a node palette — the single list of which kinds can be added in the current scope. Each kind is one chip that is both clickable (appends a node of that kind to the end of the current scope — the discoverable, keyboard-accessible path) and draggable onto the canvas (same append; a mouse accelerator). There is deliberately no separate dropdown/"Add" control — the chips are the one place kinds are listed. At the root scope the palette offers only do (empty ScopePath ⇒ every root entry must be a do-kind workflow, §1.2); deeper scopes offer all of TASK_KINDS. The heading/hint are scope-aware ("Add a workflow" at root vs "Add a node" deeper). Below (demoted, smaller): the "Workflow Details" panel, now partly editable — see the document-field rule below.
    • Center: the canvas — breadcrumb (scope path) + SvelteFlow view of the current scope + "+ Add Node" control. Dropping a palette chip anywhere on the canvas always appends to the end of the current scope's list — the drop position is never read, so this introduces no free XY placement and array order stays the only ordering (§3); reordering is the inspector's move-up/move-down controls. At the root scope the canvas draws the top-level workflow cards with no edges and no connection handles between them — they are independent (§1.2), so array order is meaningful only for the workflowType derivation, never for implying sequential execution; the connecting line + connector dots appear only at non-root scopes (a do/ for/try body). The breadcrumb's root segment is a generic, translated "Workflow" label (not the project name — that already sits in the header, and the root scope's label is a distinct identifier from the project name); every later segment is the actual scope-path task name and is clickable to navigate back to that level (the second-to-last segment is always the immediate parent scope).
    • Right: inspector for the selected task (or workflow-level document metadata when nothing's selected). Workflow-global save/validation errors are not shown here — they render as a banner below the header (see Save & dirty state below).
  • Document-field editability (the "Workflow Details" panel). Edits write directly into the same reactive workflow object task edits use, so they ride the existing dirty/save() plumbing with nothing new added. Per field:
    • taskQueueeditable (required by the schema).
    • title, summaryeditable, optional: clearing the input removes the property entirely rather than storing "" (same convention as the inspector's optional fields, writeForTask in forms/forForm.ts).
    • tags — editable per schema but a key/value map; a dedicated editor is deferred as a follow-up, not wired here yet. (Its values are unknown, not just strings, so the Set task's string key/value form can't be reused verbatim without risking lossy coercion — it needs its own typed editor.)
    • versionread-only for now; only ever set at Publish time (§5.4, not built), so no editor until then.
    • dslnever user-editable.
    • workflowTypederived, never hand-edited (§1.2). Surfaced as a read-only "Workflow Type" row in the panel (alongside Directory/Version/ DSL) with a muted note that it's auto-derived from the first top-level workflow's name and any direct edit is overwritten on save. It is edited only indirectly, by renaming the first root workflow (see the Inspector relabel below).
    • Directory (the routing/filesystem name) is not a document field at all and stays permanently read-only.
    • Rendering: the panel (WorkflowDetails.svelte) is server-rendered like the rest of the page — no {#if browser} gate. An earlier note here claimed a "latent hydration issue" whereby SSR'd form inputs (here and in the inspector via a ?selected= deep link) never got their oninput handlers wired, worked around by making the panel client-only. Root-causing it proved that claim false: in a production build the inputs hydrate and bind correctly every time, sequentially and under parallel load. The symptom only ever appeared against a cold Vite dev server hit by concurrent first-requests — a dev-server compile race (the on-demand SSR module graph served partially before it finished), i.e. a Playwright-parallel-workers test artifact, not an app bug. The fix was to delete the workaround; nothing in the inspector/CommonFieldsForm was ever gated (their "client-only" reputation was the same misdiagnosis). Only Canvas.svelte stays client- gated, for the real reason that SvelteFlow needs the DOM to measure/lay out.
  • Scope lives in the URL ([...scope], one rest-param route — a zero- segment match is the root, so it also covers /workflows/[name]). This is what makes refresh, back/forward, and shared links open the drilled-into scope instead of always the root. The scheme:
    • Each segment is a task name (unique within its scope per the §4 mutation-layer guard). For do/for/fork the name alone suffices — the child list is implied by the task's kind (do/fordo, forkbranches). A try owns two child lists, so its name segment is followed by a literal try or catch selector, e.g. a URL ending …/orderProcessing/fulfilOrder/try (or …/fulfilOrder/catch). (try/catch are therefore reserved: a task named exactly try/catch can't be a scope segment — a fine trade for the readable grammar.)
    • scope.ts provides the pure scopePathToUrlSegments / resolveUrlSegments inverse pair. The route's load resolves the URL to a ScopePath for first-paint (no root-then-jump flash); the page then re-resolves the same segments against its live (possibly-edited) workflow, so drilling into a just-added, unsaved container still works. Drill-in and breadcrumb navigate via goto(); a rename of a task in the open path rewrites the URL with replaceState (one edit, not a navigation).
    • Names, not ids, in the URL — deliberately, for readable and shareable links. The trade-off: a rename breaks any previously shared deep link into that task (names aren't stable identity — __zigflow_id is, but ids are kept out of the URL for readability). A stale or malformed link falls back to the root scope with a brief notice rather than 404-ing the whole page.
    • Selection rides in a ?selected=<taskName> query param on the same route (name-based, same rename-invalidates-old-links trade-off as scope segments), resolved against the current scope's list. It's a query, not a path segment — selection is "which item in the current scope is focused," not a different view — and not a hash fragment: a hash never reaches the server, so it couldn't drive the SSR first paint and would reintroduce the flash-of-no-selection the scope fix removed. Selecting/deselecting/renaming update it via replaceState, not goto/pushState, so browser history records scope changes only, not every click; changing scope drops it. A stale or absent name is silently "nothing selected" (a valid state) — no notice, unlike a bad scope segment.
  • Node component (TaskNode.svelte) — a card per task: icon/kind glyph, name, one-line subtitle (method+endpoint for call, duration for wait, etc.). The card is informational: single-clicking it selects the task (showing it in the inspector). All per-task actions — move-up/move-down/delete, and drill-in for containers — live in the inspector (see below), shown only for the selected task, so the canvas stays clutter-free. Drilling into a container's sub-canvas (do/for/fork/try) is an explicit inspector button, never the single card click: a single click cannot disambiguate select from drill, and try in particular exposes two drill targets — its try body and its catch handler — that need distinct buttons.
    • Double-click as a drill accelerator. Container nodes (do/for/fork/try) also support double-click as a shortcut for the inspector's Open action — it opens the same sub-canvas the Open button does. The explicit inspector button(s) remain the discoverable/accessible primary path; double-click is a mouse-only accelerator layered on top. For try, double-click deliberately opens the try body (not catch) — catch stays reachable only via its own inspector button. Non-container kinds have nothing to drill into, so double-click just re-selects them. It is detected in the canvas via SvelteFlow's own node-click event (event.detail === 2), not a DOM handler on the card: SvelteFlow/d3-zoom stop dblclick propagation before it reaches Svelte's delegated listener root, and selecting on the first click must not rebuild the node (which recreated the card mid-gesture and swallowed the second click) — so selection rides a context-driven CSS class instead of the nodes array. A node double-click does not zoom the pane (SvelteFlow only zooms on background double-clicks); the empty-pane double-click-to-zoom is unchanged.
  • Selection is UI-only state, held in the editor layer and never part of the workflow tree (per the UI/domain split in §"Editor architecture" of AGENTS.md). It is cleared whenever the visible scope changes — drilling into a container, or navigating via the breadcrumb — because a selection pointing at a node that's no longer on screen is worse than no selection.
  • Save & dirty state. Save PUTs the in-memory workflow to PUT /api/workflows/[name], the single validation gate (§4), triggered by a Save button or Cmd/Ctrl+S. (On-disk save only; Publish/GitOps stays future — §5.4.)
    • Dirty state is a snapshot comparison — serialized current workflow !== serialized last-saved workflow — not a flag toggled by each mutation. This is more robust: an edit followed by an undo back to the saved state correctly reads as clean, and no future mutation entry point can forget to set a flag. A successful save adopts the server-returned (id-filled, workflowType-synced) workflow as the new saved snapshot, so client and disk don't drift.
    • Save/validation errors render as a full-width banner below the header, not in the inspector pane. They are workflow-global — a list of schema error paths that may span several tasks — so the inspector, which is about the one selected task, is the wrong home for them. Each error shows its raw JSON-pointer path and message plus a derived "in {task}" hint naming the enclosing task. A failed request (network/server) gets its own distinct message, never conflated with "the workflow is invalid".
    • The "in {task}" hint is bounded by how Ajv reports against this schema. The task union uses oneOf + unevaluatedProperties, so an invalid nested task typically surfaces as errors at the enclosing container boundary (e.g. a bad wait reported on /do/0/orderProcessing rather than .../waitForPayment/wait). The hint therefore names the nearest task the path resolves to, which may be a container, not the deepest offending field. That granularity is why the PoC shows a flat list of paths + hints rather than click-to-navigate-to-the-exact-field — the schema's own error reporting wouldn't reliably support the latter. Revisit only if precise error localization becomes a priority.
  • Inspector forms — dedicated forms exist for call (a call-type selector; see below), set (a key/value form with a per-entry value type — see below), wait, switch (case list with when/then), and for. The http form (see the call sub-bullet) now surfaces the full with: method/endpoint/output/redirect/body/headers/query. The write path keeps the endpoint's loaded shape — a bare-string endpoint (URI template or expression) stays a bare string rather than being re-wrapped as { uri: … } (which was pure diff noise), and an object endpoint is spread so only uri is overwritten. (The schema's endpoint object allows only uriunevaluatedProperties: false — so object-spreading is defensive: it avoids silently discarding any hand-authored extra key, leaving the Save validator to reject it rather than losing it quietly.) See writeHttpTask in forms/callHttpForm.ts. fork/try show a pointer to their sub-canvas rather than an inline branch/step editor. A set given as a single expression string (the less common form) falls back to the generic JSON editor rather than getting its own form — proportionate to how rarely that form is used, not a gap to close by default. raise/listen/run/other call sub-types likewise fall back to a JSON textarea for now — the pattern for adding a dedicated form is established and should be extended over time. Switch then authoring is scoped to continue/exit/end plus same-scope sibling task names (via siblingNames()). Loaded YAML may contain a then naming a task in a different scope (a cross-scope goto); that value is preserved on load and save but is not editable through the dropdown — editing such a field would coerce it to an in-scope option. A documented limitation, revisited only if cross-scope gotos prove common in practice.
    • call form (type selector). CallForm wraps a Call type selector (HTTP / gRPC / Activity, default HTTP — matching the palette's new-call default). HTTP renders the dedicated sub-form; gRPC and Activity render the shared read-only JSON fallback until their own forms land (§8). Switching type changes only call + with (TaskBase preserved), and a component-local per-type with cache means switching away and back restores the prior shape rather than discarding it — nothing about the "mode" is persisted on the task. The single http-vs-fallback shape guard lives inside CallForm (not duplicated in the inspector's registry dispatch), mirroring WaitForm's self-contained duration/until switch. The HTTP sub-form now edits method + endpoint plus output (raw/content/response dropdown; the content default is written as omitted), redirect (checkbox — the schema declares no default, so unchecked omits the property and only checked writes true), and body (a JSON textarea accepting any JSON value; blank omits, invalid JSON keeps the last valid, like the schema fields). headers/query are edited by a shared name/value map editor (HttpMapField, used for both): a plain key/value list (values are always strings — no type selector), empty omits the property, and duplicate keys are last-wins. A whole-value runtime-expression headers/query has no structured editor — shown read-only (edited in YAML), mirroring set's map-vs-expression split (isMapField). The http form is now complete.
      • method — select of common verbs + "Other". The HTTP method field is a dropdown of the nine general HTTP methods (HTTP_METHODS — a Studio-only convenience constant, cross-referenced to IANA/MDN/Go) plus an "Other" option that reveals a free-text input. This is deliberately not a schema enum: the schema's method stays an unconstrained string, so a free-typed verb can't fail the workflow's own validation (an "Other" escape hatch would contradict a hard enum). Which option shows is inferred once at load (inferMethodOption: exact match → that option, else "Other" with the raw text in the free-text field) — the same one-time, load-time-only pattern as inferSetValueType, never re-inferred mid-edit, with no persisted mode; choosing "Other" pre-fills the current value rather than blanking it. Whatever text is in the selected/free-text field is written as method verbatim.
    • set per-entry value types. Each set entry carries an explicit value type — string (default), boolean, number, null, json — chosen in a selector beside its key, not inferred from the value's text on save. This fixes a real bug in the earlier content-sniffing write path: a literal string like "42" or "true" silently became the number 42 / boolean true, with no way to store the string. The type drives both the value input (plain text; a true/false select; a number input; no input for null; or a JSON textarea for json) and read/write: string stores the text verbatim, boolean/number/null store the real JS primitive, and json parses the textarea — any valid JSON value (object, array, or a bare scalar), with invalid JSON keeping the last valid value, matching (and labelled "JSON" like) the schema textareas. json deliberately uses a raw JSON textarea, not a recursive nested picker — set values are unbounded JSON, and a recursive picker would re-solve the generic-schema- form problem the per-kind registry was chosen to avoid, only rescoped to one field; the swappable read/write contract (a key maps to whatever JS value results) leaves a nested picker open later. On load, each entry's type is inferred once from the value's actual JS type for display (a plain object and an array both infer as json); after that it is explicit and user-controlled, never re-inferred. Logic (including the load-time inference, kept separate from the write path) lives in forms/setForm.tsreadSetEntries, writeSetTask, inferSetValueType, coerceSetValueForType — unit-tested. A set given as a single expression string still routes to the JSON fallback rather than this form.
    • Root-scope workflow relabel. For a do task at the root scope (a top-level workflow entry — reusing the same atRoot detection the palette's root restriction uses, §6/§1.2), the rename field is labelled "Workflow Type" instead of "Name", with a note that it sets this workflow's own type. It stays fully editable via the same renameTask call — only the label/copy change. This uses a dedicated message key inspector_workflow_type_label (never inspector_name_label), deliberately marking that a root workflow's name is its Temporal workflow type, not a cosmetic label swap. It differs from the read-only Workflow Type row in Workflow Details: that row only ever mirrors the first workflow (§1.2), so renaming a second/third root workflow here doesn't change that document field.
    • Common TaskBase fields (CommonFieldsForm.svelte, a collapsed "Advanced" section shown for every kind, below the kind-specific form). Every task extends taskBase, so these are shared: if (a runtime- expression guard); input.schema, output (as + schema), export (as + schema); and metadata (a heartbeat Duration reusing wait's duration inputs — integer-only per the schema — plus a generic plaintext key/value list for other entries). Conventions reused, not reinvented: optional fields are removed when cleared (like Title/Summary); as uses a string-vs-object parse (parseSetValue); each schema is an arbitrary embedded JSON-Schema doc edited as a JSON textarea (invalid JSON shows an inline error and keeps the last valid value). The metadata key/value list never exposes or overwrites metadata.__zigflow_id (§2.3) or heartbeat, and preserves any non-string metadata entries untouched. The read/write logic lives in editor/commonFields.ts (read/writeCommonFields, writeMetadata, writeThen), unit-tested.
    • Task-level then ("On completion") is a dropdown at the bottom of the inspector, reusing thenOptions(siblingNames); continue (the default) drops the property. It is hidden for a root do-workflow — top-level workflows are independent (§1.2), so a goto between them is meaningless; any then present on such a workflow in loaded YAML is preserved without exposing an editor (§1.1). (Distinct from Switch's per-case then.)
  • Internationalisation (i18n). All user-visible text in the UI must go through an i18n library — there are no hardcoded strings in components. For this PoC only two locales are supported: en (US English, and the fallback for anything unmatched) and en-GB. Locale is determined solely from the browser's Accept-Language header, resolved server-side per request — there is no in-app language switcher and no locale persistence to build yet. A non-base locale file must carry only values that differ from the base (en); byte-identical duplicates (and orphan keys the base lacks) are rejected by npm run check:locales (scripts/check-locale-duplication.mjs), wired into npm run check and CI so the cleanup can't silently regress. This reads the base/locale list from the inlang config, so new locales are covered automatically. This is a binding constraint on every component-building step from here on (inspector forms, canvas, node palette, …): new components must be authored against the i18n library, not with inline English. Follow-up: the components already built in earlier steps (Canvas, Inspector, TaskNode, page routes) currently hold hardcoded English strings and must be retrofitted — tracked as follow-up work (§8), not done in the pass that introduced this rule.
  • i18n library — Paraglide JS v2 (@inlang/paraglide-js). Chosen over svelte-i18n because its preferredLanguage strategy resolves the locale from Accept-Language server-side (as required above) and is request-safe via AsyncLocalStorage, whereas svelte-i18n's module-global locale store has a known SSR cross-request leak. Two costs come with this choice:
    • Paraglide is a compiler with generated output (gitignored), so a compile step must run before type-checking — already wired via an i18n:compile script fronting check/check:watch.
    • Message keys must be flat and kind-suffixed (kind_*, subtitle_*, …) and selected with explicit switch statements, never dynamic lookup (m[key]()), because dynamic lookup defeats Paraglide's tree-shaking. This constrains how future inspector-form message keys should be organized.

7. Design philosophy notes

A few recurring principles worth stating explicitly, since they've guided several decisions above and should guide future ones:

  • Don't get in the user's way for things the schema itself allows. Empty do: [], a Try node that's momentarily just placeholders, a workflow with one task — these are all valid, so the editor shouldn't add defensive friction (warnings, blocked states) around them just because they look sparse. Spend effort on genuine invalid states, not on states that are merely minimal.
  • One central place for each kind of rule. One validator (schema-driven, §4), one place node identity is assigned (§2.3), one place then options are computed (sibling names + directives, used identically by Switch cases and any task's own then). Avoid parallel, hand-maintained copies of a rule that need to be kept in sync by discipline rather than by construction.
  • Prefer decisions that are cheap now and don't foreclose future options. A smaller, reversible choice today beats a larger, load-bearing one made on a guess about tomorrow's requirements.

8. Open items

Deliberate, tracked deferrals — choices made knowingly, with the follow-up recorded here, rather than gaps left by oversight (§7, "Don't get in the user's way").

Inspector forms

The task-form registry (taskForms, §6) gives every task kind a home; kinds without a dedicated editor yet point at the shared read-only fallback (a JSON view, or the "open the sub-canvas" hint for container kinds). Adding an editor is then a one-line swap of that kind's registry entry. Deferred, in intended order (the per-entry value-type selector that led this list is done — see §6, "set per-entry value types"):

  • set — dot-notation keys (post-PoC). Typing data.key to build { "data": { "key": … } } is deferred. Open questions to resolve before it is built:
    • how an existing/loaded nested set value is displayed back — flattened to dot-paths, or fall back to the raw JSON editor;
    • collision behaviour when a path and its parent are both set (e.g. data.key and data);
    • whether the path syntax must support array indices, or object nesting only.
  • set as a whole-value runtime expression. set may be a single RuntimeExpression string instead of a key/value map (SetTask.set: Record | RuntimeExpression). Only the map form has a dedicated editor; an expression-form set falls back to the read-only JSON view, and the inspector guards on the map form so editing can't clobber an expression. A map-vs-expression toggle folds into the set audit above.
  • call — dedicated gRPC/Activity forms. CallForm now hosts all three call types behind a Call type selector (§6), but only HTTP has a real sub-form; call: grpc and call: activity still render the read-only JSON fallback. Their dedicated forms (grpc: proto/service/method; activity: name/taskQueue) are deferred — switching to those types today writes an empty with that the user completes in YAML or a future form.
  • fork/try/do, and raise/listen/run — no dedicated form. Container kinds (fork/try/do) are edited by drilling into their sub-canvas, so their inspector slot shows the sub-canvas hint rather than a form. raise/listen/run have no structured editor yet and show the read-only JSON view.

View generated YAML

A future "view generated YAML" control that renders the whole Zigflow document as YAML, for when a decomposed form view (like set's per-entry Inspector rows, §6) isn't enough and someone wants to see the actual output directly — e.g. to sanity-check what a Save will write, or to read the document while debugging a validation failure before hitting Save.

Decided:

  • It must render through the same serializer the Save path already calls, stringifyWorkflowYaml (src/lib/yaml/serialize.ts) — never a second, independent YAML serialization. What the user previews has to be exactly what Save writes; a parallel serializer could silently drift (§4, the single validation/serialisation path; §7, "one central place for each kind of rule").

Open (to decide when it's actually built):

  • Live vs. saved source. Show the live in-memory workflow (reflecting unsaved edits), or only the last-saved on-disk version? Leaning live — "see what my edit is about to produce" is the more useful case — but not decided.
  • UI placement & affordances. A toolbar button vs. somewhere else; a modal vs. a side panel; whether it needs a copy-to-clipboard convenience. Not decided.

i18n retrofit

  • Components built before the i18n rule (Canvas, Inspector, TaskNode, page routes) are moved onto Paraglide as they're touched; any residual hardcoded English is tracked here rather than fixed in unrelated passes — see §6, "Internationalisation".