Skip to content

Draw the workflow on the Designer tab with React Flow - #83

Open
knihit wants to merge 21 commits into
integration/workflow-canvasfrom
wc/05-canvas
Open

knihit wants to merge 21 commits into
integration/workflow-canvasfrom
wc/05-canvas

Conversation

@knihit

@knihit knihit commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Description

Canvas (workflow-canvas/src/canvas/, src/graph/):

  • The Designer tab now draws the workflow from the API, read-only, with React Flow (@xyflow/react 12.12.0, exact pin): the intent, each stage in list order with its agent and harness, and the end, joined by run-order edges. This is the first screen that uses the contract and the stub from the earlier PRs.
  • The graph is built by an IO-free core, src/graph/. buildGraph(workflow, harness) returns nodes and edges placed in one column. A test fails if the core imports anything but its own modules or reads a clock, randomness, the environment or the network. It is the same scan the stub core uses, now shared from test-support/purity.ts.
  • Nodes cannot be moved, selected or connected. The view can be panned and zoomed with the controls, and it starts zoomed out far enough to fit the 40 stages the API allows (minimum zoom 0.05; the README notes that text is small at that size).
  • Accessibility: every node and edge has a readable aria-label built in the graph core, React Flow's edit instructions are replaced by read-only wording, nodes can be reached with the Tab key, and the focus ring is 3px. The canvas colours live only in canvas.css.

Loading the workflow (src/api/, src/launch/, src/workflow/, src/designer/):

  • WorkflowApi is the port with the two calls the page makes; createHttpApi is its adapter over fetch. It ignores fields it does not know and turns a failed call into an ApiError with a message fit to show.
  • The page reads the launch code from the address (#<launch code>), removes it from the address bar, trades it for a token with POST /api/session and loads GET /api/workflow. The token is kept in the session storage of the tab, so a reload resumes (see the last section). The first load starts once, before React renders (createWorkflowSource), because a launch code works once and strict mode runs effects twice.
  • Loading, error and empty states are shown (empty covers no file yet, or a file with errors, which are listed). A diagnostic line that is not a whole number from 1 is left out, and an api_version other than 1 is refused before the workflow loads.

Docs and config:

  • workflow-canvas/README.md describes the canvas, the layers around it and how to open the page with a launch code. CHANGELOG has an entry under Unreleased. VERSION is untouched.
  • package-lock.json is updated for the new dependency.

How to run

cd workflow-canvas
npm ci
npm run dev:stub

dev:stub starts the stub API and Vite together and prints the launch code to open the page with. Ctrl+C stops both. Set WC_API_TARGET to point the page at another server that follows api/openapi.yaml.

Fixes from the hands-on QA run

  • A launch address pasted into the open tab opens a session in the background (watchLaunchCode), but only for a fragment shaped like a launch code (16 to 256 characters without whitespace; the API sets no alphabet), and the page changes to the new workflow only if that works. An anchor such as #some-anchor changes nothing. This is product code in src/launch/, src/workflow/ and src/main.tsx, with a README note and a CHANGELOG line.
  • e2e/tests/reload.spec.ts opens the launch address, reloads, and expects the workflow to load again with no request to POST /api/session.

Fixes from the adversarial review

  • A node keeps its whole name, agent and outcome in a title; an outcome too long for the intent node is also written out under the graph.
  • A reply with two stages of one name is refused instead of hiding a node.
  • The loading and error states are announced (role="status", role="alert").
  • The no-IO scan reads each file as TypeScript (see below).
  • The long-line check of the stub counts operations (n against 4n) and no longer uses a time limit, so load cannot fail it.
  • The port of the e2e page is WC_APP_PORT (default 4173), read in playwright.shared.ts only.
  • New e2e: 40 stages fit in the canvas; an anchor leaves the loaded workflow in place.

Type of Change

  • Dependency change (package.json, Cargo.toml, or CLI dependency)
  • Documentation
  • Other: the read-only workflow canvas, in workflow-canvas/

Testing

  • Root npm test (tests/scripts/**/*.test.js): not run; this PR changes only workflow-canvas/, the root CHANGELOG.md and its own docs.
  • If cli/ changed: not applicable
  • Smoke tested affected agent(s): not applicable, no agent changed
  • If an agent's or skill's behavior changed: not applicable
  • New or changed code files carry the required SPDX header (see AGENTS.md's License headers rule); formats without comment syntax (e.g. JSON) are exempt

Checked in workflow-canvas/: npm run lint, npm run typecheck (3 projects) and npm test (39 files, 706 tests) pass at 584cbe5, and so do the reload and policy e2e specs (14 tests, against the stub). The full e2e suite runs at the top of the stack (#97). The build prints only the existing chunk-size warning.

Tests added:

  • Unit tests of the graph core (nodes, edges, labels, order, 40 stages) and of the view constants (a 40-stage graph fits at about 0.08 zoom; an e2e test measures the real fit).
  • Tests of the HTTP client, the launch-code reader, the workflow loader (including the line and api_version rules), the Designer states and the settle hook.
  • stub-api/http/ui-wire.test.ts parses the real replies of the stub with the page's parsers, so the page and the contract cannot drift apart.
  • e2e: the canvas shows the intent, stages and end; the error state; an axe check of the canvas and the error state; and a test with no fixed waits that checks focus, accessible name, description and outline for all 4 nodes.

Screenshots

Designer tab, full page, light theme (regenerated by npm run screenshots):

Designer tab with the workflow drawn on the canvas

Checklist

  • My code follows the style guidelines of this project (see AGENTS.md's Code Style & Conventions)
  • I have performed a self-review of my own changes
  • I have commented my code where necessary
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings — build is clean
  • I have added or updated tests that prove my change works (see Testing above for the exact commands)

Notes for Reviewers

  • The base is integration/workflow-canvas, which already holds Add a stub API for the workflow canvas contract #81. The PR has 14 commits; the last three (6a38de2, b68a4ad, 7294fd9) answer the adversarial reviews.
  • Follow-ups: the Execution tab is still a placeholder; editing and saving a workflow on the canvas; running a workflow from the page.
  • Known limits: with 40 stages the text is small at the lowest zoom.

Fixes from the second adversarial review

  • A pasted launch code no longer reloads the page. It opens a session in the background and the page changes only on success, so a refused code leaves the workflow on the page (createWorkflowSource, watchLaunchCode; unit tests and e2e for a refused and an accepted code; a test that the first load runs once under strict mode).
  • Clipping counts drawn width, not characters, so Japanese text and emoji are listed in full under the graph; a stage name, agent and harness that a node cuts short are listed there too.
  • The no-IO scan reads files as TypeScript, so it catches aliases (const f = fetch), XMLHttpRequest, WebSocket, sendBeacon, storage, import.meta, animation frames and dynamic imports, tells a local name such as document from the global, and does not take // in a string for a comment. 33 of its new tests fail on the old scan.
  • Rejected: the duplicate and unused-export nits of the first report (no behaviour).

Fixes from the third adversarial review

  • A workflow that replaces the one on the page gets a new canvas, so it is fitted to the new graph (an e2e test swaps 2 stages for 40 and checks every node is in view).
  • Pasted codes settle by one rule: the newest workflow wins, and an error shows only while no workflow does, checked again after the last wait. A refused later code no longer drops an earlier success, and a late error no longer replaces a newer workflow.
  • A pasted launch code is any 16 to 256 characters without whitespace, so the code of the contract's own example and codes with +, /, = or . pass. A longer anchor is offered as a code and refused, and the page keeps the workflow.
  • Rejected: a message in the page for a refused paste (the workflow that stays is the answer); a prediction-free width check for clipping (it is a heuristic, title holds the full text, and realistic names and sentences are listed); the holes of the no-IO scan (the README says it guards against accidents and is not a sandbox); a timeout on the two calls (they get one in Read runs through the typed client and add the run core #115, and the launch code call has none in Run the workflow from the Execution tab with live node states #93).

Fixes from the second hands-on QA run

  • A refused launch address now shows a dismissible notice with the reason, and the open workflow stays (LaunchNotice; notice and dismissNotice on the workflow source). This reverses the earlier rejection of a message for a refused paste: nothing on screen said the code was refused. Unit tests for the source and the notice, and an e2e assertion.
  • Names that the server puts in backticks are shown as code, without the ticks, in the load error and in the problems of a file that cannot be drawn (MessageText).

Fixes: a reload keeps the session

  • The launch code works once and the page removes it from the address, so a reload ended in "no launch code". The page now keeps the session (token and harness, never the launch code) in sessionStorage, behind a small port (launch/session-memory.ts), and a load with no code in the address resumes from it. Script on the page can read the token. It lasts for the life of the tab, including copies made by Duplicate tab or window.open, and a browser may restore it with the tab; index.html carries a Content-Security-Policy that lets the page run only its own files. The token goes only to the same-origin /api, and nothing logs it.
  • A 401 on the kept token (the server was started again) clears it and shows "get a fresh launch address from the server". A pasted code replaces the kept session only once its workflow is on the page; a refused paste keeps the old one, and when several codes race, the newest shown wins (LoadResult.remember, called by createWorkflowSource).
  • STUB_STRICT=1 npm run dev:stub makes the dev stub follow the contract (the code works once). The stub that Playwright starts always does, so the e2e suite runs against a single-use code.
  • api/openapi.yaml: one sentence says a client may keep the token for the life of its browser tab, that it survives a reload, and that the client drops it on any 401. Nothing else in the contract changes.
  • Tests that failed before: session memory (key, shape, blocked storage), loadWorkflow (resume, 401 clears, a newer session is not forgotten, the code is never stored), the source (remember on show, replace, refused paste, racing codes), and e2e for reload, 401 after reload, a refused paste and "the launch code is not in storage". Hands-on on STUB_STRICT=1 npm run dev:stub in a browser: open, reload, restart the stub then reload, paste a new address, a refused paste, and a fresh tab.

Round 6: the session is kept as soon as a code opens it

  • A failed first load lost the session. If POST /api/session worked and the first GET /api/workflow then failed (timeout, 5xx), nothing was stored and the spent code left a reload with no way in. The session is now kept right after the code opens it when none is kept. A pasted code still replaces a kept session only once its workflow is shown, and a refused paste keeps it.
  • One guard for every 401. A 401 on any call that carries the token clears it (if no newer session was kept) and fails with the same "server no longer knows this session" message. Here it covers the load; the run calls (Read runs through the typed client and add the run core #115) and the save, check and reload calls (Save the workflow from the Designer, with checks while typing and conflict handling #100) use the same guard.
  • A spent code no longer beats a valid kept session. loadOnOpen tries the code, then the kept session, and shows the error of the code only if both fail.
  • Text: openapi.yaml and the README now say the token lasts for the tab, survives a reload, is copied by Duplicate tab and window.open, and is dropped on any 401. The advice is now "get a fresh launch address from the server".
  • CSP: a <meta> policy (default-src 'self', inline styles, data: images and fonts). It breaks nothing: the e2e suite and the dev server show no violation, and a test checks that an inline script is refused.
  • Tests that failed before: first load fails after the code works (unit and e2e reload), the old pinned test replaced, spent code with a kept session (unit and e2e), a 401 after the load, the CSP e2e. Hands-on in a browser (dev server and stub, STUB_STRICT=1): open, reload, a 500 on the first GET then reload, a 401 on save and on run start, a spent code with a kept session, a pasted spent code, and two tabs.

Round 7: a fresh address over a dead session, and the policy pinned

  • A fresh address could still lose its session. A tab held a session from an earlier server run, and the person opened a fresh launch address in it. If the first GET /api/workflow then failed (timeout, 5xx), the new session was not stored, the dead one was cleared on its 401, and a reload said "no launch code". The session of a code in the address now replaces the kept one as soon as the code opens it, unless a newer session was kept meanwhile. A pasted code still replaces a kept session only once its workflow is shown, and a refused paste keeps the old one.
  • A spent address says so. When the code in the address is refused and the kept session opens instead, the page shows the dismissible notice with the reason ("The open workflow is unchanged"). The lost-session and no-code messages now add that an address works once.
  • The policy is pinned. test-support/csp.test.ts compares every directive and source, so adding data: or blob: to a script source, or ws: to connect-src, fails. The policy gains base-uri 'none' and form-action 'none'.
  • Tests that failed before: loadOnOpen with a dead kept session and a failing first load, the notice (unit for the loader and the source), and an e2e test of the dead session. An existing test (a slow first open that answers after a pasted code) failed against a first draft of the fix, so the early store replaces only the session that was kept when the open began; a new test pins that case over a kept session.
  • Hands-on (production build, vite preview, strict stub restarted between steps): the old build ends in "no launch code" after the reload, the new build resumes. With the new policy, open and reload show no violation and no console error, an injected <base> and a form submit are each blocked, and the notice shows for a spent address over a kept session.
  • Not done: the font-src data: nit, the README wording of the inline script, and the old nits N-1 to N-7.

Round 8: a pasted address whose first load fails

  • A pasted address could still lose its session. The tab showed a workflow whose session had died (the server was started again), and a fresh address was pasted. The page kept the old session until the pasted workflow showed, as designed. When the first GET /api/workflow of the pasted session failed (a 5xx or a timeout), a reload sent the dead token, got a 401 and said to get a fresh address. The pasted code was already spent, so the session was lost for good.
  • Fix. The session of a pasted address now waits as a pending session under its own storage key (launch/session-memory.ts gains recallPending, holdPending and dropPending). A reload tries the pending session before the kept one. If it fails, the kept one opens with the refusal notice. Showing a workflow keeps its session and clears the pending one. A 401 on a token drops only that token, and a newer session kept meanwhile is not replaced.
  • Why this design. The kept session must not change before the pasted workflow shows, and the spent code leaves only its session to retry with, so that session needs a place that is not the kept slot. Probing the kept session after a failed paste adds a request and still loses the pasted session when the kept one is alive. Documenting the gap leaves the lockout. A second slot is the smallest change, and a reload now tries a short list of sessions instead of one.
  • Tests that failed before: the memory port (a pending slot beside the kept one), loadWorkflow and loadOnOpen (hold, promote, drop on 401, order, fallback and refusal text), the source (a pasted session resumed after its first load failed over a dead kept session), and an e2e test of the same case. The e2e test fails on the previous code at the reload.
  • Test gaps closed: the fallback resumes the session kept when the open began, not the one just made; a stale notice stays quiet after a pasted workflow was shown; the guards compare tokens, checked with two sessions of one harness. The README says a session of a code in the address replaces the kept one unless a newer one was kept meanwhile.
  • Hands-on (production build, vite preview, strict stub restarted between steps, free ports): the tab showed a workflow, the stub was restarted so its session was dead, a fresh address was pasted and its first GET /api/workflow answered 500 once. Before: the reload sent the dead token, got a 401, drew no node and cleared the storage. After: the reload sends one GET with the pasted session's token, gets a 200 and draws the 4 nodes. The same run on the build at the top of the stack (Test editing in the Designer end to end and add the changelog entry #97) gives the same result.
  • Not done: when a slow open and a refused paste overlap, the page shows the error of the paste and not that of the open. The session of the open is kept, so a reload recovers it.

Round 9: a late session no longer outranks the shown one

  • A late older session won on reload. Two addresses were pasted one after the other and the first session answered last. It was held as the pending session and a reload opened it, not the session the page shows. A session is now kept or held only if nothing newer was kept since its load began; otherwise it is dropped.
  • A brief failure no longer loses the pending session. Showing the kept session again leaves the pending one alone. It stays until the server answers 401 or a newer session replaces it.
  • A plain reload tells nothing. The "launch address was not opened" notice is for a code in the address only; a reload has no code.
  • Tests that failed before: unit tests for a late paste over a kept session, a late paste with nothing kept, a late open, and a pending session that a newer paste held meanwhile; two e2e tests (a late older paste, and a plain reload after a pending session failed). The surviving mutants (a pending session counted as kept, the first pending session winning, a pending 401 matched by harness) now fail a test.
  • Hands-on (production build, vite preview, STUB_STRICT=1): the old build resumed the late session on reload, the new build resumed the shown one. npm test (39 files, 706 tests), lint, typecheck and the reload e2e specs pass.

@knihit
knihit added this pull request to stack #79 October 25, 2026 05:07
@knihit
knihit marked this pull request as ready for review October 26, 2026 03:46
@knihit
knihit removed this pull request from stack #79 October 27, 2026 02:47
@knihit
knihit added this pull request to stack #101 October 27, 2026 02:47
@knihit
knihit removed this pull request from stack #101 October 27, 2026 02:48
@knihit
knihit added this pull request to stack #102 October 27, 2026 02:48
@knihit
knihit force-pushed the wc/05-canvas branch 5 times, most recently from e54a05a to 9fcbc48 Compare October 27, 2026 21:08
@knihit
knihit force-pushed the wc/05-canvas branch 2 times, most recently from 0fc7a25 to aeba79c Compare October 27, 2026 22:06
@knihit
knihit removed this pull request from stack #102 October 27, 2026 22:40
@knihit
knihit added this pull request to stack #117 October 27, 2026 22:42
Base automatically changed from wc/04-stub-api to integration/workflow-canvas October 28, 2026 14:31
Move the scan out of the stub core's test into test-support/purity.ts, parameterised by the rules of one core, so a second core can be held to the same rule. The scan's own tests move with it, unchanged.
Open a session with the launch code in the address, load the workflow, and draw the intent, each stage in list order and the end with React Flow, read-only. The graph is built by an IO-free core. Loading, error and empty states are shown.
Open one shared session in the Playwright global setup, because a launch code works once, and answer the page's session call with it. Add canvas tests, an axe check of the error state, and regenerate the Designer screenshot.
Give each node and edge a readable aria-label built in the graph core,
and replace the edit instructions of React Flow with read-only wording.
Zoom out far enough to fit the 40 stages the API allows. Leave out a
diagnostic line that is not a whole number from 1, refuse an API version
other than 1, and keep the canvas colours in canvas.css only. Read the
real replies of the stub with the page's parsers in a test.
The badge font shorthand named Arial, which each host resolves to a
different face, so the Designer screenshot differed between a local run
and CI. Inherit the page font so the capture is the same everywhere, and
refresh the screenshot.
The server may repeat an identical diagnostic, which gave two list items
the same key. Add the index to the key and test a repeated problem.
The test deep-compared two whole replies, so the HTTP Date header made it fail whenever the two GETs fell in different seconds. Compare the status and body instead.
Pasting the printed address into the same tab only changes the fragment,
so the page stayed on "no launch code". Reload on hashchange, and make
the reload e2e test open the address in the same tab.
…-proof test

- Reload on a pasted launch address only when the fragment has the shape of
  a launch code (22 to 256 URL-safe characters), so an anchor such as
  #some-anchor no longer throws the loaded workflow away.
- Keep the whole name, agent and outcome in a title on each node, and write
  out an outcome that is too long for the intent node under the graph.
- Reject a reply with two stages of one name instead of hiding a node.
- Announce the loading and error states with a status and an alert region.
- Make the Playwright port a setting, WC_APP_PORT (default 4173), read in
  one place.
- Replace the wall-clock limit on the long-line check with counts of
  operations, so load cannot fail it.
- Scan the no-IO cores without their comments, and catch window, document,
  storage, sockets and import.meta.env.
- Add an e2e check that 40 stages fit in the canvas, and fix the README note.
…, scan as TypeScript

- A launch code pasted into the open tab opens a session in the background.
  The page changes to the new workflow only when that works, so a refused
  code leaves the workflow on the page.
- Clip by drawn width, not by characters, so Japanese and emoji text is
  listed in full under the graph. The stage name, agent and harness are
  listed there too when a node cuts them short.
- Read the sources of the no-IO cores as TypeScript. Aliases, XMLHttpRequest,
  WebSocket, sendBeacon, storage, import.meta, animation frames, dynamic
  imports and a // inside a string are handled; a local name such as
  document is not taken for the global.
- Test that the first load runs once under strict mode.
…pt every code shape

A workflow that replaces the one on the page gets a new canvas, so it is fitted
again. Offers settle by one rule: the newest workflow wins, and an error shows
only while no workflow does, checked again after the last wait. A launch code is
any 16 to 256 characters without whitespace, as the API allows.
…code

A refused launch address left the open workflow in place and said nothing.
The page now shows a dismissible notice with the reason. Names that the
server puts in backticks are shown as code, without the ticks.
The launch code works once and the page removes it from the address, so a
reload ended in "no launch code". The page now keeps the session token and
harness in sessionStorage under one key, behind a small port, and a load with
no code in the address resumes from it.

- A pasted code replaces the kept session only when its workflow is shown. A
  refused code keeps the old one.
- A 401 on the kept token clears it and asks for a fresh launch address.
- The launch code is never stored.
- STUB_STRICT=1 makes dev:stub follow the contract (the code works once). The
  stub that Playwright starts always does.
…ode is spent

A first load that failed after the launch code worked left no stored
session, so a reload had no code and no token. The session is now kept
when the code opens it and none is kept; a pasted code still replaces a
kept session only once its workflow is shown.

A 401 on any call that carries the token now clears it, through one shared
guard. A spent code in the address no longer beats a valid kept session:
the session is tried first, and the error of the code is shown only if
both fail.

The page gets a Content-Security-Policy meta tag (own files only). The
README and API text now say the token lasts for the tab, is copied by
Duplicate tab and window.open, and is dropped on any 401. The advice after
a lost session is to get a fresh launch address from the server.
…orm targets

The test now compares every directive and source, so adding data: or blob: to a script source, or ws: to connect-src, fails. The policy gains base-uri 'none' and form-action 'none', which default-src does not cover.
A tab that held a session from an earlier server run lost the new session when the first load failed: the dead one was cleared on its 401 and a reload found nothing. The session of a code in the address now replaces the kept one as soon as the code opens it, unless a newer session was kept meanwhile. A spent address that falls back to the kept session now says so in a notice, and the messages say an address works once.
…wn session is dead

A pasted launch address opens a session, but the page keeps the shown session until the pasted workflow is on screen. If the shown session had died and the first load of the pasted one failed, a reload tried only the dead session and the spent address was lost.

The session of a pasted address now waits as a pending session under its own key. A reload tries the pending session before the kept one, falls back to the kept one with a notice if it fails, and showing a workflow keeps its session and clears the pending one.

The tests now use two sessions with the same harness, and pin which session the fallback resumes and that a stale notice stays quiet.
…he load began

A pasted address whose session answers after a newer paste was kept no longer
becomes the pending session, so a reload resumes the session the page shows.
A pending session that failed briefly stays until it gets a 401 or a newer
session replaces it, and a plain reload no longer tells that an address was
not opened.

This branch has not been deployed

No deployments
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