@sanoma-ai/workflows 0.11.16 → 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/src/config.ts CHANGED
@@ -231,6 +231,12 @@ export interface ResolvedConfig {
231
231
  * declares a host type.
232
232
  */
233
233
  hosts(): Promise<string[]>;
234
+ /**
235
+ * The resources the data files under `root` declare whose type is a sandbox (`<file>#<export>`),
236
+ * read at each call: where a coding agent's boxes are opened. None when no connector declares a
237
+ * sandbox type.
238
+ */
239
+ sandboxes(): Promise<string[]>;
234
240
  /**
235
241
  * The objects the data files under `root` declare, read at each call: what a workflow's change
236
242
  * operation may not aim at, since only apply changes a declared object.
@@ -289,6 +295,47 @@ export function defineConfig(config: SanomaConfig): SanomaConfig {
289
295
  return { ...config, file: config.file ?? callerFile() };
290
296
  }
291
297
 
298
+ /**
299
+ * Joins the declaration files' modules (`declarationFiles`, each by its path) to `config`, as
300
+ * `sanoma` loads it and the Worker's bundle composes it: a workflow file's default export is a
301
+ * workflow (`defineWorkflow`), a file without one a helper; each export of a data file is a
302
+ * collection. One the config lists already is kept, once. A `workflows` or `collections` that is
303
+ * no list is left for `resolveConfig` to explain. Throws naming the file for an export that is
304
+ * neither.
305
+ */
306
+ export function joinDeclarations(
307
+ config: SanomaConfig,
308
+ modules: { workflows: [path: string, module: { default?: unknown }][]; data: [path: string, module: object][] },
309
+ ): void {
310
+ if (config.workflows === undefined || Array.isArray(config.workflows)) {
311
+ const workflows = new Set(config.workflows);
312
+ for (const [path, { default: wf }] of modules.workflows) {
313
+ if (wf === undefined) continue;
314
+ if ((wf as { kind?: unknown } | null)?.kind !== "workflow") {
315
+ throw new Error(
316
+ `${path}'s default export is not a workflow: a file of workflows/ exports one, \`export default defineWorkflow({ … })\`, or none (a helper the workflows import)`,
317
+ );
318
+ }
319
+ workflows.add(wf as WorkflowDefinition<any, any>);
320
+ }
321
+ if (workflows.size > 0) config.workflows = [...workflows];
322
+ }
323
+ if (config.collections === undefined || Array.isArray(config.collections)) {
324
+ const collections = new Set(config.collections);
325
+ for (const [path, module] of modules.data) {
326
+ for (const [name, value] of Object.entries(module)) {
327
+ if (!isCollection(value)) {
328
+ throw new Error(
329
+ `${path} exports ${name}, which is not a collection: resources/data.ts holds collections only, each \`export const ${name} = collection({ name, key, fields })\` with collection from "sanoma/data"`,
330
+ );
331
+ }
332
+ collections.add(value);
333
+ }
334
+ }
335
+ if (collections.size > 0) config.collections = [...collections];
336
+ }
337
+ }
338
+
292
339
  export function resolveDatabaseUrl(config: { databaseUrl?: string }, env: NodeJS.ProcessEnv = process.env): string {
293
340
  return config.databaseUrl ?? env.SANOMA_DATABASE_URL ?? DEFAULT_DATABASE_URL;
294
341
  }
@@ -445,13 +492,14 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
445
492
  names.set(APPLY_WORKFLOW, applyWorkflow(types, ops, drivers, applySteps(appName, root, read)));
446
493
  }
447
494
  // Built-in, in every config: the life of a change, which deploys to the declared hosts after its merge.
448
- // The declared hosts' ids, as the files say them: nothing derived, nothing minted.
449
- const hosts = async () => {
495
+ // The declared hosts' (and sandboxes') ids, as the files say them: nothing derived, nothing minted.
496
+ const declaredOf = (contract: "host" | "sandbox") => async () => {
450
497
  const { loadDataFiles } = await import("./resources.ts");
451
498
  return loadDataFiles(root, connectors, declarable)
452
- .resources.filter((r) => types.get(`${r.vendor}.${r.type}`)?.host)
499
+ .resources.filter((r) => types.get(`${r.vendor}.${r.type}`)?.[contract])
453
500
  .map((r) => r.id);
454
501
  };
502
+ const hosts = declaredOf("host");
455
503
  names.set(CHANGE_WORKFLOW, changeWorkflow(changeOps, changeSteps({ remotes, hosts })));
456
504
  // Built-in, in every config: what a git host's webhook says happened, which the worker with the checkout follows.
457
505
  names.set(FOLLOW_WORKFLOW, followWorkflow(followSteps({ remotes, hosts })));
@@ -494,6 +542,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
494
542
  models: makersOf(config.connectors, "model"),
495
543
  remotes,
496
544
  hosts,
545
+ sandboxes: declaredOf("sandbox"),
497
546
  declaredObjects,
498
547
  ...(scenarios && { scenarios }),
499
548
  types,
@@ -298,10 +298,11 @@ export const VendorEntrySchema = z.strictObject({
298
298
  export type VendorEntry = z.infer<typeof VendorEntrySchema>;
299
299
 
300
300
  /**
301
- * The contracts a resource type meets, from its spec: `host`, `database` and `wakeup` (`Host`,
302
- * `Database`, `Wakeup`), and `source` for a type a host's `source` names (the repository it clones).
301
+ * The contracts a resource type meets, from its spec: `host`, `database`, `wakeup` and `sandbox`
302
+ * (`Host`, `Database`, `Wakeup`, `Sandbox`), and `source` for a type a host's `source` names (the
303
+ * repository it clones).
303
304
  */
304
- export const CAPABILITIES = ["host", "database", "wakeup", "source"] as const;
305
+ export const CAPABILITIES = ["host", "database", "wakeup", "sandbox", "source"] as const;
305
306
 
306
307
  /** A resource type a connector declares with `defineResource`. */
307
308
  export const ResourceTypeEntrySchema = z.strictObject({
package/src/index.ts CHANGED
@@ -69,6 +69,7 @@ export {
69
69
  declaredWakeup,
70
70
  defineConfig,
71
71
  envWakeup,
72
+ joinDeclarations,
72
73
  type Login,
73
74
  readRootSnapshot,
74
75
  resolveConfig,
@@ -79,7 +80,7 @@ export {
79
80
  // An app's `app.ts` (`defineApp`), and where the launcher finds an app's workflows and data file.
80
81
  export { type AppEntry, appSourceOf, readApps } from "./apps.ts";
81
82
  export { type AppDefinition, defineApp } from "./definitions.ts";
82
- export { appFolders, APPS_DIR, filesUnder, kindDirs } from "./paths.ts";
83
+ export { appFolders, APPS_DIR, declarationFiles, filesUnder, kindDirs } from "./paths.ts";
83
84
  // `sanoma` itself (the people types' constructors) is at `@sanoma-ai/workflows/resources`, for data files.
84
85
  // `collection()` itself is at `@sanoma-ai/workflows/data`, for `resources/data.ts`; this tells one apart, as the launcher does.
85
86
  export { isCollection } from "./collection.ts";
@@ -193,7 +194,7 @@ export type { RunArgs } from "./run.ts";
193
194
  export { startWorker, type Worker, type WorkerOptions } from "./worker.ts";
194
195
  export { superviseWorker, type Supervisor, type SupervisorOptions } from "./supervisor.ts";
195
196
  export type { DeadlineSource, Deadlines, SelfStop, Wait } from "./deadlines.ts";
196
- export { nextFire, nextScheduledFire } from "./schedules.ts";
197
+ export { nextFire, nextScheduledFire, timeMatcherFile, type TimeMatcherClass, useTimeMatcher } from "./schedules.ts";
197
198
  // `secret` itself is at `@sanoma-ai/workflows/resources`, for data files; this is the drivers' side.
198
199
  export {
199
200
  generateSecret,
@@ -252,6 +253,16 @@ export {
252
253
  databaseUrlSecret,
253
254
  storeDatabaseUrl,
254
255
  } from "./database.ts";
256
+ export {
257
+ type Box,
258
+ type Process,
259
+ type SandboxBackend,
260
+ type SandboxCapabilities,
261
+ type SandboxContext,
262
+ type SandboxOutputs,
263
+ type SandboxSpec,
264
+ } from "./sandbox-contract.ts";
265
+ export { LOCAL_SANDBOX, localBackend } from "./local-sandbox.ts";
255
266
  export type { Chat, Chats } from "./chats.ts";
256
267
  export type { Tokens } from "./tokens.ts";
257
268
  export { type InviteRequest, openInvite, sealInvite, sealLoginCode } from "./seal.ts";
@@ -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 }),
@@ -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/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.