@sanoma-ai/workflows 0.11.16 → 0.11.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +16 -8
  2. package/dist/call.d.ts +35 -4
  3. package/dist/call.js +128 -40
  4. package/dist/call.js.map +1 -1
  5. package/dist/client.d.ts +17 -8
  6. package/dist/client.js +90 -51
  7. package/dist/client.js.map +1 -1
  8. package/dist/collection.d.ts +25 -7
  9. package/dist/collection.js +236 -91
  10. package/dist/collection.js.map +1 -1
  11. package/dist/config.d.ts +24 -1
  12. package/dist/config.js +48 -10
  13. package/dist/config.js.map +1 -1
  14. package/dist/db/company.d.ts +20 -0
  15. package/dist/db/company.js +9 -1
  16. package/dist/db/company.js.map +1 -1
  17. package/dist/describe.js +1 -1
  18. package/dist/describe.js.map +1 -1
  19. package/dist/description.d.ts +6 -3
  20. package/dist/description.js +5 -4
  21. package/dist/description.js.map +1 -1
  22. package/dist/errors.d.ts +2 -1
  23. package/dist/errors.js +3 -2
  24. package/dist/errors.js.map +1 -1
  25. package/dist/grid.js +3 -2
  26. package/dist/grid.js.map +1 -1
  27. package/dist/host-derive.d.ts +13 -8
  28. package/dist/host-derive.js +26 -13
  29. package/dist/host-derive.js.map +1 -1
  30. package/dist/index.d.ts +7 -3
  31. package/dist/index.js +7 -3
  32. package/dist/index.js.map +1 -1
  33. package/dist/ledger.d.ts +7 -0
  34. package/dist/ledger.js +32 -2
  35. package/dist/ledger.js.map +1 -1
  36. package/dist/local-sandbox.d.ts +17 -0
  37. package/dist/local-sandbox.js +131 -0
  38. package/dist/local-sandbox.js.map +1 -0
  39. package/dist/op.d.ts +10 -2
  40. package/dist/op.js.map +1 -1
  41. package/dist/paths.d.ts +9 -0
  42. package/dist/paths.js +20 -4
  43. package/dist/paths.js.map +1 -1
  44. package/dist/resource.d.ts +7 -1
  45. package/dist/resource.js +5 -1
  46. package/dist/resource.js.map +1 -1
  47. package/dist/resources.d.ts +1 -1
  48. package/dist/resources.js +9 -6
  49. package/dist/resources.js.map +1 -1
  50. package/dist/runner-box.d.ts +23 -0
  51. package/dist/runner-box.js +181 -0
  52. package/dist/runner-box.js.map +1 -0
  53. package/dist/runner-protocol.d.ts +73 -0
  54. package/dist/runner-protocol.js +34 -0
  55. package/dist/runner-protocol.js.map +1 -0
  56. package/dist/sandbox-contract.d.ts +109 -0
  57. package/dist/sandbox-contract.js +26 -0
  58. package/dist/sandbox-contract.js.map +1 -0
  59. package/dist/sandbox-runner.cjs +7 -0
  60. package/dist/scenario.js +16 -7
  61. package/dist/scenario.js.map +1 -1
  62. package/dist/schedules.d.ts +11 -3
  63. package/dist/schedules.js +15 -3
  64. package/dist/schedules.js.map +1 -1
  65. package/dist/schema.js +6 -3
  66. package/dist/schema.js.map +1 -1
  67. package/dist/secret.d.ts +2 -1
  68. package/dist/secret.js +3 -2
  69. package/dist/secret.js.map +1 -1
  70. package/dist/shared.d.ts +39 -5
  71. package/dist/shared.js +13 -1
  72. package/dist/shared.js.map +1 -1
  73. package/dist/sites.js +10 -8
  74. package/dist/sites.js.map +1 -1
  75. package/dist/source.d.ts +21 -0
  76. package/dist/source.js.map +1 -1
  77. package/drizzle/company/0003_ledger_target.sql +2 -0
  78. package/drizzle/company/meta/0003_snapshot.json +845 -0
  79. package/drizzle/company/meta/_journal.json +7 -0
  80. package/package.json +5 -2
  81. package/src/call.ts +181 -60
  82. package/src/client.ts +118 -67
  83. package/src/collection.ts +265 -97
  84. package/src/config.ts +65 -16
  85. package/src/db/company.ts +9 -1
  86. package/src/describe.ts +1 -1
  87. package/src/description.ts +5 -4
  88. package/src/errors.ts +3 -2
  89. package/src/grid.ts +3 -1
  90. package/src/host-derive.ts +38 -18
  91. package/src/index.ts +15 -2
  92. package/src/ledger.ts +42 -2
  93. package/src/local-sandbox.ts +133 -0
  94. package/src/op.ts +10 -2
  95. package/src/paths.ts +22 -4
  96. package/src/resource.ts +16 -2
  97. package/src/resources.ts +8 -6
  98. package/src/runner-box.ts +218 -0
  99. package/src/runner-protocol.ts +61 -0
  100. package/src/sandbox-contract.ts +121 -0
  101. package/src/sandbox-runner.ts +187 -0
  102. package/src/scenario.ts +16 -6
  103. package/src/schedules.ts +19 -4
  104. package/src/schema.ts +14 -3
  105. package/src/secret.ts +6 -2
  106. package/src/shared.ts +40 -4
  107. package/src/sites.ts +11 -7
  108. package/src/source.ts +14 -0
package/src/describe.ts CHANGED
@@ -205,7 +205,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
205
205
  key: c.key,
206
206
  fields: c.fields,
207
207
  record: { $id: `sanoma:collection/${c.name}`, ...toJsonSchema(recordSchemaOf(c), `${c.name} record`, "input") },
208
- ops: [c.get, c.list, c.save, c.delete].map((op) => op.id),
208
+ ops: [c.get, c.list, c.count, c.save, c.saveMany, c.delete].map((op) => op.id),
209
209
  ...(declared.has(c.name) && { declared: declared.get(c.name) }),
210
210
  }))
211
211
  .toSorted((a, b) => a.name.localeCompare(b.name));
@@ -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({
@@ -348,7 +349,7 @@ export const CollectionEntrySchema = z.strictObject({
348
349
  fields: z.record(z.string(), FieldSchema),
349
350
  /** What `save` accepts, as JSON Schema, with `$id` `sanoma:collection/<name>`. */
350
351
  record: JsonSchema,
351
- /** Its operations' ids: `data.<name>.get`, `.list`, `.save` and `.delete`. */
352
+ /** Its operations' ids: `data.<name>.get`, `.list`, `.count`, `.save`, `.saveMany` and `.delete`. */
352
353
  ops: z.array(z.string()),
353
354
  /** Where `resources/data.ts` declares it, read as the data files are; absent for one declared elsewhere. */
354
355
  declared: DeclaredCollectionSchema.optional(),
package/src/errors.ts CHANGED
@@ -79,8 +79,9 @@ export const isRunControlError = (err: unknown): boolean =>
79
79
 
80
80
  /** Thrown into the run when the policy denies an operation call. The run fails with it. */
81
81
  export class PolicyDeniedError extends SanomaError {
82
- constructor(op: string, reason: string) {
83
- super("policy_denied", `${op} was denied by policy: ${reason}`, { op, reason });
82
+ /** `data` adds to the error's: a batch's `denied` keys. */
83
+ constructor(op: string, reason: string, data: Record<string, unknown> = {}) {
84
+ super("policy_denied", `${op} was denied by policy: ${reason}`, { op, reason, ...data });
84
85
  this.name = "PolicyDeniedError";
85
86
  }
86
87
  }
package/src/grid.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  ANONYMOUS,
8
8
  ANONYMOUS_PRINCIPAL,
9
9
  groupNamesOf,
10
+ isBatch,
10
11
  previewRequests,
11
12
  VISITORS,
12
13
  visitorPrincipal,
@@ -71,7 +72,8 @@ export function permissionsGrid(
71
72
  ];
72
73
  const opsById = new Map(description.ops.map((op) => [op.id, op]));
73
74
  const workflows = description.workflows.filter((w) => !w.builtin);
74
- const direct = description.ops.filter((op) => op.direct).toSorted((a, b) => a.id.localeCompare(b.id));
75
+ // A batch has no row: the policy decides its entries, the save and the delete, which have theirs.
76
+ const direct = description.ops.filter((op) => op.direct && !isBatch(op)).toSorted((a, b) => a.id.localeCompare(b.id));
75
77
  // Every row's question, in the grid's order: each workflow's start, then the operations it may
76
78
  // call, as the run form asks them; then each direct operation.
77
79
  const asked = workflows.map((w) => previewRequests(w, opsById));
@@ -11,6 +11,7 @@ import { runtimeImage, sourceKeyId } from "./host.ts";
11
11
  import type { Bindings } from "./lockfile.ts";
12
12
  import { type Resource, valueAt } from "./resource.ts";
13
13
  import { secret, type Secret, type Secrets } from "./secret.ts";
14
+ import type { SecretConfiguration } from "./secret-store.ts";
14
15
  import { sourceKeyPair, sshPublicKey } from "./ssh-key.ts";
15
16
  import { mintInstanceToken, WAKEUP_TOKEN } from "./wakeup-service.ts";
16
17
 
@@ -78,8 +79,10 @@ export interface HostFacts {
78
79
  unbound?: ReadonlyMap<string, readonly string[]>;
79
80
  /** True when the config binds `SANOMA_SIGNING_KEY`: the host is given that key, else its own (`HOST_SIGNING_KEY`). */
80
81
  signingKeyBound?: boolean;
81
- /** Explicit environment delivery: destination variable to logical secret name, never values. */
82
+ /** Explicit environment delivery (`host: true`): destination variable to logical secret name, never values. */
82
83
  hostEnvironment?: Readonly<Record<string, string>>;
84
+ /** Of `hostEnvironment`'s logical names, those with no value where apply runs: the host cannot be given them. */
85
+ hostEnvironmentUnset?: readonly string[];
83
86
  /**
84
87
  * While the config has no deploy-key type, the GitHub repositories a host may clone (`origin`'s,
85
88
  * a declared `source`'s) that GitHub shows to no one: private (or not there). One GitHub does
@@ -227,8 +230,8 @@ const nameOf = (repository: string) =>
227
230
  * the caller leaves out, with what names them, so apply changes nothing it cannot give all it needs.
228
231
  * Problems: a host with no `database`, a name (none to tell, or another host's), an image or a
229
232
  * source that cannot be told, a credential its vendor's driver reads from this machine's
230
- * environment that nothing binds, a private repository it has no deploy key for, two hosts that
231
- * would attach the sites' domains.
233
+ * environment that nothing binds, a binding given to it (`host: true`) with no value where apply
234
+ * runs, a private repository it has no deploy key for, two hosts that would attach the sites' domains.
232
235
  */
233
236
  export function deriveHosts(
234
237
  resources: DeclaredResource[],
@@ -355,10 +358,16 @@ export function deriveHosts(
355
358
  const carried = [...vendors].flatMap((vendor) => facts.carried.get(vendor) ?? []);
356
359
  for (const name of [...carried, ...facts.githubApp]) secrets[name] = secret(name);
357
360
  for (const [environment, name] of Object.entries(facts.hostEnvironment ?? {})) {
358
- if (Object.hasOwn(secrets, environment) && secrets[environment]!.$secret !== name)
361
+ const derived = Object.hasOwn(secrets, environment);
362
+ if (derived && secrets[environment]!.$secret !== name)
359
363
  problem(`${environment} has a derived host binding and cannot be replaced by ${name}`);
360
- else if (!Object.hasOwn(secrets, environment))
361
- Object.defineProperty(secrets, environment, { value: secret(name), enumerable: true });
364
+ // Its binding is `{ env }` (`host` takes no other, and none is generated): the variable it is
365
+ // read from where apply runs. Unset, it fails the host's update, a derived one's too.
366
+ else if (facts.hostEnvironmentUnset?.includes(name))
367
+ problem(
368
+ `${name} (\`host: true\`) has no value where apply runs, so the host cannot be given it: set ${environment}, which its binding in sanoma.config.ts reads, or take \`host: true\` off that binding`,
369
+ );
370
+ else if (!derived) Object.defineProperty(secrets, environment, { value: secret(name), enumerable: true });
362
371
  }
363
372
  // One the driver reads here from the environment alone would be left off the host, and a
364
373
  // Worker updated without it would lose it.
@@ -440,8 +449,9 @@ export async function hostRootFacts(root: string): Promise<HostRootFacts> {
440
449
  * What the declared hosts that derive (`deriveHosts`) are filled from: the root's facts
441
450
  * (`rootFacts`, asked only when a declared host derives: `hostRootFacts`, or a snapshot's), and of
442
451
  * `secrets` (none outside a run) which
443
- * optional credentials, App keys and source key are stored, and which credentials the drivers
444
- * read from this machine's environment alone (`unbound`). With `mint` (apply, never drift or a
452
+ * optional credentials, App keys and source key are stored, which credentials the drivers
453
+ * read from this machine's environment alone (`unbound`), and which bindings given to the hosts
454
+ * (`host: true`) have no value (`hostEnvironmentUnset`). With `mint` (apply, never drift or a
445
455
  * sandbox run), the source key (where the config has the deploy-key type) and, when a wakeup is
446
456
  * declared, the wakeup's token are minted first when none is stored, so the host is given them
447
457
  * from its first apply. With no deploy-key type, GitHub is asked which repositories a host may clone are private.
@@ -454,32 +464,41 @@ export async function hostFacts(options: {
454
464
  drivers: ReadonlyMap<string, Driver>;
455
465
  secrets?: Secrets;
456
466
  builtInStore: boolean;
457
- /** True when the config binds `SANOMA_SIGNING_KEY` (`{ env }` or `{ bootstrap }`). */
458
- signingKeyBound?: boolean;
459
- hostEnvironment?: Readonly<Record<string, string>>;
467
+ /** The config's secret bindings: whether it binds `SANOMA_SIGNING_KEY`, and those given to the hosts (`host: true`). */
468
+ bindings?: SecretConfiguration["bindings"];
460
469
  sender?: string;
461
470
  mint: boolean;
471
+ /** A sandbox run's: its calls resolve no stored value, so a binding given to the hosts with none is no problem. */
472
+ sandbox?: boolean;
462
473
  }): Promise<HostFacts | undefined> {
463
474
  const { rootFacts, resources, types, drivers, secrets, mint } = options;
464
475
  const typeOf = (r: DeclaredResource) => types.get(`${r.vendor}.${r.type}`);
465
476
  const deriving = resources.filter((r) => typeOf(r)?.host?.derives);
466
477
  if (!deriving.length) return undefined;
467
478
  const hosts = deriving.filter((r) => !typeOf(r)?.host?.bindsBootstrap);
468
- // Which optional credentials and App keys are stored: read at once, and only for a host given secrets.
479
+ // The store is read only in a run, and only for a host given secrets.
480
+ const reading = secrets !== undefined && hosts.length > 0;
481
+ // Which optional credentials and App keys are stored: read at once.
469
482
  const stored = async (names: readonly string[]) =>
470
- secrets && hosts.length
483
+ reading
471
484
  ? new Set((await Promise.all(names.map(async (n) => ((await secrets.get(n)) === undefined ? [] : [n])))).flat())
472
485
  : new Set<string>();
473
486
  const statuses = [...drivers].flatMap(([vendor, { env }]) =>
474
487
  env ? statusesOf(env, () => undefined).map((c) => ({ vendor, ...c })) : [],
475
488
  );
489
+ const bindings = options.bindings ?? {};
490
+ const hostEnvironment: Record<string, string> = Object.fromEntries(
491
+ Object.entries(bindings).flatMap(([name, b]) => (b.host && b.env !== undefined ? [[b.env, name]] : [])),
492
+ );
493
+ const delivered = Object.values(hostEnvironment);
476
494
  // Only what the store holds: a marker for a name it does not would fail the host's deploy.
477
- const held = await stored(statuses.map((c) => c.name));
495
+ const held = await stored([...statuses.map((c) => c.name), ...delivered]);
496
+ const hostEnvironmentUnset = reading && !options.sandbox ? delivered.filter((name) => !held.has(name)) : [];
478
497
  const carried = new Map<string, string[]>();
479
498
  const unbound = new Map<string, string[]>();
480
499
  for (const c of statuses) {
481
500
  // The driver reads the environment first: set there alone, it runs here and the host is not given it.
482
- const into = held.has(c.name) ? carried : secrets && hosts.length && process.env[c.name] ? unbound : undefined;
501
+ const into = held.has(c.name) ? carried : reading && process.env[c.name] ? unbound : undefined;
483
502
  into?.set(c.vendor, [...(into.get(c.vendor) ?? []), c.name]);
484
503
  }
485
504
  const githubApp = [...(await stored(Object.values(GITHUB_APP)))];
@@ -487,7 +506,7 @@ export async function hostFacts(options: {
487
506
  const keyed = types.has(DEPLOY_KEY_TYPE);
488
507
  const [{ version, origin, domains }, sourceKey] = await Promise.all([
489
508
  rootFacts(),
490
- secrets && hosts.length && keyed ? sourceKeyOf(secrets, mint) : undefined,
509
+ reading && keyed ? sourceKeyOf(secrets, mint) : undefined,
491
510
  ]);
492
511
  // What a host may clone: `origin`, else the `source` it declares (with no deploy-key type, no
493
512
  // GitHub repository is declared to be one).
@@ -510,8 +529,9 @@ export async function hostFacts(options: {
510
529
  builtInStore: options.builtInStore,
511
530
  carried,
512
531
  ...(unbound.size > 0 && { unbound }),
513
- ...(options.signingKeyBound && { signingKeyBound: true }),
514
- ...(options.hostEnvironment && { hostEnvironment: options.hostEnvironment }),
532
+ ...(bindings.SANOMA_SIGNING_KEY !== undefined && { signingKeyBound: true }),
533
+ ...(delivered.length > 0 && { hostEnvironment }),
534
+ ...(hostEnvironmentUnset.length > 0 && { hostEnvironmentUnset }),
515
535
  ...(options.sender !== undefined && { sender: options.sender }),
516
536
  githubApp,
517
537
  ...(sourceKey !== undefined && { sourceKey }),
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,18 @@ 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";
266
+ export { runnerBox, runnerHealth, runnerScript } from "./runner-box.ts";
267
+ export { RUNNER_HEALTH, RUNNER_PORT, RUNNER_PROTOCOL, type RunnerHealth } from "./runner-protocol.ts";
255
268
  export type { Chat, Chats } from "./chats.ts";
256
269
  export type { Tokens } from "./tokens.ts";
257
270
  export { type InviteRequest, openInvite, sealInvite, sealLoginCode } from "./seal.ts";
package/src/ledger.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { ConnectionSnapshot } from "./connection.ts";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  import { randomUUID } from "node:crypto";
4
- import { appendFile, mkdir, readFile, truncate } from "node:fs/promises";
4
+ import { appendFile, mkdir, readdir, readFile, truncate } from "node:fs/promises";
5
5
  import { join, resolve } from "node:path";
6
- import { eq, sql } from "drizzle-orm";
6
+ import { desc, eq, sql } from "drizzle-orm";
7
7
  import { setTimeout as delay } from "node:timers/promises";
8
8
  import { isDeepStrictEqual } from "node:util";
9
9
  import { type Db, lazyDb, pgCode } from "./db.ts";
@@ -48,6 +48,11 @@ export type LedgerRecord = {
48
48
  type: "op.called";
49
49
  connection?: ConnectionSnapshot;
50
50
  op: string;
51
+ /**
52
+ * The instance the call acted on (`data:outbound/Acme`), from the operation's `target`: what
53
+ * `readByTarget` finds the record by. Absent for an operation that declares none.
54
+ */
55
+ target?: string;
51
56
  /**
52
57
  * The outline node of the call that made it (`OutlineNode.path`): where the step is in the
53
58
  * code. Absent in records written before the worker placed calls (ledgers from 0.1).
@@ -234,6 +239,8 @@ export interface LedgerStore {
234
239
  append(record: LedgerRecord): Promise<void>;
235
240
  /** A run's records in `seq` order. */
236
241
  read(runId: string): Promise<LedgerRecord[]>;
242
+ /** The `op.called` records whose `target` it is, across every run, newest first, at most `limit`: a record's history. */
243
+ readByTarget(target: string, limit: number): Promise<LedgerRecord[]>;
237
244
  /**
238
245
  * Releases what the store holds (a connection pool), for good. Called by the one owner of the
239
246
  * store: the worker's `stop` and `SanomaClient.close` close the Postgres store each made for a
@@ -261,6 +268,13 @@ export function ownedLedger(config: { ledger?: LedgerStore; databaseUrl: string
261
268
 
262
269
  const bySeq = (a: LedgerRecord, b: LedgerRecord) => a.seq - b.seq;
263
270
 
271
+ /** The calls among `records` that acted on `target`, as `readByTarget` reads them: newest first, by `at`, then `seq`. */
272
+ const actedOn = (records: LedgerRecord[], target: string, limit: number): LedgerRecord[] =>
273
+ records
274
+ .filter((r) => r.type === "op.called" && r.target === target)
275
+ .toSorted((a, b) => b.at - a.at || b.seq - a.seq)
276
+ .slice(0, limit);
277
+
264
278
  /** An error that trying again would only repeat. */
265
279
  const final = (message: string, cause: unknown) => Object.assign(new Error(message, { cause }), { retryable: false });
266
280
 
@@ -364,6 +378,16 @@ export function jsonlLedger(dir: string): LedgerStore {
364
378
  // A case-insensitive file system can map two run ids to one file.
365
379
  return (await load(path)).records.filter((r) => r.runId === runId).toSorted(bySeq);
366
380
  },
381
+ async readByTarget(target, limit) {
382
+ await Promise.all([...queues.values()].map((pending) => pending.catch(() => {})));
383
+ const files = await readdir(root).catch((err: NodeJS.ErrnoException) => {
384
+ if (err.code === "ENOENT") return [];
385
+ throw err;
386
+ });
387
+ const runs = await Promise.all(files.filter((f) => f.endsWith(".jsonl")).map((f) => load(join(root, f))));
388
+ const records = runs.flatMap((run) => run.records);
389
+ return actedOn(records, target, limit);
390
+ },
367
391
  };
368
392
  }
369
393
 
@@ -398,6 +422,18 @@ export function postgresLedger(databaseUrl: string): LedgerStore & { close(): Pr
398
422
  // In seq order, as the other stores read; two records of one seq in write order.
399
423
  return rows.map((r) => r.record);
400
424
  },
425
+ async readByTarget(target, limit) {
426
+ const rows = await (
427
+ await store.db()
428
+ )
429
+ .select({ record: ledger.record })
430
+ .from(ledger)
431
+ .where(eq(ledger.target, target))
432
+ // Newest first, as the other stores read; two records of one `at` and `seq` in write order.
433
+ .orderBy(desc(sql`(${ledger.record} ->> 'at')::bigint`), desc(ledger.seq), desc(ledger.n))
434
+ .limit(limit);
435
+ return rows.map((r) => r.record);
436
+ },
401
437
  close: store.close,
402
438
  };
403
439
  }
@@ -438,6 +474,10 @@ export function memoryLedger(): LedgerStore {
438
474
  async read(runId) {
439
475
  return [...(runs.get(runId)?.values() ?? [])].map((r) => structuredClone(r)).toSorted(bySeq);
440
476
  },
477
+ async readByTarget(target, limit) {
478
+ const records = [...runs.values()].flatMap((run) => [...run.values()]);
479
+ return actedOn(records, target, limit).map((r) => structuredClone(r));
480
+ },
441
481
  };
442
482
  }
443
483
 
@@ -0,0 +1,133 @@
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. The runner in a remote box
44
+ * (`sandbox-runner.ts`) serves its own machine's.
45
+ */
46
+ export function localBox(id: string, home: string, base: Record<string, string>): Box {
47
+ /** The process groups its commands lead, killed by `stop` whether or not their leader has exited. */
48
+ const groups = new Set<number>();
49
+ const at = (path: string) => resolve(home, path);
50
+ return {
51
+ id,
52
+ async run(command, { cwd, env, stdin, signal } = {}): Promise<Process> {
53
+ const child = spawn("bash", ["-c", command], {
54
+ cwd: at(cwd ?? "."),
55
+ env: { ...base, ...env },
56
+ stdio: "pipe",
57
+ // Its own process group, so a bridge and what it starts are killed together.
58
+ detached: true,
59
+ });
60
+ const group = child.pid;
61
+ if (group !== undefined) groups.add(group);
62
+ child.stdin.on("error", () => {});
63
+ if (!stdin) child.stdin.end();
64
+ let ended = false;
65
+ const exited = new Promise<{ exitCode: number }>((done) => {
66
+ child.on("error", () => done({ exitCode: 127 }));
67
+ // Exited when it exits, not when its pipes close: what it left running may hold them. What
68
+ // it printed last still arrives, so the end waits a moment for that.
69
+ child.on("exit", (code) => {
70
+ ended = true;
71
+ // Forgotten once none of its group is left, so `stop` kills no group its pid leads later.
72
+ if (group !== undefined && !groupAlive(group)) groups.delete(group);
73
+ const end = () => done({ exitCode: code ?? 1 });
74
+ child.on("close", end);
75
+ setTimeout(end, PIPES_WAIT).unref();
76
+ });
77
+ });
78
+ const kill = (sig: NodeJS.Signals = "SIGKILL") => {
79
+ // Once it has ended, only while its group is left, so a reused pid is never hit.
80
+ if (group !== undefined && (!ended || groupAlive(group))) killGroup(group, sig);
81
+ };
82
+ const onAbort = () => kill();
83
+ if (signal?.aborted) onAbort();
84
+ signal?.addEventListener("abort", onAbort, { once: true });
85
+ void exited.then(() => signal?.removeEventListener("abort", onAbort));
86
+ return {
87
+ stdin: Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
88
+ stdout: web(child.stdout),
89
+ stderr: web(child.stderr),
90
+ wait: () => exited,
91
+ kill,
92
+ };
93
+ },
94
+ // A Buffer is a Uint8Array: no copy.
95
+ read: (path) => readFile(at(path)).catch(() => null),
96
+ async write(path, bytes, { mode } = {}) {
97
+ await mkdir(dirname(at(path)), { recursive: true });
98
+ await writeFile(at(path), bytes);
99
+ if (mode !== undefined) await chmod(at(path), mode);
100
+ },
101
+ /** A port on loopback. */
102
+ expose: async (port) => ({ url: `http://127.0.0.1:${port}` }),
103
+ /** Kills every command's process group: what a bridge started too. */
104
+ async stop() {
105
+ for (const group of groups) killGroup(group);
106
+ groups.clear();
107
+ },
108
+ };
109
+ }
110
+
111
+ /** How long a command's last output may take to arrive after it exits, in ms. */
112
+ const PIPES_WAIT = 200;
113
+
114
+ const web = (stream: Readable) => Readable.toWeb(stream) as ReadableStream<Uint8Array>;
115
+
116
+ /** Whether any process of the group `group` is left. */
117
+ function groupAlive(group: number): boolean {
118
+ try {
119
+ process.kill(-group, 0);
120
+ return true;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /** Kills the process group `group` (a `detached` child's pid). */
127
+ function killGroup(group: number, signal: NodeJS.Signals = "SIGKILL") {
128
+ try {
129
+ process.kill(-group, signal);
130
+ } catch {
131
+ // Gone already.
132
+ }
133
+ }
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,13 @@ 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, once per declared resource of that type, when a coding
369
+ * agent's turn needs a box. It reads its credentials through `context.secrets` and the
370
+ * resource's fields from `context.declared`.
371
+ */
372
+ sandbox?: (context: SandboxContext) => SandboxBackend | Promise<SandboxBackend>;
365
373
  }
366
374
 
367
375
  /** 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/resources.ts CHANGED
@@ -203,7 +203,7 @@ export function admit(
203
203
  * Each declaration is joined to its binding in the lockfile (`readLockfile`) by its key,
204
204
  * `<file>#<export>`; a binding no export declares is an orphan, unless its file failed to load.
205
205
  * Problems: the lockfile's, a binding of another type than its declaration's, two declarations
206
- * bound to one object, and several wakeups.
206
+ * bound to one object, and several wakeups or sandboxes.
207
207
  */
208
208
  export function loadDataFiles(
209
209
  root: string | undefined,
@@ -367,13 +367,15 @@ function declarationsOf(
367
367
  if (why === undefined) boundBy.set(object, id);
368
368
  else leaveOut(id, why);
369
369
  }
370
- // An instance registers with one wakeup: several are each a problem, and left out, as is any that names one.
371
- const wakeups = [...declarations.keys()].filter((id) => types.get(declarations.get(id)!.value[DECLARED])?.wakeup);
372
- if (wakeups.length > 1) {
373
- for (const id of wakeups) {
370
+ // An instance registers with one wakeup, and opens its coding agents' boxes in one sandbox: several
371
+ // of either are each a problem, and left out, as is any that names one.
372
+ for (const contract of ["wakeup", "sandbox"] as const) {
373
+ const ids = [...declarations.keys()].filter((id) => types.get(declarations.get(id)!.value[DECLARED])?.[contract]);
374
+ if (ids.length < 2) continue;
375
+ for (const id of ids) {
374
376
  leaveOut(
375
377
  id,
376
- `an instance has one wakeup: leave out this one or ${wakeups.filter((other) => other !== id).join(", ")}`,
378
+ `an instance has one ${contract}: leave out this one or ${ids.filter((other) => other !== id).join(", ")}`,
377
379
  );
378
380
  }
379
381
  }