Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ not per individual commit.
- Reloading the canvas page resumes the open workflow from a session kept for the tab; a restarted server asks for a fresh launch address.
- A spent launch address in the tab falls back to the kept session and says so, and a first load that failed, of an opened or a pasted address, can be retried with a reload.
- The workflow canvas uses the Konductor docs theme, with light and dark modes, and system colours in forced-colors mode.
- The Execution tab runs the workflow and shows live node states.

### Changed

Expand Down
60 changes: 50 additions & 10 deletions workflow-canvas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ stub that serves it and the tests around both are in this folder.
IO; `api/` is the typed client of the API; `launch/` and `workflow/` open the
session and load the workflow; `designer/` and `canvas/` draw it;
`execution/` runs it and shows the run; `execution/core/` does no IO and reads a run
once a second. A read that fails with no answer or a server error is tried
once a second. A read that fails with no answer, a server error, 408 or 429 is tried
again, up to three in a row; any other failure ends the following. A start
that finds a run already active follows that run. Every run call takes a
signal, and every call ends on its own after 15 seconds, so a server that
never answers cannot hold the tab. A failure counts only for the run it belongs to.
signal, and every call but the launch code trade ends on its own after 15
seconds, so a server that never answers cannot hold the tab. A failure counts only for the run it belongs to.
- `api/`: the HTTP API contract (`openapi.yaml`) and its test.
- `stub-api/`: a TypeScript stub server for the contract, for local
development and tests.
Expand Down Expand Up @@ -295,6 +295,38 @@ intent, because the API needs one (1 to 2048 characters) to start a run.
Run is off and the goal box is read-only from the click until the run ends.
Read-only, not greyed out, so the box keeps focus and shows the goal of that
run. Enter in the box starts a run, like the button.
- If the server answers 409 `already_running`, as it does when a run from
another tab is going, the tab follows the run named in that answer instead
of starting one. The line under the button says so, and the goal typed is not
used for that run. The API does not give the goal of a run, so while the tab
follows a run that was already going the Goal box is empty, says "Not known"
and is labelled so. When that run ends, the status line still says it was
already in progress.
- Resume works after a reload, though the launch code works once: the page
resumes from the session it kept (see above). If the server was started
again, the kept token is refused and the page asks for a fresh launch
address; the run goes on unseen. The tab keeps the id of the latest run in
the storage of the browser tab (`sessionStorage`; the id is no secret) and
drops it once the run has ended or turns out to be gone. When the page
opens again with a session it reads that run, trying again like any read. If the run is still running, the
tab follows it as if the person had joined it, without a click on Run. A run
that has ended, one that the server will not give (for instance after it
started again) or a browser that blocks storage leaves the tab ready for a
new run. If the readings fail for another reason, such as no answer, the tab
does not know whether the run is going, and shows the alert for a run that
may still be going. Pressing Run while the page still looks for the run
stops the look and starts a run; a run that is going answers 409 and is
joined.
- Pasting another launch address into the open tab lets go of the run: the
tab stops following it, shows no run status and forgets its id. The run goes
on at its server. The Goal box and Run belong to the workflow that is open.
- A read of the run that fails is tried again after a second; the tab gives up
after three failures in a row. Only a missing answer, a 5xx, 408 or 429 is tried again:
any 4xx or a reply the page cannot read shows the alert at once. A call that
gets no answer in 15 seconds counts as a failure, except the one that trades
the launch code, which has no limit because the code works once. A start that
gets no answer, or is accepted in a reply the page cannot read, says that the
run may not have started and that Run joins it if it did.
- Each node shows a badge with a word and a colour: **Queued** (with a dashed
outline), **Running**, **Done** and **Failed**. The word carries the state;
only Queued and Not run also differ in shape. The intent is Done once the run
Expand All @@ -314,8 +346,10 @@ intent, because the API needs one (1 to 2048 characters) to start a run.
alert. The goal is one line: line breaks in the outcome of the intent become
spaces, and Enter that ends an input method composition starts no run.
- A workflow can be saved while a run goes on, so the run may have other stages
than the graph. Node states attach by stage name, so a warning above the
graph names the stages that are not drawn and those that are not in the run.
than the graph. The graph is read once, when the page loads, while the run
works from the saved file. Node states attach by stage name, so a warning
above the graph names the stages that are not drawn and those that are not in
the run.
- A run goes on while the Designer tab is open, and the tab shows it again when
you come back, with the goal box as it was: what you typed, or the goal of
the run in flight. Closing the page stops the reading, not the run.
Expand All @@ -332,8 +366,12 @@ The code follows the ports-and-adapters split of the graph:
fails if the core imports anything but its own modules, the graph core and the
wire types, or reads a clock, randomness, the environment or the network.
- The adapters are `createHttpApi` (`startRun`, `getRun`), which `loadWorkflow`
ties to the session token, and `timerScheduler`. The token is held only by
those run calls.
ties to the session token, `timerScheduler`, and `sessionRunMemory`, which
keeps the id of the latest run for `resumeRun`. The token is held only by
those run calls. `createHttpApi` ends a call after a timeout, except the launch code call,
which works once and so is never cut short, and
`loadWorkflow` turns the 409 `already_running`, which carries the `run_id`,
into the core's `RunAlreadyActive` so the core can follow that run.
- `useExecution` keeps the state in `App`, and `Execution` draws it. The
Designer and Execution tabs share `WorkflowGate` (loading, error and empty
states) and `WorkflowGraphCard` (the card with the graph).
Expand Down Expand Up @@ -419,9 +457,11 @@ cannot be run, and in both theme modes.

The stub runs one run at a time and never fails a stage. So the one test that
starts a real run on it (`execution.spec.ts`, the first run test) waits for the
end of the run, and the tests of a failed stage or a refused run answer the
run calls themselves (`e2e/support/run-routes.ts`). A test that starts another
run on the stub must also wait for its end, or the next test gets a 409.
end of the run, and the tests of a failed stage, a refused run, a joined run
and a dropped read answer the run calls themselves (`e2e/support/run-routes.ts`).
A test that starts another run on the stub must also wait for its end, or the
next test gets a 409. For the same reason, if that one test fails and Playwright
retries it, the retry gets a 409 and follows the run still going.

Shared page objects and helpers live in `e2e/support/`. New tests should
extend them rather than repeat selectors: import `test` and `expect` from
Expand Down
37 changes: 37 additions & 0 deletions workflow-canvas/e2e/support/run-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,40 @@ export async function refuseRunsWith(
route.fulfill({ status, json: body }),
);
}

/**
* Makes the page's call to start a run answer 409 `already_running` with the
* id of a run, and its reads answer with `reply`, as a server does while a
* run from an earlier page load is still going.
*/
export async function joinActiveRunWith(
page: Page,
reply: ReturnType<typeof runReply>,
): Promise<void> {
await page.route('**/api/run', (route) =>
route.fulfill({
status: 409,
json: {
error: 'A run is already active.',
code: 'already_running',
run_id: RUN_ID,
},
}),
);
await page.route('**/api/runs/*', (route) => route.fulfill({ json: reply }));
}

/**
* Makes the first read of a run fail as if the connection dropped, then
* answers every read with `reply`.
*/
export async function dropFirstReadThenAnswerWith(
page: Page,
reply: ReturnType<typeof runReply>,
): Promise<void> {
let reads = 0;
await page.route('**/api/runs/*', (route) => {
reads += 1;
return reads === 1 ? route.abort() : route.fulfill({ json: reply });
});
}
147 changes: 147 additions & 0 deletions workflow-canvas/e2e/tests/execution.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { expect, test } from '../support/fixtures';
import {
answerRunsInTurn,
answerRunsWith,
dropFirstReadThenAnswerWith,
joinActiveRunWith,
refuseRunsWith,
runReply,
} from '../support/run-routes';
Expand Down Expand Up @@ -61,6 +63,33 @@ test('runs the workflow on the stub: each stage goes from queued to running to d
await expect(canvas.runError).toHaveCount(0);
});

test('resumes the run that is going when the page is reloaded, though the launch code works once', async ({
canvas,
}) => {
const sessionCalls: string[] = [];
await canvas.runButton.click();
await expect(canvas.badge('node-stage-draft')).toHaveText('Running', {
timeout: STEP_TIMEOUT_MS,
});
canvas.page.on('request', (request) => {
if (request.url().endsWith('/api/session'))
sessionCalls.push(request.url());
});

await canvas.page.reload();
await canvas.title.waitFor();
await canvas.selectTab('Execution');

await expect(canvas.runStatus).toContainText(
'Following the run that was already in progress.',
);
await expect(canvas.runButton).toBeDisabled();
await expect(canvas.badge('node-end')).toHaveText('Passed', {
timeout: STEP_TIMEOUT_MS * 2,
});
expect(sessionCalls).toEqual([]);
});

test('announces the status in a polite live region and names the state of each node', async ({
canvas,
}) => {
Expand Down Expand Up @@ -139,6 +168,42 @@ test('shows the error of the API in one alert when the run does not start', asyn
await expect(canvas.runButton).toBeEnabled();
});

test('follows the run that is already active when the server answers 409 with its id', async ({
canvas,
}) => {
await joinActiveRunWith(
canvas.page,
runReply('running', ['running', 'pending']),
);

await canvas.runButton.click();

await canvas.expectBadges(['Done', 'Running', 'Queued', 'Queued']);
await expect(canvas.runStatus).toContainText(
'Following the run that was already in progress. Running draft.',
);
await expect(canvas.runError).toHaveCount(0);
await expect(canvas.runButton).toBeDisabled();
});

test('keeps following the run when one read of it fails', async ({
canvas,
}) => {
await answerRunsWith(
canvas.page,
runReply('running', ['running', 'pending']),
);
await dropFirstReadThenAnswerWith(
canvas.page,
runReply('passed', ['passed', 'passed']),
);

await canvas.runButton.click();

await canvas.expectBadges(['Done', 'Done', 'Done', 'Passed']);
await expect(canvas.runError).toHaveCount(0);
});

test('says the run may still be going when it can no longer be followed', async ({
canvas,
}) => {
Expand Down Expand Up @@ -254,3 +319,85 @@ test('starts the run when Enter is pressed in the goal box, and keeps focus ther
await expect(canvas.runStatus).toContainText('Running draft.');
await expect(canvas.goal).toBeFocused();
});

test('shows the run that is still going when the page is opened again', async ({
canvas,
}) => {
await answerRunsWith(
canvas.page,
runReply('running', ['running', 'pending']),
);
await canvas.runButton.click();
await expect(canvas.runStatus).toContainText('Running draft.');

await canvas.page.reload();
await canvas.title.waitFor();
await canvas.selectTab('Execution');

await canvas.expectBadges(['Done', 'Running', 'Queued', 'Queued']);
await expect(canvas.runStatus).toContainText(
'Following the run that was already in progress. Running draft.',
);
await expect(canvas.runButton).toBeDisabled();
await expect(canvas.goal).toHaveValue('');
await expect(canvas.goal).toHaveAttribute('placeholder', 'Not known');
});

test('shows Ready when the page is opened again after the run has ended', async ({
canvas,
}) => {
await answerRunsWith(canvas.page, runReply('passed', ['passed', 'passed']));
await canvas.runButton.click();
await canvas.expectBadges(['Done', 'Done', 'Done', 'Passed']);

await canvas.page.reload();
await canvas.title.waitFor();
await canvas.selectTab('Execution');

await expect(canvas.runStatus).toContainText('Ready. Press Run to start.');
await canvas.expectBadges([null, null, null, null]);
await expect(canvas.runButton).toBeEnabled();
});

test('has no Execution tab after a reload when the server no longer knows the session', async ({
canvas,
}) => {
await canvas.expectWorkflowDrawn();
await canvas.page.route('**/api/workflow', (route) =>
route.fulfill({
status: 401,
json: {
error: 'The session token is missing or wrong.',
code: 'unauthorized',
},
}),
);

await canvas.page.reload();
await canvas.title.waitFor();

await expect(
canvas.page.getByText(/server no longer knows this session/),
).toBeVisible();
await canvas.selectTab('Execution');
await expect(canvas.runButton).toHaveCount(0);
});

test('drops the session when the run start is answered 401, so a reload does not reuse it', async ({
canvas,
}) => {
await refuseRunsWith(canvas.page, 401, {
error: 'The session token is missing or wrong.',
code: 'unauthorized',
});

await canvas.runButton.click();

await expect(canvas.runError).toContainText(
'The server no longer knows this session',
);
await expect(canvas.runError).toContainText('fresh launch address');
expect(await canvas.page.evaluate(() => window.sessionStorage.length)).toBe(
0,
);
});
6 changes: 5 additions & 1 deletion workflow-canvas/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import './chrome/chrome.css';
import { TOP_BAR_ID, TopBar } from './chrome/TopBar';
import { Designer } from './designer/Designer';
import { useSettled } from './designer/use-settled';
import type { Scheduler } from './execution/core';
import type { RunMemory, Scheduler } from './execution/core';
import { Execution } from './execution/Execution';
import { RunAnnouncements } from './execution/RunAnnouncements';
import { timerScheduler } from './execution/timer-scheduler';
Expand All @@ -29,11 +29,14 @@ export function App({
load,
notice,
scheduler = timerScheduler,
runMemory,
}: {
load: Promise<LoadResult>;
/** Shown above the tabs, such as the refusal of a pasted launch address. */
notice?: ReactNode;
scheduler?: Scheduler;
/** Keeps the latest run across a reload; a page without it starts afresh. */
runMemory?: RunMemory;
}) {
// Read here, not in the tab, so switching tabs does not load again.
const result = useSettled(load);
Expand All @@ -42,6 +45,7 @@ export function App({
const execution = useExecution(
result?.ok ? result.workflow.runs : undefined,
scheduler,
runMemory,
);
const [goal, setGoal] = useGoal(result);
const [mode, toggleMode] = useThemeMode();
Expand Down
25 changes: 25 additions & 0 deletions workflow-canvas/src/api/http-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,31 @@ describe('createHttpApi, when a call does not go through', () => {
await outcome;
});

it('gives the launch code no timeout, as it works once', async () => {
vi.useFakeTimers();
let answer: (response: Response) => void = () => {};
let aborted = false;
const fetchFn = vi.fn(
(_path: string, init?: RequestInit) =>
new Promise<Response>((resolve, reject) => {
answer = resolve;
init?.signal?.addEventListener('abort', () => {
aborted = true;
reject(new Error('aborted'));
});
}),
);
const api = createHttpApi(fetchFn as unknown as typeof fetch, 5000);

const session = api.createSession('code-1');
await vi.advanceTimersByTimeAsync(60_000);
expect(aborted).toBe(false);
answer(reply(SESSION_BODY));

await expect(session).resolves.toMatchObject({ token: 't-1' });
expect(vi.getTimerCount()).toBe(0);
});

it('stops its timer when the call ends', async () => {
vi.useFakeTimers();
const { api } = apiFor(reply(RUN_BODY));
Expand Down
Loading
Loading