@sanoma-ai/workflows 0.11.15 → 0.11.17

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 (117) hide show
  1. package/README.md +14 -3
  2. package/dist/app-steps.d.ts +17 -0
  3. package/dist/app-steps.js +43 -0
  4. package/dist/app-steps.js.map +1 -0
  5. package/dist/app-workflows.d.ts +170 -0
  6. package/dist/app-workflows.js +107 -0
  7. package/dist/app-workflows.js.map +1 -0
  8. package/dist/apply-steps.js +3 -9
  9. package/dist/apply-steps.js.map +1 -1
  10. package/dist/approvals.d.ts +1 -1
  11. package/dist/approvals.js +3 -0
  12. package/dist/approvals.js.map +1 -1
  13. package/dist/call.d.ts +2 -1
  14. package/dist/call.js +2 -1
  15. package/dist/call.js.map +1 -1
  16. package/dist/change-steps.js +11 -17
  17. package/dist/change-steps.js.map +1 -1
  18. package/dist/chat-steps.d.ts +5 -0
  19. package/dist/chat-steps.js +107 -0
  20. package/dist/chat-steps.js.map +1 -0
  21. package/dist/chat-workflow.d.ts +43 -0
  22. package/dist/chat-workflow.js +47 -0
  23. package/dist/chat-workflow.js.map +1 -0
  24. package/dist/chats.d.ts +23 -4
  25. package/dist/chats.js +54 -7
  26. package/dist/chats.js.map +1 -1
  27. package/dist/client.d.ts +42 -31
  28. package/dist/client.js +118 -105
  29. package/dist/client.js.map +1 -1
  30. package/dist/config.d.ts +20 -0
  31. package/dist/config.js +55 -4
  32. package/dist/config.js.map +1 -1
  33. package/dist/db/company.d.ts +259 -2
  34. package/dist/db/company.js +32 -1
  35. package/dist/db/company.js.map +1 -1
  36. package/dist/db.d.ts +15 -0
  37. package/dist/db.js +42 -2
  38. package/dist/db.js.map +1 -1
  39. package/dist/description.d.ts +6 -3
  40. package/dist/description.js +4 -3
  41. package/dist/description.js.map +1 -1
  42. package/dist/follow-steps.d.ts +8 -0
  43. package/dist/follow-steps.js +113 -0
  44. package/dist/follow-steps.js.map +1 -0
  45. package/dist/follow-workflow.d.ts +56 -0
  46. package/dist/follow-workflow.js +50 -0
  47. package/dist/follow-workflow.js.map +1 -0
  48. package/dist/index.d.ts +10 -5
  49. package/dist/index.js +9 -4
  50. package/dist/index.js.map +1 -1
  51. package/dist/local-sandbox.d.ts +10 -0
  52. package/dist/local-sandbox.js +130 -0
  53. package/dist/local-sandbox.js.map +1 -0
  54. package/dist/op.d.ts +9 -2
  55. package/dist/op.js.map +1 -1
  56. package/dist/paths.d.ts +9 -0
  57. package/dist/paths.js +20 -4
  58. package/dist/paths.js.map +1 -1
  59. package/dist/resource.d.ts +7 -1
  60. package/dist/resource.js +5 -1
  61. package/dist/resource.js.map +1 -1
  62. package/dist/run.d.ts +39 -0
  63. package/dist/run.js +15 -0
  64. package/dist/run.js.map +1 -1
  65. package/dist/sandbox-contract.d.ts +100 -0
  66. package/dist/sandbox-contract.js +26 -0
  67. package/dist/sandbox-contract.js.map +1 -0
  68. package/dist/sandbox.d.ts +10 -0
  69. package/dist/sandbox.js +42 -4
  70. package/dist/sandbox.js.map +1 -1
  71. package/dist/scenario.d.ts +19 -0
  72. package/dist/scenario.js.map +1 -1
  73. package/dist/schedules.d.ts +11 -3
  74. package/dist/schedules.js +15 -3
  75. package/dist/schedules.js.map +1 -1
  76. package/dist/seal.d.ts +7 -0
  77. package/dist/seal.js +14 -2
  78. package/dist/seal.js.map +1 -1
  79. package/dist/shared.d.ts +34 -2
  80. package/dist/shared.js +39 -2
  81. package/dist/shared.js.map +1 -1
  82. package/dist/worker.d.ts +8 -0
  83. package/dist/worker.js +116 -10
  84. package/dist/worker.js.map +1 -1
  85. package/drizzle/company/0002_runs_tell_the_app.sql +27 -0
  86. package/drizzle/company/meta/0002_snapshot.json +819 -0
  87. package/drizzle/company/meta/_journal.json +7 -0
  88. package/package.json +1 -1
  89. package/src/app-steps.ts +67 -0
  90. package/src/app-workflows.ts +251 -0
  91. package/src/apply-steps.ts +3 -9
  92. package/src/approvals.ts +3 -1
  93. package/src/call.ts +2 -1
  94. package/src/change-steps.ts +10 -17
  95. package/src/chat-steps.ts +117 -0
  96. package/src/chat-workflow.ts +69 -0
  97. package/src/chats.ts +80 -14
  98. package/src/client.ts +154 -109
  99. package/src/config.ts +66 -3
  100. package/src/db/company.ts +49 -2
  101. package/src/db.ts +51 -3
  102. package/src/description.ts +4 -3
  103. package/src/follow-steps.ts +129 -0
  104. package/src/follow-workflow.ts +88 -0
  105. package/src/index.ts +32 -3
  106. package/src/local-sandbox.ts +132 -0
  107. package/src/op.ts +9 -2
  108. package/src/paths.ts +22 -4
  109. package/src/resource.ts +16 -2
  110. package/src/run.ts +41 -0
  111. package/src/sandbox-contract.ts +114 -0
  112. package/src/sandbox.ts +41 -4
  113. package/src/scenario.ts +17 -0
  114. package/src/schedules.ts +19 -4
  115. package/src/seal.ts +16 -2
  116. package/src/shared.ts +47 -2
  117. package/src/worker.ts +149 -10
@@ -0,0 +1,132 @@
1
+ import { spawn } from "node:child_process";
2
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { Readable, Writable } from "node:stream";
5
+ import type { Box, Process, SandboxBackend, SandboxCapabilities } from "./sandbox-contract.ts";
6
+
7
+ // The local backend of the `Sandbox` contract: boxes on this machine, the app's for a coding agent
8
+ // on a laptop and the testing package's fake. A box is a directory, its home, kept across stops;
9
+ // its commands are child processes, each in a process group of its own; its ports are loopback's.
10
+
11
+ /** What the local backend can do: its boxes are this machine's directories, which `stop` leaves. */
12
+ export const LOCAL_SANDBOX: SandboxCapabilities = { snapshot: true, pause: false, port: true };
13
+
14
+ /**
15
+ * The boxes under `dir`: the box `name` is the directory `<dir>/<name>`, its home, made on its
16
+ * first `open`, deleted by `discard`. This machine is its image and its size, so it takes neither.
17
+ * A command's environment is `env(home)` (default: this Node and this process's `PATH`, and
18
+ * `HOME`), never this process's, under the caller's `env`.
19
+ */
20
+ export const localBackend = (
21
+ dir: string,
22
+ env: (home: string) => Record<string, string> = (home) => ({
23
+ PATH: [dirname(process.execPath), process.env.PATH || "/usr/bin:/bin"].join(":"),
24
+ HOME: home,
25
+ }),
26
+ ): SandboxBackend => ({
27
+ async open({ name }) {
28
+ const home = boxDir(dir, name);
29
+ await mkdir(home, { recursive: true });
30
+ return localBox(name, home, env(home));
31
+ },
32
+ discard: ({ name }) => rm(boxDir(dir, name), { recursive: true, force: true }),
33
+ });
34
+
35
+ function boxDir(dir: string, name: string): string {
36
+ if (!name || name !== basename(name) || name.startsWith(".")) throw new Error(`${name} is no box's name`);
37
+ return join(dir, name);
38
+ }
39
+
40
+ /**
41
+ * The box at `home`, over `node:child_process` and `node:fs`: a command is `bash -c` in a process
42
+ * group of its own (killed whole by `kill`, and by `stop`), with `base` under the caller's `env`;
43
+ * a relative path or `cwd` is under `home`, any other this machine's.
44
+ */
45
+ function localBox(id: string, home: string, base: Record<string, string>): Box {
46
+ /** The process groups its commands lead, killed by `stop` whether or not their leader has exited. */
47
+ const groups = new Set<number>();
48
+ const at = (path: string) => resolve(home, path);
49
+ return {
50
+ id,
51
+ async run(command, { cwd, env, stdin, signal } = {}): Promise<Process> {
52
+ const child = spawn("bash", ["-c", command], {
53
+ cwd: at(cwd ?? "."),
54
+ env: { ...base, ...env },
55
+ stdio: "pipe",
56
+ // Its own process group, so a bridge and what it starts are killed together.
57
+ detached: true,
58
+ });
59
+ const group = child.pid;
60
+ if (group !== undefined) groups.add(group);
61
+ child.stdin.on("error", () => {});
62
+ if (!stdin) child.stdin.end();
63
+ let ended = false;
64
+ const exited = new Promise<{ exitCode: number }>((done) => {
65
+ child.on("error", () => done({ exitCode: 127 }));
66
+ // Exited when it exits, not when its pipes close: what it left running may hold them. What
67
+ // it printed last still arrives, so the end waits a moment for that.
68
+ child.on("exit", (code) => {
69
+ ended = true;
70
+ // Forgotten once none of its group is left, so `stop` kills no group its pid leads later.
71
+ if (group !== undefined && !groupAlive(group)) groups.delete(group);
72
+ const end = () => done({ exitCode: code ?? 1 });
73
+ child.on("close", end);
74
+ setTimeout(end, PIPES_WAIT).unref();
75
+ });
76
+ });
77
+ const kill = (sig: NodeJS.Signals = "SIGKILL") => {
78
+ // Once it has ended, only while its group is left, so a reused pid is never hit.
79
+ if (group !== undefined && (!ended || groupAlive(group))) killGroup(group, sig);
80
+ };
81
+ const onAbort = () => kill();
82
+ if (signal?.aborted) onAbort();
83
+ signal?.addEventListener("abort", onAbort, { once: true });
84
+ void exited.then(() => signal?.removeEventListener("abort", onAbort));
85
+ return {
86
+ stdin: Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
87
+ stdout: web(child.stdout),
88
+ stderr: web(child.stderr),
89
+ wait: () => exited,
90
+ kill,
91
+ };
92
+ },
93
+ // A Buffer is a Uint8Array: no copy.
94
+ read: (path) => readFile(at(path)).catch(() => null),
95
+ async write(path, bytes, { mode } = {}) {
96
+ await mkdir(dirname(at(path)), { recursive: true });
97
+ await writeFile(at(path), bytes);
98
+ if (mode !== undefined) await chmod(at(path), mode);
99
+ },
100
+ /** A port on loopback. */
101
+ expose: async (port) => ({ url: `http://127.0.0.1:${port}` }),
102
+ /** Kills every command's process group: what a bridge started too. */
103
+ async stop() {
104
+ for (const group of groups) killGroup(group);
105
+ groups.clear();
106
+ },
107
+ };
108
+ }
109
+
110
+ /** How long a command's last output may take to arrive after it exits, in ms. */
111
+ const PIPES_WAIT = 200;
112
+
113
+ const web = (stream: Readable) => Readable.toWeb(stream) as ReadableStream<Uint8Array>;
114
+
115
+ /** Whether any process of the group `group` is left. */
116
+ function groupAlive(group: number): boolean {
117
+ try {
118
+ process.kill(-group, 0);
119
+ return true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ /** Kills the process group `group` (a `detached` child's pid). */
126
+ function killGroup(group: number, signal: NodeJS.Signals = "SIGKILL") {
127
+ try {
128
+ process.kill(-group, signal);
129
+ } catch {
130
+ // Gone already.
131
+ }
132
+ }
package/src/op.ts CHANGED
@@ -5,6 +5,7 @@ import type { ConnectionSnapshot } from "./connection.ts";
5
5
  import type { ProviderV4 } from "@ai-sdk/provider";
6
6
  import type { z } from "zod";
7
7
  import type { Fake } from "./fake.ts";
8
+ import type { SandboxBackend, SandboxContext } from "./sandbox-contract.ts";
8
9
  import type { Secrets } from "./secret.ts";
9
10
  import type { RemoteHalf } from "./source.ts";
10
11
  import type { Steps } from "./steps.ts";
@@ -325,8 +326,8 @@ export interface Driver {
325
326
  * to call: the config then names the connector alone, and never a driver, a fake, steps or a
326
327
  * login. Any may be missing: a workflow using an operation of a connector with no driver is
327
328
  * refused, a connector with no fake has none for sandbox runs, one with no login half signs no
328
- * visitor in, one with no model half gives the app no models, and one with no remote half hosts
329
- * no repository a change is reviewed on.
329
+ * visitor in, one with no model half gives the app no models, one with no remote half hosts
330
+ * no repository a change is reviewed on, and one with no sandbox half opens no box.
330
331
  */
331
332
  export interface ConnectorSetup {
332
333
  /** Auth methods available to named connections; login identity is separate. */
@@ -362,6 +363,12 @@ export interface ConnectorSetup {
362
363
  * resolved config (`ResolvedConfig.remotes`). It reads its credentials when called.
363
364
  */
364
365
  remote?: () => RemoteHalf;
366
+ /**
367
+ * Makes the connector's sandbox backend, which opens the boxes of the type that meets the
368
+ * `Sandbox` contract by name: by the app, when a coding agent's turn needs a box. It reads its
369
+ * credentials through `context.secrets`.
370
+ */
371
+ sandbox?: (context: SandboxContext) => SandboxBackend | Promise<SandboxBackend>;
365
372
  }
366
373
 
367
374
  /** What the app gives a connector's model half (`ConnectorSetup.model`): the secrets, as a login half reads them. */
package/src/paths.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { rootSnapshot } from "./bundled.ts";
3
4
  import { APP_NAME, APPS_DIR, appOfPath } from "./shared.ts";
4
5
 
5
6
  // Where an import may reach, as the lints and the data-file reader all decide it, the one walk up
6
- // the directories and the one walk down (`filesUnder`). Node's `path` and `fs` only, no parser:
7
- // the oxlint plugin loads it.
7
+ // the directories and the one walk down (`filesUnder`). Node's `path` and `fs` only (and a
8
+ // bundle's registered roots, plain data), no parser: the oxlint plugin loads it.
8
9
 
9
10
  /**
10
11
  * Where a data file imports `secret` from, and `sanoma`, the runtime's own resource constructors
@@ -70,8 +71,8 @@ export { APPS_DIR, appOfPath };
70
71
  /** Each directory's company root (the nearest one with a `sanoma.config.ts`, short of a `node_modules`), once found. */
71
72
  const roots = new Map<string, string>();
72
73
 
73
- /** Whether `dir` is a company's root: it holds a `sanoma.config.ts`. */
74
- const isCompany = (dir: string) => existsSync(join(dir, "sanoma.config.ts"));
74
+ /** Whether `dir` is a company's root: it holds a `sanoma.config.ts`, or a bundle registered its snapshot (`useRootSnapshot`). */
75
+ const isCompany = (dir: string) => rootSnapshot(dir) !== undefined || existsSync(join(dir, "sanoma.config.ts"));
75
76
 
76
77
  /**
77
78
  * The app a file is in: `<app>` for a file under `<root>/apps/<app>/`, where the root is the
@@ -113,5 +114,22 @@ export const kindDirs = (root: string, kind: string): string[] => [
113
114
  ...appFolders(root).map((app) => join(app, kind)),
114
115
  ];
115
116
 
117
+ /**
118
+ * The files whose declarations join the config's, as `sanoma` loads it and the Worker's bundle
119
+ * imports them: each workflow source under `workflows/` and each app's (a test or a declaration
120
+ * file aside), then each `resources/data.ts` there is.
121
+ */
122
+ export function declarationFiles(root: string): { workflows: string[]; data: string[] } {
123
+ return {
124
+ workflows: kindDirs(root, "workflows").flatMap((dir) => filesUnder(dir, isWorkflowSource)),
125
+ data: kindDirs(root, "resources")
126
+ .map((dir) => join(dir, COLLECTIONS_FILE))
127
+ .filter((path) => existsSync(path)),
128
+ };
129
+ }
130
+
131
+ /** A workflow file's name: `.ts`, but a test (`.test.ts`, `.spec.ts`) or a declaration. */
132
+ const isWorkflowSource = (name: string) => name.endsWith(".ts") && !/\.(test|spec|d)\.ts$/.test(name);
133
+
116
134
  /** Where a company's scenarios are, under its root and each app's folder. */
117
135
  export const SCENARIOS_DIR = "scenarios";
package/src/resource.ts CHANGED
@@ -4,6 +4,7 @@ import { checkDatabase, type DatabaseSpec } from "./database.ts";
4
4
  import { isRunControlError } from "./errors.ts";
5
5
  import { checkHost, hasPath, type HostSpec } from "./host.ts";
6
6
  import { type Connector, type OpSpec, RESOURCE, VENDOR } from "./op.ts";
7
+ import { checkSandbox, type SandboxSpec } from "./sandbox-contract.ts";
7
8
  import { isSanomaSecret, isSecret, type Secret } from "./secret.ts";
8
9
  import { errorMessage } from "./shared.ts";
9
10
  import { checkWakeup, type WakeupSpec } from "./wakeup.ts";
@@ -114,6 +115,11 @@ export interface ResourceSpec<S extends z.ZodObject = z.ZodObject> {
114
115
  * is `writable`, not `stateless`, and its `afterCreate` calls the operation `storeUrl` names.
115
116
  */
116
117
  database?: DatabaseSpec;
118
+ /**
119
+ * Set when the type meets the `Sandbox` contract: it runs a coding agent's boxes, which its
120
+ * connector's backend (`ConnectorSetup.sandbox`) opens. Such a type is `writable`.
121
+ */
122
+ sandbox?: SandboxSpec;
117
123
  /**
118
124
  * True when its objects hold no data, so one gone at the vendor is created again by apply
119
125
  * without asking (a host running the company's code); otherwise apply asks first, as a
@@ -366,6 +372,7 @@ export interface Resource<
366
372
  | "host"
367
373
  | "wakeup"
368
374
  | "database"
375
+ | "sandbox"
369
376
  | "stateless"
370
377
  | "afterCreate"
371
378
  | "vendorless"
@@ -373,7 +380,12 @@ export interface Resource<
373
380
  >
374
381
  >
375
382
  >,
376
- Readonly<Pick<ResourceSpec<S>, "identity" | "find" | "host" | "wakeup" | "database" | "afterCreate" | "formerly">> {
383
+ Readonly<
384
+ Pick<
385
+ ResourceSpec<S>,
386
+ "identity" | "find" | "host" | "wakeup" | "database" | "sandbox" | "afterCreate" | "formerly"
387
+ >
388
+ > {
377
389
  (
378
390
  desired: Declaring<z.input<S>, R>,
379
391
  options?: { connection?: ConnectionRef<Id extends `${infer V}.${string}` ? V : string> },
@@ -710,12 +722,13 @@ export function defineResource<
710
722
  >(
711
723
  spec: ResourceSpec<S> & { vendor: V; type: T; fields: { references?: R }; writable?: W },
712
724
  ): Resource<S, `${V}.${T}`, R, W> {
713
- const { vendor, type, schema, fields, find, identity, host, wakeup, database } = spec;
725
+ const { vendor, type, schema, fields, find, identity, host, wakeup, database, sandbox } = spec;
714
726
  const writable = (spec.writable ?? false) as W;
715
727
  const what: `${V}.${T}` = `${vendor}.${type}`;
716
728
  if (host) checkHost(what, host, spec);
717
729
  if (wakeup) checkWakeup(what, wakeup, spec);
718
730
  if (database) checkDatabase(what, database, spec);
731
+ if (sandbox) checkSandbox(what, sandbox, spec);
719
732
  if (spec.vendorless && (writable || host))
720
733
  throw new Error(`${what}: a vendorless type has no object to change or host`);
721
734
  const title = spec.title.toLowerCase();
@@ -908,6 +921,7 @@ export function defineResource<
908
921
  ...(host && { host }),
909
922
  ...(wakeup && { wakeup }),
910
923
  ...(database && { database }),
924
+ ...(sandbox && { sandbox }),
911
925
  stateless: spec.stateless ?? host !== undefined,
912
926
  vendorless: spec.vendorless ?? false,
913
927
  ...(spec.afterCreate && { afterCreate: spec.afterCreate }),
package/src/run.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import type { ConnectionRef, ConnectionRegistry, ConnectionSnapshot } from "./connection.ts";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
+ import type { WorkerApp } from "./app-steps.ts";
4
+ import type { SanomaClient } from "./client.ts";
3
5
  import type { Collection } from "./collection.ts";
4
6
  import type { DeclaredObjects } from "./config.ts";
7
+ import type { Db } from "./db.ts";
5
8
  import type { DeadlineRegistry } from "./deadlines.ts";
6
9
  import type { ApprovalState, Approver, Principal, WorkflowDefinition } from "./define.ts";
7
10
  import type { Fake } from "./fake.ts";
@@ -53,6 +56,24 @@ export interface WorkerState {
53
56
  scenarios?: URL;
54
57
  /** The worker's `source`, when it was given one. */
55
58
  source?: Source;
59
+ /** The app's steps (`WorkerOptions.app`): what the app's built-ins run; none without them. */
60
+ appSteps?: WorkerApp;
61
+ /**
62
+ * The worker's own client, connected on first use, for its version (what it starts runs on);
63
+ * closed when the worker stops. What the built-ins' steps tell and read through, never a run's body.
64
+ */
65
+ client(): Promise<SanomaClient>;
66
+ /** Runs `write` once the one before it has settled: the worker's writes to its repository, one at a time. */
67
+ serially<T>(write: () => Promise<T>): Promise<T>;
68
+ /**
69
+ * Fast-forwards the checkout to the host's `main` (`followBase`), one of the worker's writes
70
+ * (`serially`), as a change's deploy and a push to `main` do first; on a new revision, how far
71
+ * each app taken from a source is behind it is checked again, in the background, into the
72
+ * runtime's table (`recordAppsBehind` in worker.ts), as at the worker's start.
73
+ */
74
+ followMain(): Promise<{ revision: string } | { skipped: string } | { error: string }>;
75
+ /** The runtime's tables (`db.ts`): what the worker writes for the app to read, such as a sandbox run's checks. */
76
+ db(): Promise<Db>;
56
77
  types: ReadonlyMap<string, Resource>;
57
78
  /** The objects the data files declare, read at each call: a workflow's change operation may not aim at one (`refuseDeclared`). */
58
79
  declaredObjects(): Promise<DeclaredObjects>;
@@ -88,6 +109,11 @@ export interface WorkerState {
88
109
  stopped: boolean;
89
110
  /** Which of its runs are executing and which wait, until when: what the worker's `idle` reads. */
90
111
  deadlines: DeadlineRegistry;
112
+ /**
113
+ * Aborted once the worker has stopped, after DBOS is down: what work of its that outlives a DBOS
114
+ * call stops on (a chat turn's agent, the check of the apps behind), recorded nowhere.
115
+ */
116
+ shutdown: AbortSignal;
91
117
  }
92
118
 
93
119
  /** What a run keeps in memory. Rebuilt the same way when DBOS replays the run. */
@@ -136,3 +162,18 @@ export interface WorkflowOutline {
136
162
  * it, whether it is a sandbox run, the worker's source and policy), as no `ctx` member gives them.
137
163
  */
138
164
  export const currentRun = new AsyncLocalStorage<Run>();
165
+
166
+ /** The run whose built-in step is running (`currentRun`): `what` names the step for the error when there is none. */
167
+ export function runNow(what: string): Run {
168
+ const run = currentRun.getStore();
169
+ if (!run) throw new Error(`${what} ran outside its run`);
170
+ return run;
171
+ }
172
+
173
+ /**
174
+ * A sandbox run's checks recorded for the app (`recordChecks`, which never throws); nothing for a
175
+ * live run, which never loads `sandbox.ts`, and with it Cucumber.
176
+ */
177
+ export async function recordSandboxChecks(run: Run): Promise<void> {
178
+ if (run.sandbox !== undefined) await (await import("./sandbox.ts")).recordChecks(run, run.sandbox);
179
+ }
@@ -0,0 +1,114 @@
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
+ /** The box `name`, made from `image` with `size` vCPUs when there is none, woken or started when stopped. */
84
+ open(opts: { name: string; image: string; size: number }): Promise<Box>;
85
+ /** Deletes the box `name` and whatever the vendor kept of it: the next `open` finds it empty. */
86
+ discard(opts: { name: string }): Promise<void>;
87
+ }
88
+
89
+ /** What the app gives a connector's sandbox half (`ConnectorSetup.sandbox`): the secrets, as a model half reads them. */
90
+ export type SandboxContext = Pick<LoginContext, "secrets">;
91
+
92
+ /**
93
+ * Throws what is wrong with a resource type's `sandbox`: a type apply cannot change (not
94
+ * `writable`), a capability that is not a boolean, an input that names no field, `outputs` that
95
+ * is not a function.
96
+ */
97
+ export function checkSandbox(
98
+ what: string,
99
+ sandbox: SandboxSpec,
100
+ type: { schema: z.ZodObject; writable?: boolean },
101
+ ): void {
102
+ const problems: string[] = [];
103
+ if (!type.writable) problems.push("it must be writable, so apply can create and update it");
104
+ const capabilities: Partial<SandboxCapabilities> = sandbox.capabilities ?? {};
105
+ for (const name of ["snapshot", "pause", "port"] as const) {
106
+ if (typeof capabilities[name] !== "boolean") problems.push(`capabilities.${name} must be true or false`);
107
+ }
108
+ for (const input of ["image", "size"] as const) {
109
+ const path: unknown = sandbox.inputs?.[input];
110
+ if (typeof path !== "string" || !hasPath(type.schema, path)) problems.push(`${input} names no field (${path})`);
111
+ }
112
+ if (typeof sandbox.outputs !== "function") problems.push("outputs must be a function of the state");
113
+ if (problems.length) throw new Error(`${what}: its sandbox: ${problems.join("; ")}`);
114
+ }
package/src/sandbox.ts CHANGED
@@ -9,8 +9,10 @@ import { entry, type LedgerRecord, write } from "./ledger.ts";
9
9
  import type { DriverFn } from "./op.ts";
10
10
  import type { Run } from "./run.ts";
11
11
  import { memorySecrets } from "./secret.ts";
12
- import { type Given, loadScenarios, type Scenario } from "./scenario.ts";
13
- import { errorMessage } from "./shared.ts";
12
+ import { writeRunChecks } from "./db.ts";
13
+ import { warn } from "./log.ts";
14
+ import { check, type Given, loadScenarios, type RunChecks, type Scenario } from "./scenario.ts";
15
+ import { errorMessage, notLoaded } from "./shared.ts";
14
16
 
15
17
  /*
16
18
  * What a worker does for a sandbox run, imported only when it starts one: kept apart from
@@ -125,8 +127,9 @@ export async function seedSandbox(run: Run, name: string, input: unknown): Promi
125
127
  const { scenarios, errors } = await loadScenarios(state);
126
128
  const scenario = scenarios.find((s) => s.name === name);
127
129
  if (!scenario) {
128
- const why = errors.length ? `; these files did not load: ${errors.map((e) => e.message).join("; ")}` : "";
129
- throw new SanomaError("invalid_input", `No scenario named "${name}"${why}`, { scenario: name });
130
+ throw new SanomaError("invalid_input", `No scenario named "${name}"${notLoaded(errors)}`, {
131
+ scenario: name,
132
+ });
130
133
  }
131
134
  if (scenario.workflow !== run.workflow) {
132
135
  throw new SanomaError("invalid_input", `Scenario "${name}" runs ${scenario.workflow}, not ${run.workflow}`, {
@@ -214,3 +217,37 @@ export async function seedSandbox(run: Run, name: string, input: unknown): Promi
214
217
  run.fakeSecrets = secrets;
215
218
  await write(run, entry(run, { type: "scenario.seeded", scenario: name, seeds }));
216
219
  }
220
+
221
+ /**
222
+ * A seeded sandbox run's checks against its ledger so far, written for the app (`writeRunChecks`):
223
+ * Cucumber runs its scenario's Then steps over the feature file as it reads now, which the agent
224
+ * may have changed since. Called when the run waits and when it ends (`recordSandboxChecks`); a
225
+ * check met is settled when what it expects stays so (a call made, a decision recorded), and every
226
+ * one once the run has ended, which the app reads from the run's status. No step and no ledger
227
+ * record, so a replay neither depends on it nor differs by it: a failure is warned about, and the
228
+ * run goes on.
229
+ */
230
+ export async function recordChecks(run: Run, name: string): Promise<void> {
231
+ if (!run.fakeDrivers) return;
232
+ const { state } = run;
233
+ try {
234
+ const checks = await checksOf(run, name).catch((err: unknown): RunChecks => ({ error: errorMessage(err) }));
235
+ await writeRunChecks(await state.db(), run.id, checks);
236
+ } catch (err) {
237
+ warn(`run ${run.id}: could not record its checks against scenario "${name}": ${errorMessage(err)}`);
238
+ }
239
+ }
240
+
241
+ async function checksOf({ id, state }: Run, name: string): Promise<RunChecks> {
242
+ const loaded = await loadScenarios(state);
243
+ const scenario = loaded.scenarios.find((s) => s.name === name);
244
+ if (!scenario) return { error: `The feature files no longer have scenario "${name}"${notLoaded(loaded.errors)}` };
245
+ const checks = await check(state, scenario, await state.ledger.read(id));
246
+ return {
247
+ checks: checks.map((c, i) => {
248
+ const e = scenario.expect[i]!;
249
+ // Met once: a call made, or a decision recorded, stays made. Every one is, once the run has ended (`runDetail`).
250
+ return { ...c, settled: c.ok && (("called" in e && e.called === true) || "decision" in e) };
251
+ }),
252
+ };
253
+ }
package/src/scenario.ts CHANGED
@@ -186,6 +186,23 @@ export interface Check {
186
186
  detail?: string;
187
187
  }
188
188
 
189
+ /** One of a sandbox run's checks, and whether its answer is final. */
190
+ export interface RunCheck extends Check {
191
+ /**
192
+ * True once the answer cannot change: the run has ended, or the check expects a call that
193
+ * has been made (or a decision recorded). Until then a check not met is not failed yet, and
194
+ * one met (a call not made so far) may still fail.
195
+ */
196
+ settled: boolean;
197
+ }
198
+
199
+ /**
200
+ * A sandbox run's checks, as the worker ran them when the run last waited or ended (`recordChecks`):
201
+ * each of its scenario's expectations against its ledger, or why there are none (its scenario is
202
+ * no longer in the feature files, with those that did not load, or they could not be read at all).
203
+ */
204
+ export type RunChecks = { checks: RunCheck[] } | { error: string };
205
+
189
206
  /**
190
207
  * What steps can name: the config's operations, workflows, resource types and collections by id,
191
208
  * the steps its connectors (and the company) ship, where the feature files are, and the root
package/src/schedules.ts CHANGED
@@ -140,23 +140,38 @@ interface TimeMatcher {
140
140
  /** The first instant strictly after `date` that matches; one that does not at the end of its search horizon. */
141
141
  nextWakeupTime(date: Date | number): Date;
142
142
  }
143
- type TimeMatcherClass = new (pattern: string, timezone?: string) => TimeMatcher;
143
+ export type TimeMatcherClass = new (pattern: string, timezone?: string) => TimeMatcher;
144
144
 
145
145
  let matcherClass: TimeMatcherClass | undefined;
146
146
 
147
147
  /**
148
- * DBOS's `TimeMatcher`, from `dist/src/scheduler/crontab.js` beside its main module: the package's
149
- * exports leave it out (dbos-sdk 5.2.11), so it is loaded by path, and a test holds it there.
148
+ * The file DBOS's `TimeMatcher` is in, `dist/src/scheduler/crontab.js` beside its main module, as
149
+ * `require` resolves the package: its exports leave it out (dbos-sdk 5.2.11), so it is loaded by
150
+ * path, and a test holds it there.
150
151
  */
152
+ export const timeMatcherFile = (require: NodeJS.Require): string =>
153
+ join(dirname(require.resolve("@dbos-inc/dbos-sdk")), "scheduler", "crontab.js");
154
+
155
+ /** DBOS's `TimeMatcher`, required from its file (`timeMatcherFile`), or the one `useTimeMatcher` gave. */
151
156
  export function timeMatcher(): TimeMatcherClass {
152
157
  if (matcherClass) return matcherClass;
153
158
  const require = createRequire(import.meta.url);
154
- const file = join(dirname(require.resolve("@dbos-inc/dbos-sdk")), "scheduler", "crontab.js");
159
+ const file = timeMatcherFile(require);
155
160
  const loaded = (require(file) as { TimeMatcher?: unknown }).TimeMatcher;
156
161
  if (typeof loaded !== "function") throw new Error(`DBOS's cron parser is not in ${file} any more`);
157
162
  return (matcherClass = loaded as TimeMatcherClass);
158
163
  }
159
164
 
165
+ /**
166
+ * From now on, DBOS's cron parser is `matcher`, where it cannot be required by path: workerd,
167
+ * which bundles the runtime, and imports it by its file.
168
+ */
169
+ export function useTimeMatcher(matcher: TimeMatcherClass): void {
170
+ if (typeof matcher !== "function")
171
+ throw new Error("DBOS's cron parser (TimeMatcher) is not in its scheduler's crontab.js any more");
172
+ matcherClass = matcher;
173
+ }
174
+
160
175
  /**
161
176
  * When `schedule` (a cron expression read in `timezone`, UTC without one) fires next, strictly
162
177
  * after `from`: as DBOS's scheduler finds it. Throws for a schedule that never fires.
package/src/seal.ts CHANGED
@@ -2,8 +2,9 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2
2
  import { deriveSigningKey, signingKeyFor } from "./deployment-key.ts";
3
3
 
4
4
  // Small values sealed under a key of their own, derived from the deployment's SANOMA_SIGNING_KEY
5
- // for one purpose (HKDF-SHA256): a sign-in code in a `login-code` run's input, and a request for
6
- // an invite that a holder of the key sends to the instance (`POST /auth/invite`). AES-256-GCM, the
5
+ // for one purpose (HKDF-SHA256): a sign-in code in a `login-code` run's input, a code a person
6
+ // pastes back to their own agent's sign-in, in the message to their chat turn's run, and a request
7
+ // for an invite that a holder of the key sends to the instance (`POST /auth/invite`). AES-256-GCM, the
7
8
  // purpose as its additional data, so a value sealed for one purpose never opens for another.
8
9
 
9
10
  const IV = 12;
@@ -40,6 +41,7 @@ function unseal(purpose: string, sealed: string): unknown {
40
41
  }
41
42
 
42
43
  const CODE = "login code";
44
+ const CHAT_CODE = "chat code";
43
45
  const INVITE = "invite request";
44
46
 
45
47
  /**
@@ -57,6 +59,18 @@ export function openLoginCode(sealed: string, to: string): string {
57
59
  return opened.code;
58
60
  }
59
61
 
62
+ /**
63
+ * A code a person pastes for their own agent's sign-in, sealed for the chat turn's run `runId`
64
+ * (`SanomaClient.turnCode`), so the run's messages, which DBOS keeps, never show it.
65
+ */
66
+ export const sealChatCode = (runId: string, code: string): string => seal(CHAT_CODE, { runId, code });
67
+
68
+ /** The code `sealChatCode` sealed for `runId`; undefined for anything else, a code sealed for another run among it. */
69
+ export function openChatCode(sealed: string, runId: string): string | undefined {
70
+ const opened = unseal(CHAT_CODE, sealed) as { runId?: unknown; code?: unknown } | undefined;
71
+ return typeof opened?.code === "string" && opened.runId === runId ? opened.code : undefined;
72
+ }
73
+
60
74
  /** A request for an invite (`POST /auth/invite { request }`): for whom, until when (ms since the epoch), and a nonce. */
61
75
  export interface InviteRequest {
62
76
  email: string;