@sanoma-ai/workflows 0.11.16 → 0.11.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +16 -8
  2. package/dist/call.d.ts +35 -4
  3. package/dist/call.js +128 -40
  4. package/dist/call.js.map +1 -1
  5. package/dist/client.d.ts +17 -8
  6. package/dist/client.js +90 -51
  7. package/dist/client.js.map +1 -1
  8. package/dist/collection.d.ts +25 -7
  9. package/dist/collection.js +236 -91
  10. package/dist/collection.js.map +1 -1
  11. package/dist/config.d.ts +24 -1
  12. package/dist/config.js +48 -10
  13. package/dist/config.js.map +1 -1
  14. package/dist/db/company.d.ts +20 -0
  15. package/dist/db/company.js +9 -1
  16. package/dist/db/company.js.map +1 -1
  17. package/dist/describe.js +1 -1
  18. package/dist/describe.js.map +1 -1
  19. package/dist/description.d.ts +6 -3
  20. package/dist/description.js +5 -4
  21. package/dist/description.js.map +1 -1
  22. package/dist/errors.d.ts +2 -1
  23. package/dist/errors.js +3 -2
  24. package/dist/errors.js.map +1 -1
  25. package/dist/grid.js +3 -2
  26. package/dist/grid.js.map +1 -1
  27. package/dist/host-derive.d.ts +13 -8
  28. package/dist/host-derive.js +26 -13
  29. package/dist/host-derive.js.map +1 -1
  30. package/dist/index.d.ts +7 -3
  31. package/dist/index.js +7 -3
  32. package/dist/index.js.map +1 -1
  33. package/dist/ledger.d.ts +7 -0
  34. package/dist/ledger.js +32 -2
  35. package/dist/ledger.js.map +1 -1
  36. package/dist/local-sandbox.d.ts +17 -0
  37. package/dist/local-sandbox.js +131 -0
  38. package/dist/local-sandbox.js.map +1 -0
  39. package/dist/op.d.ts +10 -2
  40. package/dist/op.js.map +1 -1
  41. package/dist/paths.d.ts +9 -0
  42. package/dist/paths.js +20 -4
  43. package/dist/paths.js.map +1 -1
  44. package/dist/resource.d.ts +7 -1
  45. package/dist/resource.js +5 -1
  46. package/dist/resource.js.map +1 -1
  47. package/dist/resources.d.ts +1 -1
  48. package/dist/resources.js +9 -6
  49. package/dist/resources.js.map +1 -1
  50. package/dist/runner-box.d.ts +23 -0
  51. package/dist/runner-box.js +181 -0
  52. package/dist/runner-box.js.map +1 -0
  53. package/dist/runner-protocol.d.ts +73 -0
  54. package/dist/runner-protocol.js +34 -0
  55. package/dist/runner-protocol.js.map +1 -0
  56. package/dist/sandbox-contract.d.ts +109 -0
  57. package/dist/sandbox-contract.js +26 -0
  58. package/dist/sandbox-contract.js.map +1 -0
  59. package/dist/sandbox-runner.cjs +7 -0
  60. package/dist/scenario.js +16 -7
  61. package/dist/scenario.js.map +1 -1
  62. package/dist/schedules.d.ts +11 -3
  63. package/dist/schedules.js +15 -3
  64. package/dist/schedules.js.map +1 -1
  65. package/dist/schema.js +6 -3
  66. package/dist/schema.js.map +1 -1
  67. package/dist/secret.d.ts +2 -1
  68. package/dist/secret.js +3 -2
  69. package/dist/secret.js.map +1 -1
  70. package/dist/shared.d.ts +39 -5
  71. package/dist/shared.js +13 -1
  72. package/dist/shared.js.map +1 -1
  73. package/dist/sites.js +10 -8
  74. package/dist/sites.js.map +1 -1
  75. package/dist/source.d.ts +21 -0
  76. package/dist/source.js.map +1 -1
  77. package/drizzle/company/0003_ledger_target.sql +2 -0
  78. package/drizzle/company/meta/0003_snapshot.json +845 -0
  79. package/drizzle/company/meta/_journal.json +7 -0
  80. package/package.json +5 -2
  81. package/src/call.ts +181 -60
  82. package/src/client.ts +118 -67
  83. package/src/collection.ts +265 -97
  84. package/src/config.ts +65 -16
  85. package/src/db/company.ts +9 -1
  86. package/src/describe.ts +1 -1
  87. package/src/description.ts +5 -4
  88. package/src/errors.ts +3 -2
  89. package/src/grid.ts +3 -1
  90. package/src/host-derive.ts +38 -18
  91. package/src/index.ts +15 -2
  92. package/src/ledger.ts +42 -2
  93. package/src/local-sandbox.ts +133 -0
  94. package/src/op.ts +10 -2
  95. package/src/paths.ts +22 -4
  96. package/src/resource.ts +16 -2
  97. package/src/resources.ts +8 -6
  98. package/src/runner-box.ts +218 -0
  99. package/src/runner-protocol.ts +61 -0
  100. package/src/sandbox-contract.ts +121 -0
  101. package/src/sandbox-runner.ts +187 -0
  102. package/src/scenario.ts +16 -6
  103. package/src/schedules.ts +19 -4
  104. package/src/schema.ts +14 -3
  105. package/src/secret.ts +6 -2
  106. package/src/shared.ts +40 -4
  107. package/src/sites.ts +11 -7
  108. package/src/source.ts +14 -0
@@ -0,0 +1,218 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import {
4
+ FILE,
5
+ frame,
6
+ RUNNER_HEALTH,
7
+ type RunnerAnswer,
8
+ type RunnerHealth,
9
+ type RunnerRequest,
10
+ STDERR,
11
+ STDIN,
12
+ unframe,
13
+ } from "./runner-protocol.ts";
14
+ import type { Box, Process } from "./sandbox-contract.ts";
15
+
16
+ // A box whose commands and files go through the runner (`sandbox-runner.ts`), a small server of
17
+ // ours a backend starts inside a remote box, over one WebSocket: so every vendor's backend is
18
+ // "open the box, start the runner, talk to it", whatever the vendor's own exec can or cannot
19
+ // stream. The protocol is in `runner-protocol.ts`. One connection carries many processes; a
20
+ // dropped one ends them here (exit 255) but kills nothing in the box.
21
+
22
+ /**
23
+ * The runner as one file (`dist/sandbox-runner.cjs`, its dependencies bundled in), which a
24
+ * backend writes into a box and starts with the box's `node` (22 or later).
25
+ */
26
+ export async function runnerScript(): Promise<Uint8Array> {
27
+ // `../dist/` from `src/` (the tests) and from `dist/` alike.
28
+ const at = new URL("../dist/sandbox-runner.cjs", import.meta.url);
29
+ return readFile(at).catch(() => {
30
+ throw new Error(`${fileURLToPath(at)} is missing: build @sanoma-ai/workflows`);
31
+ });
32
+ }
33
+
34
+ /**
35
+ * What the runner at `url` (asked with `headers`) answers at `RUNNER_HEALTH`, or undefined when nothing
36
+ * answers as a runner: a backend starts one then, and replaces one of another `protocol`.
37
+ */
38
+ export const runnerHealth = (url: string, headers?: Record<string, string>): Promise<RunnerHealth | undefined> =>
39
+ fetch(`${url}${RUNNER_HEALTH}`, { headers, signal: AbortSignal.timeout(5_000) })
40
+ .then(async (res) => {
41
+ const health = res.ok ? ((await res.json()) as Partial<RunnerHealth> | null) : null;
42
+ return typeof health?.protocol === "number" && typeof health.pid === "number"
43
+ ? { protocol: health.protocol, pid: health.pid }
44
+ : undefined;
45
+ })
46
+ .catch(() => undefined);
47
+
48
+ const encoder = new TextEncoder();
49
+
50
+ /** Drops a write or close the reader no longer wants (it cancelled). */
51
+ const ignore = () => {};
52
+
53
+ /** What an id of the connection waits for: a process's output and exit, or a request's answer. */
54
+ interface Waiter {
55
+ bytes(stream: number, bytes: Uint8Array): void;
56
+ answer(answer: RunnerAnswer): void;
57
+ /** The connection dropped. */
58
+ lost(): void;
59
+ }
60
+
61
+ /** An open connection to the runner, and what its ids wait for. */
62
+ interface Connection {
63
+ ws: WebSocket;
64
+ waiting: Map<number, Waiter>;
65
+ }
66
+
67
+ /** How long the runner's WebSocket may take to open, in ms. */
68
+ const OPEN_MS = 30_000;
69
+
70
+ /**
71
+ * The box `id` whose runner is at `url` (`http(s)://`, the WebSocket's upgrade sent with
72
+ * `headers`): its commands, files and `stop` (which kills every command in it and closes the
73
+ * connection). Connects at the first call, and again at the next call after a drop or a `stop`.
74
+ * How its ports are reached is the vendor's, so the backend adds `expose`.
75
+ */
76
+ export function runnerBox({
77
+ url,
78
+ headers,
79
+ id,
80
+ }: {
81
+ url: string;
82
+ headers?: Record<string, string>;
83
+ id: string;
84
+ }): Omit<Box, "expose"> {
85
+ const where = `the runner at ${new URL(url).host}`;
86
+ let current: Promise<Connection> | undefined;
87
+ let last = 0;
88
+
89
+ const connect = () => (current ??= open());
90
+ function open(): Promise<Connection> {
91
+ const opening = new Promise<Connection>((resolve, reject) => {
92
+ // Node's WebSocket (undici) takes `headers` beside the standard's protocols.
93
+ const ws = new WebSocket(url.replace(/^http/, "ws"), { headers } as unknown as string[]);
94
+ ws.binaryType = "arraybuffer";
95
+ const connection: Connection = { ws, waiting: new Map() };
96
+ const late = setTimeout(() => ws.close(), OPEN_MS);
97
+ ws.addEventListener("open", () => {
98
+ clearTimeout(late);
99
+ resolve(connection);
100
+ });
101
+ ws.addEventListener("message", ({ data }: { data: string | ArrayBuffer }) => {
102
+ try {
103
+ if (typeof data !== "string") {
104
+ const { id: of, stream, bytes } = unframe(new Uint8Array(data));
105
+ return connection.waiting.get(of)?.bytes(stream, bytes);
106
+ }
107
+ const answer = JSON.parse(data) as RunnerAnswer;
108
+ if (answer.id !== undefined) connection.waiting.get(answer.id)?.answer(answer);
109
+ } catch {
110
+ // A frame that is not the protocol's (another runner's) is dropped.
111
+ }
112
+ });
113
+ ws.addEventListener("close", ({ code, reason }) => {
114
+ clearTimeout(late);
115
+ if (current === opening) current = undefined;
116
+ reject(new Error(`${where} closed the connection (${reason || code})`));
117
+ for (const waiter of connection.waiting.values()) waiter.lost();
118
+ connection.waiting.clear();
119
+ });
120
+ });
121
+ return opening;
122
+ }
123
+
124
+ /** Sends `request` (and `bytes` after it): resolves with the file it answers, null for none, or nothing for `ok`. */
125
+ async function ask(
126
+ request: { op: "read"; path: string } | { op: "write"; path: string; mode?: number } | { op: "stop" },
127
+ bytes?: Uint8Array,
128
+ ): Promise<Uint8Array | null | undefined> {
129
+ const { ws, waiting } = await connect();
130
+ const n = ++last;
131
+ return new Promise((resolve, reject) => {
132
+ const settle = (done: () => void) => {
133
+ waiting.delete(n);
134
+ done();
135
+ };
136
+ waiting.set(n, {
137
+ bytes: (_, file) => settle(() => resolve(file)),
138
+ answer: (answer) =>
139
+ settle(() =>
140
+ answer.op === "error"
141
+ ? reject(new Error(`${where}: ${answer.message}`))
142
+ : resolve(answer.op === "absent" ? null : undefined),
143
+ ),
144
+ lost: () => reject(new Error(`${where} closed the connection`)),
145
+ });
146
+ ws.send(JSON.stringify({ ...request, id: n }));
147
+ if (bytes) ws.send(frame(n, FILE, bytes));
148
+ });
149
+ }
150
+
151
+ return {
152
+ id,
153
+ async run(command, { cwd, env, stdin = false, signal } = {}): Promise<Process> {
154
+ const { ws, waiting } = await connect();
155
+ const n = ++last;
156
+ const stdout = new TransformStream<Uint8Array, Uint8Array>();
157
+ const stderr = new TransformStream<Uint8Array, Uint8Array>();
158
+ const out = stdout.writable.getWriter();
159
+ const err = stderr.writable.getWriter();
160
+ const exited = new Promise<{ exitCode: number }>((done) => {
161
+ const end = (exitCode: number) => {
162
+ waiting.delete(n);
163
+ out.close().catch(ignore);
164
+ err.close().catch(ignore);
165
+ done({ exitCode });
166
+ };
167
+ waiting.set(n, {
168
+ bytes: (stream, bytes) => void (stream === STDERR ? err : out).write(bytes).catch(ignore),
169
+ answer(answer) {
170
+ if (answer.op === "exit") return end(answer.code);
171
+ if (answer.op !== "error") return;
172
+ err.write(encoder.encode(`${where}: ${answer.message}\n`)).catch(ignore);
173
+ end(127);
174
+ },
175
+ lost: () => end(255),
176
+ });
177
+ });
178
+ ws.send(JSON.stringify({ op: "run", id: n, command, cwd, env, stdin } satisfies RunnerRequest));
179
+ const kill = (sig: NodeJS.Signals = "SIGKILL") => {
180
+ if (waiting.has(n)) ws.send(JSON.stringify({ op: "kill", id: n, signal: sig } satisfies RunnerRequest));
181
+ };
182
+ const onAbort = () => kill();
183
+ if (signal?.aborted) onAbort();
184
+ signal?.addEventListener("abort", onAbort, { once: true });
185
+ void exited.then(() => signal?.removeEventListener("abort", onAbort));
186
+ const endInput = () => void ws.send(JSON.stringify({ op: "stdin-end", id: n } satisfies RunnerRequest));
187
+ return {
188
+ stdin: new WritableStream<Uint8Array>({
189
+ write(bytes) {
190
+ if (!stdin) throw new Error("its stdin is closed: run it with stdin: true");
191
+ ws.send(frame(n, STDIN, bytes));
192
+ },
193
+ close: endInput,
194
+ abort: endInput,
195
+ }),
196
+ stdout: stdout.readable,
197
+ stderr: stderr.readable,
198
+ wait: () => exited,
199
+ kill,
200
+ };
201
+ },
202
+ read: async (path) => (await ask({ op: "read", path })) ?? null,
203
+ async write(path, bytes, { mode } = {}) {
204
+ await ask({ op: "write", path, mode }, bytes);
205
+ },
206
+ async stop() {
207
+ // This connection, not one a call opens meanwhile after a drop.
208
+ const closing = connect();
209
+ try {
210
+ await ask({ op: "stop" });
211
+ } finally {
212
+ // The next call opens a new connection, not this closing one.
213
+ if (current === closing) current = undefined;
214
+ (await closing.catch(() => undefined))?.ws.close();
215
+ }
216
+ },
217
+ };
218
+ }
@@ -0,0 +1,61 @@
1
+ // The runner's protocol, over one WebSocket per box, shared by the runner (`sandbox-runner.ts`)
2
+ // and its client (`runner-box.ts`): text frames for control, as JSON, each carrying the `id` of
3
+ // the process or the request it is about; binary frames for bytes, `frame(id, stream, bytes)`.
4
+
5
+ /** The port the runner listens on in a box. */
6
+ export const RUNNER_PORT = 7700;
7
+
8
+ /**
9
+ * Where the runner answers its health: not `/health`, which Agent37's edge answers itself on
10
+ * every port, so that path never reaches what listens there.
11
+ */
12
+ export const RUNNER_HEALTH = "/runner/health";
13
+
14
+ /**
15
+ * The protocol's version, which the runner's health answers: a backend replaces a runner
16
+ * that answers another, one an earlier release started in a box that slept.
17
+ */
18
+ export const RUNNER_PROTOCOL = 2;
19
+
20
+ /** What the runner's health (`RUNNER_HEALTH`) answers: its protocol, and its pid, which a backend replacing it ends. */
21
+ export interface RunnerHealth {
22
+ protocol: number;
23
+ pid: number;
24
+ }
25
+
26
+ /** What a binary frame's bytes are: a process's stdin, stdout or stderr, or a file read or written. */
27
+ export const STDIN = 0;
28
+ export const STDOUT = 1;
29
+ export const STDERR = 2;
30
+ export const FILE = 3;
31
+
32
+ /** What the client says, as text. A `write`'s bytes follow it as the frame `(id, FILE)`. */
33
+ export type RunnerRequest =
34
+ | { op: "run"; id: number; command: string; cwd?: string; env?: Record<string, string>; stdin: boolean }
35
+ | { op: "stdin-end"; id: number }
36
+ | { op: "kill"; id: number; signal: NodeJS.Signals }
37
+ | { op: "read"; id: number; path: string }
38
+ | { op: "write"; id: number; path: string; mode?: number }
39
+ | { op: "stop"; id: number };
40
+
41
+ /** What the runner answers, as text. A `read` that finds a file answers the frame `(id, FILE)`. */
42
+ export type RunnerAnswer =
43
+ | { op: "exit"; id: number; code: number }
44
+ | { op: "absent"; id: number }
45
+ | { op: "ok"; id: number }
46
+ | { op: "error"; id?: number; message: string };
47
+
48
+ /** A binary frame: `id` (4 bytes), `stream` (1 byte), then `bytes`. */
49
+ export function frame(id: number, stream: number, bytes: Uint8Array): Uint8Array<ArrayBuffer> {
50
+ const framed = new Uint8Array(5 + bytes.length);
51
+ new DataView(framed.buffer).setUint32(0, id);
52
+ framed[4] = stream;
53
+ framed.set(bytes, 5);
54
+ return framed;
55
+ }
56
+
57
+ export const unframe = (framed: Uint8Array) => ({
58
+ id: new DataView(framed.buffer, framed.byteOffset, framed.byteLength).getUint32(0),
59
+ stream: framed[4]!,
60
+ bytes: framed.subarray(5),
61
+ });
@@ -0,0 +1,121 @@
1
+ import type { z } from "zod";
2
+ import { hasPath } from "./host.ts";
3
+ import type { LoginContext } from "./op.ts";
4
+
5
+ // The `Sandbox` contract: what a resource type that runs a coding agent's box (an Agent37
6
+ // instance) declares about itself, beside `Host`, `Database` and `Wakeup`, and the backend its
7
+ // connector attaches (`ConnectorSetup.sandbox`) to open one box per name, so boxes are swappable
8
+ // and one conformance kit decides whether one works. Vendor- and library-neutral: web streams,
9
+ // no AI SDK. (`sandbox.ts` is something else: a scenario's run over the fakes.)
10
+
11
+ /** What a sandbox can do, for choosing one: declared, never checked at runtime. */
12
+ export interface SandboxCapabilities {
13
+ /** A box's files, its home's too, survive `stop` and the next `open` of its name. */
14
+ snapshot: boolean;
15
+ /** A stopped box keeps its memory and processes, and resumes them on the next `open`. */
16
+ pause: boolean;
17
+ /** `expose` makes a port of the box reachable from outside it. */
18
+ port: boolean;
19
+ }
20
+
21
+ /** What a sandbox's state says of it. */
22
+ export interface SandboxOutputs {
23
+ id: string;
24
+ healthy: boolean;
25
+ }
26
+
27
+ /**
28
+ * A resource type's `sandbox`: the fields its boxes' choices are in, by dotted path into its
29
+ * declared fields, and how its state reads. The vendor's own facts (its region, its type of
30
+ * machine) are fields of the resource, never read from the environment.
31
+ */
32
+ export interface SandboxSpec {
33
+ capabilities: SandboxCapabilities;
34
+ inputs: {
35
+ /** The image a box runs. */
36
+ image: string;
37
+ /** A box's size, in vCPUs: its memory follows the vendor's shapes. */
38
+ size: string;
39
+ };
40
+ outputs: (state: Record<string, unknown>) => SandboxOutputs;
41
+ }
42
+
43
+ /** A process `Box.run` started: `bash -c <command>`. */
44
+ export interface Process {
45
+ /** Its stdin: closed at once unless `run` was given `stdin: true`. */
46
+ stdin: WritableStream<Uint8Array>;
47
+ /** Its stdout, as it prints, ended once it has exited. */
48
+ stdout: ReadableStream<Uint8Array>;
49
+ stderr: ReadableStream<Uint8Array>;
50
+ /** Resolves when it has exited: non-zero when it failed or was killed. */
51
+ wait(): Promise<{ exitCode: number }>;
52
+ /** Kills it and what it started (default `SIGKILL`). */
53
+ kill(signal?: NodeJS.Signals): void;
54
+ }
55
+
56
+ /**
57
+ * A box: a computer of its own a coding agent works in. A relative `path` or `cwd` is under the
58
+ * box's home (`$HOME`), which a backend with `snapshot` keeps across `stop`.
59
+ */
60
+ export interface Box {
61
+ /** The vendor's id of it. */
62
+ id: string;
63
+ /** Runs `bash -c <command>`, in `cwd`, with `env` over the box's own. An abort kills it. */
64
+ run(
65
+ command: string,
66
+ opts?: { cwd?: string; env?: Record<string, string>; stdin?: boolean; signal?: AbortSignal },
67
+ ): Promise<Process>;
68
+ /** A file's bytes; null when there is none. */
69
+ read(path: string): Promise<Uint8Array | null>;
70
+ /** Writes a file, its directories made. */
71
+ write(path: string, bytes: Uint8Array, opts?: { mode?: number }): Promise<void>;
72
+ /** Where its `port` is reached from outside it, with the headers that reach it. */
73
+ expose(port: number): Promise<{ url: string; headers?: Record<string, string> }>;
74
+ /** Stops it and every process in it; the next `open` of its name finds what it kept. */
75
+ stop(): Promise<void>;
76
+ }
77
+
78
+ /**
79
+ * A connector's boxes (`ConnectorSetup.sandbox`). A box is keyed by `name`, under which the
80
+ * vendor keeps its state, so no snapshot handle crosses the contract.
81
+ */
82
+ export interface SandboxBackend {
83
+ /**
84
+ * The box `name`, made from `image` with `size` vCPUs when there is none (left out, the backend's
85
+ * own), woken or started when stopped.
86
+ */
87
+ open(opts: { name: string; image?: string; size?: number }): Promise<Box>;
88
+ /** Deletes the box `name` and whatever the vendor kept of it: the next `open` finds it empty. */
89
+ discard(opts: { name: string }): Promise<void>;
90
+ }
91
+
92
+ /**
93
+ * What the app gives a connector's sandbox half (`ConnectorSetup.sandbox`): the secrets, as a model
94
+ * half reads them, and the declared fields of the resource whose boxes it opens, which hold the
95
+ * vendor's facts (its region, its type of machine) beside the image and size `open` takes.
96
+ */
97
+ export type SandboxContext = Pick<LoginContext, "secrets"> & { declared: Record<string, unknown> };
98
+
99
+ /**
100
+ * Throws what is wrong with a resource type's `sandbox`: a type apply cannot change (not
101
+ * `writable`), a capability that is not a boolean, an input that names no field, `outputs` that
102
+ * is not a function.
103
+ */
104
+ export function checkSandbox(
105
+ what: string,
106
+ sandbox: SandboxSpec,
107
+ type: { schema: z.ZodObject; writable?: boolean },
108
+ ): void {
109
+ const problems: string[] = [];
110
+ if (!type.writable) problems.push("it must be writable, so apply can create and update it");
111
+ const capabilities: Partial<SandboxCapabilities> = sandbox.capabilities ?? {};
112
+ for (const name of ["snapshot", "pause", "port"] as const) {
113
+ if (typeof capabilities[name] !== "boolean") problems.push(`capabilities.${name} must be true or false`);
114
+ }
115
+ for (const input of ["image", "size"] as const) {
116
+ const path: unknown = sandbox.inputs?.[input];
117
+ if (typeof path !== "string" || !hasPath(type.schema, path)) problems.push(`${input} names no field (${path})`);
118
+ }
119
+ if (typeof sandbox.outputs !== "function") problems.push("outputs must be a function of the state");
120
+ if (problems.length) throw new Error(`${what}: its sandbox: ${problems.join("; ")}`);
121
+ }
@@ -0,0 +1,187 @@
1
+ import { createServer } from "node:http";
2
+ import type { AddressInfo } from "node:net";
3
+ import { homedir } from "node:os";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { type WebSocket, WebSocketServer } from "ws";
6
+ import { localBox } from "./local-sandbox.ts";
7
+ import {
8
+ FILE,
9
+ frame,
10
+ RUNNER_HEALTH,
11
+ RUNNER_PORT,
12
+ RUNNER_PROTOCOL,
13
+ type RunnerAnswer,
14
+ type RunnerHealth,
15
+ type RunnerRequest,
16
+ STDERR,
17
+ STDIN,
18
+ STDOUT,
19
+ unframe,
20
+ } from "./runner-protocol.ts";
21
+ import type { Process } from "./sandbox-contract.ts";
22
+ import { errorMessage } from "./shared.ts";
23
+
24
+ // The runner: the server of ours a backend starts inside a remote box, which `runnerBox` talks to
25
+ // over a WebSocket (the protocol is in `runner-protocol.ts`). It serves this machine as the local
26
+ // backend's box does (`localBox`, whose code it bundles): commands are `bash -c` in process groups
27
+ // of their own, a relative path or `cwd` is under the home. Built into one file,
28
+ // `dist/sandbox-runner.cjs` (`ws` bundled in), run as `node sandbox-runner.cjs`.
29
+ //
30
+ // It listens on `SANOMA_RUNNER_HOST:SANOMA_RUNNER_PORT` (default `0.0.0.0:7700`) and asks no
31
+ // credential: the vendor's edge authenticates every request to the port, the upgrade too
32
+ // (Agent37: the API key, on a port never made public). Anything inside the box already runs as
33
+ // the box's user, so a secret of the runner's own would guard nothing.
34
+
35
+ const port = Number(process.env.SANOMA_RUNNER_PORT ?? RUNNER_PORT);
36
+ const host = process.env.SANOMA_RUNNER_HOST ?? "0.0.0.0";
37
+
38
+ /**
39
+ * How long an exited command's output may go quiet before its exit is sent, in ms: `localBox` has
40
+ * waited for its pipes already, unless what it left running holds them. Output held back for a
41
+ * slow connection is not quiet.
42
+ */
43
+ const LAST_OUTPUT = 50;
44
+ /** How much a connection may hold unsent before a command's output waits for it, in bytes. */
45
+ const UNSENT = 1 << 20;
46
+ /** How often a connection is pinged: Agent37's edge closes one silent for 15 minutes. */
47
+ const BEAT = 30_000;
48
+
49
+ // Its commands' environment is its own, but for where it listens.
50
+ const { SANOMA_RUNNER_PORT: _, SANOMA_RUNNER_HOST: __, ...own } = process.env;
51
+ const box = localBox("runner", homedir(), own as Record<string, string>);
52
+
53
+ const server = createServer((req, res) => {
54
+ if (req.url !== RUNNER_HEALTH) return void res.writeHead(404).end();
55
+ res
56
+ .writeHead(200, { "content-type": "application/json" })
57
+ .end(JSON.stringify({ protocol: RUNNER_PROTOCOL, pid: process.pid } satisfies RunnerHealth));
58
+ });
59
+ new WebSocketServer({ server })
60
+ .on("connection", serve)
61
+ .on("error", (error) => console.error(`the runner's WebSocket server: ${error.message}`));
62
+ server.listen(port, host, () => {
63
+ const { address, port: bound } = server.address() as AddressInfo;
64
+ console.log(`sanoma runner listening on ${address}:${bound}`);
65
+ });
66
+ // Asked to end (the box shuts down): what it started ends with it.
67
+ process.on("SIGTERM", () => void box.stop().finally(() => process.exit(0)));
68
+
69
+ /** One client's connection: its commands, by the ids it gave them. */
70
+ function serve(ws: WebSocket) {
71
+ // Promises: a command's kill and its stdin may come before it has started, and wait for it.
72
+ const processes = new Map<number, Promise<Process>>();
73
+ const inputs = new Map<number, Promise<WritableStreamDefaultWriter<Uint8Array>>>();
74
+ /** The writes whose bytes are yet to come. */
75
+ const writes = new Map<number, { path: string; mode?: number }>();
76
+ const send = (data: Uint8Array | string) => ws.readyState === ws.OPEN && ws.send(data);
77
+ const say = (answer: RunnerAnswer) => send(JSON.stringify(answer));
78
+ const beat = setInterval(() => ws.ping(), BEAT);
79
+ // A frame ws refuses (one too large, one malformed) closes this connection, not the runner.
80
+ ws.on("error", (error) => console.error(`a connection: ${error.message}`));
81
+ ws.on("close", () => {
82
+ clearInterval(beat);
83
+ // A dropped connection kills nothing: its commands go on, their stdin ended, their output dropped.
84
+ for (const id of inputs.keys()) endInput(id);
85
+ });
86
+ ws.on("message", (data: Buffer, binary) => {
87
+ // A frame that is not the protocol's is answered, never a crash of the runner.
88
+ if (binary) {
89
+ if (data.length < 5) return say({ op: "error", message: "a binary frame is an id, a stream and bytes" });
90
+ return received(unframe(data));
91
+ }
92
+ let request: RunnerRequest;
93
+ try {
94
+ request = JSON.parse(String(data));
95
+ } catch {
96
+ return say({ op: "error", message: "a text frame is JSON" });
97
+ }
98
+ if (typeof request !== "object" || request === null) return say({ op: "error", message: "a request is an object" });
99
+ handle(request).catch((error: unknown) => say({ op: "error", id: request.id, message: errorMessage(error) }));
100
+ });
101
+
102
+ async function handle(request: RunnerRequest): Promise<unknown> {
103
+ switch (request.op) {
104
+ case "run": {
105
+ const { id, command, cwd, env, stdin } = request;
106
+ const starting = box.run(command, { cwd, env, stdin });
107
+ processes.set(id, starting);
108
+ if (stdin) {
109
+ const input = starting.then((started) => started.stdin.getWriter());
110
+ // A run that fails to start is answered below; its stdin's rejection is not another.
111
+ input.catch(() => {});
112
+ inputs.set(id, input);
113
+ }
114
+ try {
115
+ const started = await starting;
116
+ const moved = { at: Date.now(), done: false };
117
+ const pumped = Promise.all([
118
+ pump(id, STDOUT, started.stdout, moved),
119
+ pump(id, STDERR, started.stderr, moved),
120
+ ]).finally(() => (moved.done = true));
121
+ const { exitCode } = await started.wait();
122
+ // What it printed last goes out first, unless what it left running holds its pipes.
123
+ moved.at = Date.now();
124
+ while (!moved.done && Date.now() - moved.at < LAST_OUTPUT) await Promise.race([pumped, delay(10)]);
125
+ return say({ op: "exit", id, code: exitCode });
126
+ } finally {
127
+ processes.delete(id);
128
+ inputs.delete(id);
129
+ }
130
+ }
131
+ case "stdin-end":
132
+ return endInput(request.id);
133
+ case "kill":
134
+ return processes.get(request.id)?.then((started) => started.kill(request.signal));
135
+ case "read": {
136
+ const file = await box.read(request.path);
137
+ return file ? send(frame(request.id, FILE, file)) : say({ op: "absent", id: request.id });
138
+ }
139
+ case "write":
140
+ return writes.set(request.id, request);
141
+ case "stop":
142
+ await box.stop();
143
+ return say({ op: "ok", id: request.id });
144
+ default:
145
+ throw new Error(`no such request: ${(request as { op?: unknown }).op}`);
146
+ }
147
+ }
148
+
149
+ function received({ id, stream, bytes }: ReturnType<typeof unframe>) {
150
+ if (stream === STDIN)
151
+ return void inputs
152
+ .get(id)
153
+ ?.then((writer) => writer.write(bytes))
154
+ .catch(() => {});
155
+ const write = writes.get(id);
156
+ if (stream !== FILE || !write) return;
157
+ writes.delete(id);
158
+ box.write(write.path, bytes, { mode: write.mode }).then(
159
+ () => say({ op: "ok", id }),
160
+ (error: unknown) => say({ op: "error", id, message: errorMessage(error) }),
161
+ );
162
+ }
163
+
164
+ /** Ends the stdin of the command `id`, once it has started. */
165
+ function endInput(id: number) {
166
+ const input = inputs.get(id);
167
+ inputs.delete(id);
168
+ return input?.then((writer) => writer.close()).catch(() => {});
169
+ }
170
+
171
+ /** Sends what `from` prints, until it ends or fails, keeping in `moved` when it last sent or waited to. */
172
+ async function pump(id: number, stream: number, from: ReadableStream<Uint8Array>, moved: { at: number }) {
173
+ try {
174
+ for await (const chunk of from) {
175
+ // Its output waits for a slow connection, and so the command does, its pipe full.
176
+ while (ws.readyState === ws.OPEN && ws.bufferedAmount > UNSENT) {
177
+ moved.at = Date.now();
178
+ await delay(10);
179
+ }
180
+ send(frame(id, stream, chunk));
181
+ moved.at = Date.now();
182
+ }
183
+ } catch {
184
+ // A pipe that failed has nothing more to say.
185
+ }
186
+ }
187
+ }
package/src/scenario.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type Step as GherkinStep,
13
13
  } from "@cucumber/messages";
14
14
  import type { z } from "zod";
15
+ import { targetOf } from "./call.ts";
15
16
  import type { RunSummary, SanomaClient } from "./client.ts";
16
17
  import type { Approver, Principal } from "./define.ts";
17
18
  import type { ResolvedConfig } from "./config.ts";
@@ -44,10 +45,12 @@ import {
44
45
  DATA_VENDOR,
45
46
  declaresPeople,
46
47
  DRIFT_WORKFLOW,
48
+ entryOpsOf,
47
49
  errorMessage,
48
50
  fitsOp,
49
51
  flatten,
50
52
  groupNamesOf,
53
+ isBatch,
51
54
  isEnded,
52
55
  principalIdsOf,
53
56
  problemLine,
@@ -446,6 +449,12 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
446
449
  { name: "field", regexp: /\S+/, transformer: (s) => s },
447
450
  ];
448
451
  const opOf = (id: unknown) => scope.ops.get(id as string)!;
452
+ /** An operation a `Then` step reads the ledger for: never a batch, which the ledger records as its entries. */
453
+ const recordedOp = (id: unknown): string => {
454
+ const op = opOf(id);
455
+ if (isBatch(op)) throw new Error(`${op.id} is recorded as its entries: name ${entryOpsOf(op).join(" or ")}`);
456
+ return op.id;
457
+ };
449
458
  const workflowOf = (name: unknown) => scope.workflows.get(name as string)!;
450
459
  // The people as the root's data files declare them when a step names one (`readPeople`, read
451
460
  // again only once a file changed): while they declare no one, any id and any group go.
@@ -596,33 +605,33 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
596
605
  source: "{op} was called with",
597
606
  takes: TAKES_OBJECT,
598
607
  read: ([op], pickle) =>
599
- expecting({ op: op as string, input: fieldsFrom(pickle, opOf(op).input, inputOf(op), true), called: true }),
608
+ expecting({ op: recordedOp(op), input: fieldsFrom(pickle, opOf(op).input, inputOf(op), true), called: true }),
600
609
  },
601
610
  ...[true, false].map((called): Rule => ({
602
611
  kind: "then",
603
612
  source: `{op} was ${called ? "" : "not "}called`,
604
- read: ([op]) => expecting({ op: op as string, called }),
613
+ read: ([op]) => expecting({ op: recordedOp(op), called }),
605
614
  })),
606
615
  {
607
616
  kind: "then",
608
617
  source: "{op} was denied",
609
- read: ([op]) => expecting({ op: op as string, decision: "denied" }),
618
+ read: ([op]) => expecting({ op: recordedOp(op), decision: "denied" }),
610
619
  },
611
620
  {
612
621
  kind: "then",
613
622
  source: "{op} was denied with {string}",
614
- read: ([op, reason]) => expecting({ op: op as string, decision: "denied", reason: reason as string }),
623
+ read: ([op, reason]) => expecting({ op: recordedOp(op), decision: "denied", reason: reason as string }),
615
624
  },
616
625
  {
617
626
  kind: "then",
618
627
  source: "{op} was held for {who}",
619
- read: ([op, who]) => expecting({ op: op as string, decision: "held", approver: who as string }),
628
+ read: ([op, who]) => expecting({ op: recordedOp(op), decision: "held", approver: who as string }),
620
629
  },
621
630
  {
622
631
  kind: "then",
623
632
  source: "{op} was held for group {string}",
624
633
  read: ([op, group]) =>
625
- expecting({ op: op as string, decision: "held", approver: { group: groupNamed(group as string) } }),
634
+ expecting({ op: recordedOp(op), decision: "held", approver: { group: groupNamed(group as string) } }),
626
635
  },
627
636
  {
628
637
  kind: "then",
@@ -1207,6 +1216,7 @@ function testWorld(built: Built, { driver, fake, live }: FeatureTarget) {
1207
1216
  record({
1208
1217
  type: "op.called",
1209
1218
  op: op.id,
1219
+ ...targetOf(op, input),
1210
1220
  effect: op.effect,
1211
1221
  input,
1212
1222
  decision: { kind: "allow" },