Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a99dfb2
Add the stub API core: workflow check, store and simulated runs
knihit Oct 24, 2026
4487554
Serve the stub API over HTTP on the loopback address
knihit Oct 24, 2026
8b03a97
Test the stub API over HTTP against the contract
knihit Oct 24, 2026
cf3dc1c
Start the stub API in the Playwright config and in CI
knihit Oct 24, 2026
bcaec3b
Document how to run the stub API
knihit Oct 24, 2026
f974e89
Make the core purity test catch randomness and clocks
knihit Oct 24, 2026
074e1d1
Report a second YAML document where it starts, in linear time
knihit Oct 24, 2026
a8d7098
Share the error factories and guard the codes against the contract
knihit Oct 24, 2026
87c6803
Test the bound address, and let the dev server reach the stub
knihit Oct 24, 2026
4428365
Bring the workflow canvas README up to date with the stub API
knihit Oct 24, 2026
37e5cd0
Document the API target switch in the README and changelog
knihit Oct 24, 2026
415ae0c
Make the API backend switchable with WC_API_TARGET
knihit Oct 24, 2026
b28a2f8
Fail only the commands that need the API target, and hide secrets in …
knihit Oct 24, 2026
be83f21
Test dev:stub, escalate to SIGKILL, and fix the stub hint
knihit Oct 24, 2026
7e3a12b
Keep npm test free of the API target, and redact every credential form
knihit Oct 24, 2026
7fd6635
Keep the host when redacting, and tidy the wording
knihit Oct 24, 2026
b76a747
Mask the whole value when a password holds both an @ and a /
knihit Oct 24, 2026
551886b
Let the dev stub take the launch code again, so a reload is not a dea…
knihit Oct 27, 2026
56b7569
Send the target's Origin only for requests from the dev page
knihit Oct 27, 2026
597e326
Add the stub API changelog line
knihit Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/workflow-canvas.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
# until 26 becomes LTS on 2026-10-28.
#
# The e2e job runs the Playwright tests in Chromium on Node 24 only (the
# browser, not Node, is what they exercise) and also regenerates the
# browser, not Node, is what they exercise), starts the stub API beside the
# app (its config is in playwright.shared.ts) and also regenerates the
# screenshots, which must match the committed files. Both the HTML report and
# the screenshots are uploaded whether the tests pass or fail.
#
Expand Down Expand Up @@ -75,6 +76,9 @@ jobs:
- name: API contract (also part of Test, run alone for a clear label)
run: npm run api:lint

- name: Stub API (also part of Test, run alone for a clear label)
run: npm run test:stub

- name: Build
run: npm run build

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ not per individual commit.
- `workflow-canvas/`, a browser UI for designing and running workflows (Vite, React, Cloudscape), with CI.
- Playwright end-to-end tests and screenshots for `workflow-canvas/`, run in CI.
- HTTP API contract for the workflow canvas (`workflow-canvas/api/openapi.yaml`), checked by `npm run api:lint`.
- Stub API for the workflow canvas (`npm run stub`, `npm run dev:stub`); `WC_API_TARGET` points the page at a real server.

### Changed

Expand Down
141 changes: 127 additions & 14 deletions workflow-canvas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,20 @@ A browser UI for designing and running Konductor workflows. This folder is an
independent npm project (Vite, React, TypeScript, Cloudscape) and is not part
of the agent package or the CLI.

Currently an empty page with Designer and Execution tabs. Features land in
follow-up changes.
The page is a shell with Designer and Execution tabs. It does not call the
API; the contract, the stub that serves it and the tests around both are in
this folder.

## Layout

- `src/`: the UI.
- `api/`: the HTTP API contract (`openapi.yaml`) and its test.
- `stub-api/`: a TypeScript stub server for the contract, for local
development and tests.
- `e2e/`: Playwright tests (`tests/`), the screenshot capture (`capture/`),
shared page objects and helpers (`support/`) and the committed PNGs
(`screenshots/`).

Directories added in later PRs:

- `stub-api/`: a TypeScript stub server for local development and end-to-end
tests, started by the same Playwright `webServer` config.

A single `package.json` serves all of the above.

## Commands
Expand All @@ -28,14 +26,17 @@ Run from this folder with Node 24.15 or later (`.nvmrc` selects 24):

```bash
npm ci
npm run dev # Vite dev server on http://127.0.0.1:5173
npm run lint # ESLint and Prettier check
npm run dev # Vite dev server on http://127.0.0.1:5173, /api goes to WC_API_TARGET or the stub
npm run dev:stub # The stub and the Vite dev server together; Ctrl+C stops both
npm run build # production build into dist/
npm run lint # ESLint and Prettier check
npm run typecheck
npm test # Vitest, including the API contract test
npm run api:lint # Only the API contract test
npm run build
npm run test:e2e # Playwright, headless Chromium
npm run screenshots # regenerate e2e/screenshots/*.png
npm test # Vitest: UI, API contract and stub API
npm run api:lint # Only the API contract test
npm run stub # The stub API on http://127.0.0.1:5174
npm run test:stub # Only the stub API tests
npm run test:e2e # Playwright, headless Chromium, against the stub or WC_API_TARGET
npm run screenshots # Regenerate e2e/screenshots/*.png
```

## API contract
Expand Down Expand Up @@ -83,6 +84,118 @@ know, and requests with a field the contract does not name are refused.

CI runs it on each pull request, and `npm test` includes it.

## Stub API

`stub-api/` serves the six calls of `api/openapi.yaml` from memory, so the UI
and the real backend can be built against the same contract. It is a local
development and test stub with no real auth: the launch code and the session
token only exercise the session flow. It listens on `127.0.0.1` only and keeps
nothing on disk, so a restart brings back the sample data.

```bash
npm run stub
# Workflow canvas stub API (local development only, no real auth)
# Listening on http://127.0.0.1:5174
# The stub serves no page. With `npm run dev` running, open:
# http://127.0.0.1:5173/#<launch code>
```

The launch code is new each time and works once, within 60 seconds of the
moment the stub starts listening. `POST /api/session` trades it for the token
that the other calls send as `Authorization: Bearer <token>`. `STUB_PORT`
sets the port (a whole number from 1 to 65535; anything else stops the command
with a one-line error). `STUB_LAUNCH_CODE` sets the code, for tests only: it can be
weaker than the contract asks.

The contract has the code work once. That would make a reload of the page a
dead end when you develop: the reload drops the token and the code from the page,
and the printed address no longer works. So the stub of `npm run dev:stub` (it
gets `--with-dev-server`) and the one that Playwright starts take the code
again, with no time limit, and give the same token. Open the printed address
again after a reload. `npm run stub` on its own follows the contract: once,
within 60 seconds.

The data covers the first release: a workflow of an intent and stages, and the
`kiro-cli` harness. It is a `hello` workflow with a `draft` stage
(`k-developer`) and a `review` stage (`k-quality-assurance`). A saved change
lasts until the stub stops. A run is faked and calls no agent: its stages run
for 3 seconds each, one after the other, and all of them pass.

### Run the page against the stub

Run `npm run dev:stub`. It starts the stub and `npm run dev` together, and
Ctrl+C stops both. Or start the stub in one terminal and `npm run dev` in
another. Then open the address that the stub prints. The dev server forwards
`/api` to the stub (`vite.config.ts`), so the page and the API share one
origin. The stub sends no CORS headers and accepts only its own `Origin`, so
the proxy swaps the page's `Origin` (`http://127.0.0.1:5173` or
`http://localhost:5173`) for the stub's. Any other `Origin`, or none, goes
through unchanged and the stub refuses a POST or PUT with a 403, so a page on
another origin cannot use the dev server to call the API. The page does not
call the API, so try it with `curl`, and send the `Origin` of the page:

```bash
curl -s -X POST http://127.0.0.1:5173/api/session \
-H 'Origin: http://127.0.0.1:5173' \
-H 'Content-Type: application/json' -d '{"code":"<launch code>"}'
```

The launcher sends SIGTERM to the other command when one exits, and SIGKILL
to a command that is still running 5 seconds later. If the launcher itself is
killed with SIGKILL, the stub and Vite may keep running; stop them yourself.

## Choose the API backend

The page always calls the relative `/api`. Only the proxy target changes, and
one variable sets it: `WC_API_TARGET`, an origin such as
`http://127.0.0.1:8080`. When it is unset, the target is the stub. A bad value
(not an http or https origin, or one with a path) stops the command that needs
it (`dev`, `preview`, `test:e2e`, `screenshots`) with a one-line error that
hides any user, password or query in the value. `build` and `npm test` never
read it, so a mistyped value in your shell cannot fail them (the tests of the
Playwright config set it themselves). `stub-api/target.ts` is the only place
that reads it.

- Stubbed dev: `npm run dev:stub`
- Real dev: `WC_API_TARGET=http://127.0.0.1:8080 npm run dev`
- Stubbed e2e: `npm run test:e2e`
- Real e2e: `WC_API_TARGET=http://127.0.0.1:8080 npm run test:e2e`

`dev:stub` ignores `WC_API_TARGET`, since it starts the stub. For real e2e,
Playwright does not start the stub, so start the real server first with the
launch code `e2e-launch-code`, and run the tests within 60 seconds of it
listening. The launch code works once, so restart the server before each run;
a second run against the same server fails with a 401.

The proxy sends the `Origin` of the target in place of the page's own, and
forwards any other `Origin` unchanged. A real server must pass the same Origin
check as the stub (refuse a POST or PUT from any other `Origin`), and the same
`POST /api/session` step from `api/openapi.yaml`, where the launch code is
traded for the token that the other calls send. The e2e and contract tests then show whether the real server
answers as the stub does.

### Code

The code is in two parts, so the rules can be tested without a server:

- `core/`: the workflow check, the file store and the run simulator. It does
no IO and takes its clock as a parameter. A test fails if a core file
imports anything but its own modules, `yaml` and `createHash`, or reads the
clock, randomness or the environment.
- `http/`: a thin adapter on `node:http` that checks headers and bodies and
hands the rest to the core.

`npm run test:stub` runs the tests. Those in `http/` start the stub on a free
port, call it over HTTP and check each reply against the schema of the
contract. They move a fake clock by hand, so none of them waits. A test
compares the error codes, diagnostic codes, limits and patterns of the stub
with the contract, so a change to one without the other fails.

Playwright starts the stub beside the app (`stub-api/web-servers.ts` holds its
launch code and the server list), with `--with-dev-server` so that a test can
open the page again with the same code. So an end-to-end test can call it. With
`WC_API_TARGET` set it does not, and the same tests call that server instead.

## End-to-end tests

The tests in `e2e/tests/` drive the production build in headless Chromium.
Expand Down
42 changes: 42 additions & 0 deletions workflow-canvas/e2e/tests/stub-api.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: Apache-2.0
import { expect, test } from '@playwright/test';
import { API_TARGET, API_URL, STUB_LAUNCH_CODE } from '../../playwright.shared';

// Shows that a test can reach the API: the stub that the Playwright config
// starts, or the server named by `WC_API_TARGET`. No page calls it.
test('the API gives a session for the launch code of the config', async ({
request,
}) => {
const reply = await request.post(`${API_URL}/api/session`, {
data: { code: STUB_LAUNCH_CODE },
headers: { Origin: API_URL },
});
expect(reply.status()).toBe(200);
expect(await reply.json()).toMatchObject({
root: 'hello',
harness: 'kiro-cli',
api_version: 1,
});
});

// A reload drops the token and the launch code from the page, so the person
// opens the printed address again. The stub of `dev:stub` and of these tests
// takes the code again. A server that follows the contract to the letter does
// not, so this test is for the stub only.
if (API_TARGET.isStub) {
test('the stub takes the launch code again and gives the same session', async ({
request,
}) => {
const trade = async () =>
request.post(`${API_URL}/api/session`, {
data: { code: STUB_LAUNCH_CODE },
headers: { Origin: API_URL },
});

const first = await trade();
const again = await trade();

expect(again.status()).toBe(200);
expect(await again.json()).toEqual(await first.json());
});
}
3 changes: 2 additions & 1 deletion workflow-canvas/e2e/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["node"]
"types": ["node"],
"allowImportingTsExtensions": true
},
"include": ["."]
}
19 changes: 18 additions & 1 deletion workflow-canvas/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion workflow-canvas/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@
},
"scripts": {
"dev": "vite",
"dev:stub": "node stub-api/dev.ts",
"stub": "node stub-api/main.ts",
"build": "vite build",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p e2e/tsconfig.json",
"lint": "eslint . && prettier --check .",
"format": "prettier --write .",
"test": "vitest run",
"api:lint": "vitest run api/openapi.test.ts",
"test:stub": "vitest run stub-api",
"test:e2e": "playwright test",
"screenshots": "playwright test --config playwright.screenshots.config.ts"
},
Expand Down Expand Up @@ -44,6 +47,7 @@
"typescript": "6.0.3",
"typescript-eslint": "8.72.0",
"vite": "8.3.5",
"vitest": "5.0.5"
"vitest": "5.0.5",
"yaml": "2.9.1"
}
}
32 changes: 16 additions & 16 deletions workflow-canvas/playwright.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,22 @@ import {
type PlaywrightTestConfig,
type Project,
} from '@playwright/test';
import { apiTargetOrExit } from './stub-api/target';
import {
APP_URL,
STUB_LAUNCH_CODE,
webServersFor,
} from './stub-api/web-servers';

export { STUB_LAUNCH_CODE };

const HOST = '127.0.0.1';
const APP_PORT = 4173;
const APP_URL = `http://${HOST}:${APP_PORT}`;
/**
* Where the API listens during a run: the stub, or the server named by
* `WC_API_TARGET`. This is the only place that reads the variable for
* Playwright; the server definitions are in `stub-api/web-servers.ts`.
*/
export const API_TARGET = apiTargetOrExit();
export const API_URL = API_TARGET.origin;

/**
* One viewport and render mode for every project. With the pinned font and the
Expand All @@ -34,17 +46,5 @@ export const sharedConfig: PlaywrightTestConfig = {
// A flaky test must fail the run, not pass on a retry.
retries: 0,
use: { baseURL: APP_URL, trace: 'retain-on-failure' },
// Servers the tests depend on, started before and stopped after the run.
// The app is served from a production build, as users get it. A backing
// service the app calls gets its own entry here, so one command starts
// everything.
webServer: [
{
name: 'app',
command: `npm run build && npx vite preview --host ${HOST} --port ${APP_PORT} --strictPort`,
url: APP_URL,
reuseExistingServer: false,
timeout: 120_000,
},
],
webServer: webServersFor(API_TARGET),
};
38 changes: 38 additions & 0 deletions workflow-canvas/stub-api/address.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from 'vitest';
import { DEFAULT_PORT, originOf, portOf, stubPort } from './address.ts';

describe('stubPort', () => {
it('is the default port when STUB_PORT is unset or empty', () => {
expect(stubPort({})).toBe(DEFAULT_PORT);
expect(stubPort({ STUB_PORT: '' })).toBe(DEFAULT_PORT);
});

it.each([
['1', 1],
['80', 80],
['6000', 6000],
['65535', 65535],
])('reads STUB_PORT=%s as %i', (value, port) => {
expect(stubPort({ STUB_PORT: value })).toBe(port);
});
});

describe('originOf', () => {
it('names the port, except the default port of http', () => {
expect(originOf(5174)).toBe('http://127.0.0.1:5174');
expect(originOf(80)).toBe('http://127.0.0.1');
});
});

describe('portOf', () => {
it.each([
['http://127.0.0.1:5174', 5174],
['http://127.0.0.1', 80],
['http://127.0.0.1:80', 80],
['https://api.example.test', 443],
['https://api.example.test:8443', 8443],
])('reads %s as port %i', (origin, port) => {
expect(portOf(origin)).toBe(port);
});
});
Loading
Loading