@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.
- package/README.md +14 -3
- package/dist/app-steps.d.ts +17 -0
- package/dist/app-steps.js +43 -0
- package/dist/app-steps.js.map +1 -0
- package/dist/app-workflows.d.ts +170 -0
- package/dist/app-workflows.js +107 -0
- package/dist/app-workflows.js.map +1 -0
- package/dist/apply-steps.js +3 -9
- package/dist/apply-steps.js.map +1 -1
- package/dist/approvals.d.ts +1 -1
- package/dist/approvals.js +3 -0
- package/dist/approvals.js.map +1 -1
- package/dist/call.d.ts +2 -1
- package/dist/call.js +2 -1
- package/dist/call.js.map +1 -1
- package/dist/change-steps.js +11 -17
- package/dist/change-steps.js.map +1 -1
- package/dist/chat-steps.d.ts +5 -0
- package/dist/chat-steps.js +107 -0
- package/dist/chat-steps.js.map +1 -0
- package/dist/chat-workflow.d.ts +43 -0
- package/dist/chat-workflow.js +47 -0
- package/dist/chat-workflow.js.map +1 -0
- package/dist/chats.d.ts +23 -4
- package/dist/chats.js +54 -7
- package/dist/chats.js.map +1 -1
- package/dist/client.d.ts +42 -31
- package/dist/client.js +118 -105
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +20 -0
- package/dist/config.js +55 -4
- package/dist/config.js.map +1 -1
- package/dist/db/company.d.ts +259 -2
- package/dist/db/company.js +32 -1
- package/dist/db/company.js.map +1 -1
- package/dist/db.d.ts +15 -0
- package/dist/db.js +42 -2
- package/dist/db.js.map +1 -1
- package/dist/description.d.ts +6 -3
- package/dist/description.js +4 -3
- package/dist/description.js.map +1 -1
- package/dist/follow-steps.d.ts +8 -0
- package/dist/follow-steps.js +113 -0
- package/dist/follow-steps.js.map +1 -0
- package/dist/follow-workflow.d.ts +56 -0
- package/dist/follow-workflow.js +50 -0
- package/dist/follow-workflow.js.map +1 -0
- package/dist/index.d.ts +10 -5
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/local-sandbox.d.ts +10 -0
- package/dist/local-sandbox.js +130 -0
- package/dist/local-sandbox.js.map +1 -0
- package/dist/op.d.ts +9 -2
- package/dist/op.js.map +1 -1
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +20 -4
- package/dist/paths.js.map +1 -1
- package/dist/resource.d.ts +7 -1
- package/dist/resource.js +5 -1
- package/dist/resource.js.map +1 -1
- package/dist/run.d.ts +39 -0
- package/dist/run.js +15 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox-contract.d.ts +100 -0
- package/dist/sandbox-contract.js +26 -0
- package/dist/sandbox-contract.js.map +1 -0
- package/dist/sandbox.d.ts +10 -0
- package/dist/sandbox.js +42 -4
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +19 -0
- package/dist/scenario.js.map +1 -1
- package/dist/schedules.d.ts +11 -3
- package/dist/schedules.js +15 -3
- package/dist/schedules.js.map +1 -1
- package/dist/seal.d.ts +7 -0
- package/dist/seal.js +14 -2
- package/dist/seal.js.map +1 -1
- package/dist/shared.d.ts +34 -2
- package/dist/shared.js +39 -2
- package/dist/shared.js.map +1 -1
- package/dist/worker.d.ts +8 -0
- package/dist/worker.js +116 -10
- package/dist/worker.js.map +1 -1
- package/drizzle/company/0002_runs_tell_the_app.sql +27 -0
- package/drizzle/company/meta/0002_snapshot.json +819 -0
- package/drizzle/company/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/app-steps.ts +67 -0
- package/src/app-workflows.ts +251 -0
- package/src/apply-steps.ts +3 -9
- package/src/approvals.ts +3 -1
- package/src/call.ts +2 -1
- package/src/change-steps.ts +10 -17
- package/src/chat-steps.ts +117 -0
- package/src/chat-workflow.ts +69 -0
- package/src/chats.ts +80 -14
- package/src/client.ts +154 -109
- package/src/config.ts +66 -3
- package/src/db/company.ts +49 -2
- package/src/db.ts +51 -3
- package/src/description.ts +4 -3
- package/src/follow-steps.ts +129 -0
- package/src/follow-workflow.ts +88 -0
- package/src/index.ts +32 -3
- package/src/local-sandbox.ts +132 -0
- package/src/op.ts +9 -2
- package/src/paths.ts +22 -4
- package/src/resource.ts +16 -2
- package/src/run.ts +41 -0
- package/src/sandbox-contract.ts +114 -0
- package/src/sandbox.ts +41 -4
- package/src/scenario.ts +17 -0
- package/src/schedules.ts +19 -4
- package/src/seal.ts +16 -2
- package/src/shared.ts +47 -2
- 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,
|
|
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
|
|
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<
|
|
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 {
|
|
13
|
-
import {
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
149
|
-
* exports leave it out (dbos-sdk 5.2.11), so it is loaded by
|
|
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 =
|
|
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,
|
|
6
|
-
//
|
|
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;
|