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
20 changes: 15 additions & 5 deletions workflow-canvas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ serves it and the tests around both are in this folder.

- `src/`: the UI. `graph/` turns a workflow into nodes and edges and does no
IO; `api/` is the typed client of the API; `launch/` and `workflow/` open the
session and load the workflow; `designer/` and `canvas/` draw it.
session and load the workflow; `designer/` and `canvas/` draw it;
`execution/core/` starts a run and follows it, and does no IO. It reads a run
once a second. A read that fails with no answer or a server error 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.
- `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 @@ -219,13 +225,17 @@ network, and the stub core is held to the same rule by the same scan
catches an alias such as `const f = fetch`, `XMLHttpRequest`, `WebSocket`,
`sendBeacon`, storage, `import.meta`, animation frames and dynamic imports,
and it ignores a word in a comment or a string. It guards against accidents;
it is not a sandbox.
it is not a sandbox. The run core, `src/execution/core/`, is held to it too,
by `test-support/execution-purity.test.ts`: it may import only its own
modules, the graph core and the wire types.

The rest wraps the core, so the rules can be tested without a browser:

- `api/`: `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.
- `api/`: `WorkflowApi` is the port with the calls the page makes (open a
session, load the workflow, start a run, read a run); `createHttpApi` is its
adapter over `fetch`. It ignores fields it does not know, refuses a reply
that breaks the contract, and turns a failed call into an `ApiError` with a
message fit to show.
- `launch/launch-code.ts` and `workflow/` take the launch code from the
address and load the workflow. `loadWorkflow` never rejects: it returns
`{ ok: true, workflow }` or `{ ok: false, message }`. `createWorkflowSource`
Expand Down
16 changes: 16 additions & 0 deletions workflow-canvas/src/api/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from 'vitest';
import { canRetryStatus } from './api';

describe('canRetryStatus', () => {
it.each([0, 408, 429, 500, 503])('retries status %i', (status) => {
expect(canRetryStatus(status)).toBe(true);
});

it.each([200, 202, 400, 401, 404, 409, 499])(
'does not retry status %i',
(status) => {
expect(canRetryStatus(status)).toBe(false);
},
);
});
23 changes: 21 additions & 2 deletions workflow-canvas/src/api/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: Apache-2.0
import type { Session, WorkflowDocument } from './types';
import type { RunView, Session, WorkflowDocument } from './types';

/**
* The calls of the workflow API that the page makes. The page depends on
Expand All @@ -10,6 +10,13 @@ export interface WorkflowApi {
createSession(code: string): Promise<Session>;
/** Loads the root workflow of the session. */
getWorkflow(token: string): Promise<WorkflowDocument>;
/**
* Starts a run of the root workflow with the goal; resolves to its id.
* Aborting the signal ends the call.
*/
startRun(token: string, goal: string, signal?: AbortSignal): Promise<string>;
/** Reads the state of a run, live or finished. */
getRun(token: string, runId: string, signal?: AbortSignal): Promise<RunView>;
}

/** A call that failed, with a message that is fit to show to a person. */
Expand All @@ -18,11 +25,23 @@ export class ApiError extends Error {
readonly status: number;
/** The `code` of the error body, when there was one. */
readonly code?: string;
/** The `run_id` of the error body, when there was one. */
readonly runId?: string;

constructor(message: string, status: number, code?: string) {
constructor(message: string, status: number, code?: string, runId?: string) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.runId = runId;
}
}

/**
* Whether a failed call can succeed on the next try: no answer (status 0), a
* server error (5xx), or a request timeout or rate limit (408, 429) from a
* proxy. Any other 4xx will not change.
*/
export function canRetryStatus(status: number): boolean {
return status === 0 || status >= 500 || status === 408 || status === 429;
}
Loading
Loading