@sanoma-ai/workflows 0.11.17 → 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.
- package/README.md +13 -9
- package/dist/call.d.ts +35 -4
- package/dist/call.js +128 -40
- package/dist/call.js.map +1 -1
- package/dist/client.d.ts +17 -8
- package/dist/client.js +90 -51
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +25 -7
- package/dist/collection.js +236 -91
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +8 -5
- package/dist/config.js +10 -10
- package/dist/config.js.map +1 -1
- package/dist/db/company.d.ts +20 -0
- package/dist/db/company.js +9 -1
- package/dist/db/company.js.map +1 -1
- package/dist/describe.js +1 -1
- package/dist/describe.js.map +1 -1
- package/dist/description.js +1 -1
- package/dist/description.js.map +1 -1
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +3 -2
- package/dist/errors.js.map +1 -1
- package/dist/grid.js +3 -2
- package/dist/grid.js.map +1 -1
- package/dist/host-derive.d.ts +13 -8
- package/dist/host-derive.js +26 -13
- package/dist/host-derive.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +7 -0
- package/dist/ledger.js +32 -2
- package/dist/ledger.js.map +1 -1
- package/dist/local-sandbox.d.ts +8 -1
- package/dist/local-sandbox.js +3 -2
- package/dist/local-sandbox.js.map +1 -1
- package/dist/op.d.ts +3 -2
- package/dist/op.js.map +1 -1
- package/dist/resources.d.ts +1 -1
- package/dist/resources.js +9 -6
- package/dist/resources.js.map +1 -1
- package/dist/runner-box.d.ts +23 -0
- package/dist/runner-box.js +181 -0
- package/dist/runner-box.js.map +1 -0
- package/dist/runner-protocol.d.ts +73 -0
- package/dist/runner-protocol.js +34 -0
- package/dist/runner-protocol.js.map +1 -0
- package/dist/sandbox-contract.d.ts +14 -5
- package/dist/sandbox-contract.js.map +1 -1
- package/dist/sandbox-runner.cjs +7 -0
- package/dist/scenario.js +16 -7
- package/dist/scenario.js.map +1 -1
- package/dist/schema.js +6 -3
- package/dist/schema.js.map +1 -1
- package/dist/secret.d.ts +2 -1
- package/dist/secret.js +3 -2
- package/dist/secret.js.map +1 -1
- package/dist/shared.d.ts +39 -5
- package/dist/shared.js +13 -1
- package/dist/shared.js.map +1 -1
- package/dist/sites.js +10 -8
- package/dist/sites.js.map +1 -1
- package/dist/source.d.ts +21 -0
- package/dist/source.js.map +1 -1
- package/drizzle/company/0003_ledger_target.sql +2 -0
- package/drizzle/company/meta/0003_snapshot.json +845 -0
- package/drizzle/company/meta/_journal.json +7 -0
- package/package.json +5 -2
- package/src/call.ts +181 -60
- package/src/client.ts +118 -67
- package/src/collection.ts +265 -97
- package/src/config.ts +20 -20
- package/src/db/company.ts +9 -1
- package/src/describe.ts +1 -1
- package/src/description.ts +1 -1
- package/src/errors.ts +3 -2
- package/src/grid.ts +3 -1
- package/src/host-derive.ts +38 -18
- package/src/index.ts +2 -0
- package/src/ledger.ts +42 -2
- package/src/local-sandbox.ts +3 -2
- package/src/op.ts +3 -2
- package/src/resources.ts +8 -6
- package/src/runner-box.ts +218 -0
- package/src/runner-protocol.ts +61 -0
- package/src/sandbox-contract.ts +11 -4
- package/src/sandbox-runner.ts +187 -0
- package/src/scenario.ts +16 -6
- package/src/schema.ts +14 -3
- package/src/secret.ts +6 -2
- package/src/shared.ts +40 -4
- package/src/sites.ts +11 -7
- package/src/source.ts +14 -0
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
|
-
|
|
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));
|
package/src/host-derive.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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,
|
|
444
|
-
* read from this machine's environment alone (`unbound`)
|
|
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
|
-
/**
|
|
458
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
-
...(
|
|
514
|
-
...(
|
|
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
|
@@ -263,6 +263,8 @@ export {
|
|
|
263
263
|
type SandboxSpec,
|
|
264
264
|
} from "./sandbox-contract.ts";
|
|
265
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";
|
|
266
268
|
export type { Chat, Chats } from "./chats.ts";
|
|
267
269
|
export type { Tokens } from "./tokens.ts";
|
|
268
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
|
|
package/src/local-sandbox.ts
CHANGED
|
@@ -40,9 +40,10 @@ function boxDir(dir: string, name: string): string {
|
|
|
40
40
|
/**
|
|
41
41
|
* The box at `home`, over `node:child_process` and `node:fs`: a command is `bash -c` in a process
|
|
42
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.
|
|
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.
|
|
44
45
|
*/
|
|
45
|
-
function localBox(id: string, home: string, base: Record<string, string>): Box {
|
|
46
|
+
export function localBox(id: string, home: string, base: Record<string, string>): Box {
|
|
46
47
|
/** The process groups its commands lead, killed by `stop` whether or not their leader has exited. */
|
|
47
48
|
const groups = new Set<number>();
|
|
48
49
|
const at = (path: string) => resolve(home, path);
|
package/src/op.ts
CHANGED
|
@@ -365,8 +365,9 @@ export interface ConnectorSetup {
|
|
|
365
365
|
remote?: () => RemoteHalf;
|
|
366
366
|
/**
|
|
367
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,
|
|
369
|
-
* credentials through `context.secrets
|
|
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`.
|
|
370
371
|
*/
|
|
371
372
|
sandbox?: (context: SandboxContext) => SandboxBackend | Promise<SandboxBackend>;
|
|
372
373
|
}
|
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
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
378
|
+
`an instance has one ${contract}: leave out this one or ${ids.filter((other) => other !== id).join(", ")}`,
|
|
377
379
|
);
|
|
378
380
|
}
|
|
379
381
|
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import {
|
|
4
|
+
FILE,
|
|
5
|
+
frame,
|
|
6
|
+
RUNNER_HEALTH,
|
|
7
|
+
type RunnerAnswer,
|
|
8
|
+
type RunnerHealth,
|
|
9
|
+
type RunnerRequest,
|
|
10
|
+
STDERR,
|
|
11
|
+
STDIN,
|
|
12
|
+
unframe,
|
|
13
|
+
} from "./runner-protocol.ts";
|
|
14
|
+
import type { Box, Process } from "./sandbox-contract.ts";
|
|
15
|
+
|
|
16
|
+
// A box whose commands and files go through the runner (`sandbox-runner.ts`), a small server of
|
|
17
|
+
// ours a backend starts inside a remote box, over one WebSocket: so every vendor's backend is
|
|
18
|
+
// "open the box, start the runner, talk to it", whatever the vendor's own exec can or cannot
|
|
19
|
+
// stream. The protocol is in `runner-protocol.ts`. One connection carries many processes; a
|
|
20
|
+
// dropped one ends them here (exit 255) but kills nothing in the box.
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The runner as one file (`dist/sandbox-runner.cjs`, its dependencies bundled in), which a
|
|
24
|
+
* backend writes into a box and starts with the box's `node` (22 or later).
|
|
25
|
+
*/
|
|
26
|
+
export async function runnerScript(): Promise<Uint8Array> {
|
|
27
|
+
// `../dist/` from `src/` (the tests) and from `dist/` alike.
|
|
28
|
+
const at = new URL("../dist/sandbox-runner.cjs", import.meta.url);
|
|
29
|
+
return readFile(at).catch(() => {
|
|
30
|
+
throw new Error(`${fileURLToPath(at)} is missing: build @sanoma-ai/workflows`);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What the runner at `url` (asked with `headers`) answers at `RUNNER_HEALTH`, or undefined when nothing
|
|
36
|
+
* answers as a runner: a backend starts one then, and replaces one of another `protocol`.
|
|
37
|
+
*/
|
|
38
|
+
export const runnerHealth = (url: string, headers?: Record<string, string>): Promise<RunnerHealth | undefined> =>
|
|
39
|
+
fetch(`${url}${RUNNER_HEALTH}`, { headers, signal: AbortSignal.timeout(5_000) })
|
|
40
|
+
.then(async (res) => {
|
|
41
|
+
const health = res.ok ? ((await res.json()) as Partial<RunnerHealth> | null) : null;
|
|
42
|
+
return typeof health?.protocol === "number" && typeof health.pid === "number"
|
|
43
|
+
? { protocol: health.protocol, pid: health.pid }
|
|
44
|
+
: undefined;
|
|
45
|
+
})
|
|
46
|
+
.catch(() => undefined);
|
|
47
|
+
|
|
48
|
+
const encoder = new TextEncoder();
|
|
49
|
+
|
|
50
|
+
/** Drops a write or close the reader no longer wants (it cancelled). */
|
|
51
|
+
const ignore = () => {};
|
|
52
|
+
|
|
53
|
+
/** What an id of the connection waits for: a process's output and exit, or a request's answer. */
|
|
54
|
+
interface Waiter {
|
|
55
|
+
bytes(stream: number, bytes: Uint8Array): void;
|
|
56
|
+
answer(answer: RunnerAnswer): void;
|
|
57
|
+
/** The connection dropped. */
|
|
58
|
+
lost(): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** An open connection to the runner, and what its ids wait for. */
|
|
62
|
+
interface Connection {
|
|
63
|
+
ws: WebSocket;
|
|
64
|
+
waiting: Map<number, Waiter>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** How long the runner's WebSocket may take to open, in ms. */
|
|
68
|
+
const OPEN_MS = 30_000;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The box `id` whose runner is at `url` (`http(s)://`, the WebSocket's upgrade sent with
|
|
72
|
+
* `headers`): its commands, files and `stop` (which kills every command in it and closes the
|
|
73
|
+
* connection). Connects at the first call, and again at the next call after a drop or a `stop`.
|
|
74
|
+
* How its ports are reached is the vendor's, so the backend adds `expose`.
|
|
75
|
+
*/
|
|
76
|
+
export function runnerBox({
|
|
77
|
+
url,
|
|
78
|
+
headers,
|
|
79
|
+
id,
|
|
80
|
+
}: {
|
|
81
|
+
url: string;
|
|
82
|
+
headers?: Record<string, string>;
|
|
83
|
+
id: string;
|
|
84
|
+
}): Omit<Box, "expose"> {
|
|
85
|
+
const where = `the runner at ${new URL(url).host}`;
|
|
86
|
+
let current: Promise<Connection> | undefined;
|
|
87
|
+
let last = 0;
|
|
88
|
+
|
|
89
|
+
const connect = () => (current ??= open());
|
|
90
|
+
function open(): Promise<Connection> {
|
|
91
|
+
const opening = new Promise<Connection>((resolve, reject) => {
|
|
92
|
+
// Node's WebSocket (undici) takes `headers` beside the standard's protocols.
|
|
93
|
+
const ws = new WebSocket(url.replace(/^http/, "ws"), { headers } as unknown as string[]);
|
|
94
|
+
ws.binaryType = "arraybuffer";
|
|
95
|
+
const connection: Connection = { ws, waiting: new Map() };
|
|
96
|
+
const late = setTimeout(() => ws.close(), OPEN_MS);
|
|
97
|
+
ws.addEventListener("open", () => {
|
|
98
|
+
clearTimeout(late);
|
|
99
|
+
resolve(connection);
|
|
100
|
+
});
|
|
101
|
+
ws.addEventListener("message", ({ data }: { data: string | ArrayBuffer }) => {
|
|
102
|
+
try {
|
|
103
|
+
if (typeof data !== "string") {
|
|
104
|
+
const { id: of, stream, bytes } = unframe(new Uint8Array(data));
|
|
105
|
+
return connection.waiting.get(of)?.bytes(stream, bytes);
|
|
106
|
+
}
|
|
107
|
+
const answer = JSON.parse(data) as RunnerAnswer;
|
|
108
|
+
if (answer.id !== undefined) connection.waiting.get(answer.id)?.answer(answer);
|
|
109
|
+
} catch {
|
|
110
|
+
// A frame that is not the protocol's (another runner's) is dropped.
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
ws.addEventListener("close", ({ code, reason }) => {
|
|
114
|
+
clearTimeout(late);
|
|
115
|
+
if (current === opening) current = undefined;
|
|
116
|
+
reject(new Error(`${where} closed the connection (${reason || code})`));
|
|
117
|
+
for (const waiter of connection.waiting.values()) waiter.lost();
|
|
118
|
+
connection.waiting.clear();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
return opening;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Sends `request` (and `bytes` after it): resolves with the file it answers, null for none, or nothing for `ok`. */
|
|
125
|
+
async function ask(
|
|
126
|
+
request: { op: "read"; path: string } | { op: "write"; path: string; mode?: number } | { op: "stop" },
|
|
127
|
+
bytes?: Uint8Array,
|
|
128
|
+
): Promise<Uint8Array | null | undefined> {
|
|
129
|
+
const { ws, waiting } = await connect();
|
|
130
|
+
const n = ++last;
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const settle = (done: () => void) => {
|
|
133
|
+
waiting.delete(n);
|
|
134
|
+
done();
|
|
135
|
+
};
|
|
136
|
+
waiting.set(n, {
|
|
137
|
+
bytes: (_, file) => settle(() => resolve(file)),
|
|
138
|
+
answer: (answer) =>
|
|
139
|
+
settle(() =>
|
|
140
|
+
answer.op === "error"
|
|
141
|
+
? reject(new Error(`${where}: ${answer.message}`))
|
|
142
|
+
: resolve(answer.op === "absent" ? null : undefined),
|
|
143
|
+
),
|
|
144
|
+
lost: () => reject(new Error(`${where} closed the connection`)),
|
|
145
|
+
});
|
|
146
|
+
ws.send(JSON.stringify({ ...request, id: n }));
|
|
147
|
+
if (bytes) ws.send(frame(n, FILE, bytes));
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
id,
|
|
153
|
+
async run(command, { cwd, env, stdin = false, signal } = {}): Promise<Process> {
|
|
154
|
+
const { ws, waiting } = await connect();
|
|
155
|
+
const n = ++last;
|
|
156
|
+
const stdout = new TransformStream<Uint8Array, Uint8Array>();
|
|
157
|
+
const stderr = new TransformStream<Uint8Array, Uint8Array>();
|
|
158
|
+
const out = stdout.writable.getWriter();
|
|
159
|
+
const err = stderr.writable.getWriter();
|
|
160
|
+
const exited = new Promise<{ exitCode: number }>((done) => {
|
|
161
|
+
const end = (exitCode: number) => {
|
|
162
|
+
waiting.delete(n);
|
|
163
|
+
out.close().catch(ignore);
|
|
164
|
+
err.close().catch(ignore);
|
|
165
|
+
done({ exitCode });
|
|
166
|
+
};
|
|
167
|
+
waiting.set(n, {
|
|
168
|
+
bytes: (stream, bytes) => void (stream === STDERR ? err : out).write(bytes).catch(ignore),
|
|
169
|
+
answer(answer) {
|
|
170
|
+
if (answer.op === "exit") return end(answer.code);
|
|
171
|
+
if (answer.op !== "error") return;
|
|
172
|
+
err.write(encoder.encode(`${where}: ${answer.message}\n`)).catch(ignore);
|
|
173
|
+
end(127);
|
|
174
|
+
},
|
|
175
|
+
lost: () => end(255),
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
ws.send(JSON.stringify({ op: "run", id: n, command, cwd, env, stdin } satisfies RunnerRequest));
|
|
179
|
+
const kill = (sig: NodeJS.Signals = "SIGKILL") => {
|
|
180
|
+
if (waiting.has(n)) ws.send(JSON.stringify({ op: "kill", id: n, signal: sig } satisfies RunnerRequest));
|
|
181
|
+
};
|
|
182
|
+
const onAbort = () => kill();
|
|
183
|
+
if (signal?.aborted) onAbort();
|
|
184
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
185
|
+
void exited.then(() => signal?.removeEventListener("abort", onAbort));
|
|
186
|
+
const endInput = () => void ws.send(JSON.stringify({ op: "stdin-end", id: n } satisfies RunnerRequest));
|
|
187
|
+
return {
|
|
188
|
+
stdin: new WritableStream<Uint8Array>({
|
|
189
|
+
write(bytes) {
|
|
190
|
+
if (!stdin) throw new Error("its stdin is closed: run it with stdin: true");
|
|
191
|
+
ws.send(frame(n, STDIN, bytes));
|
|
192
|
+
},
|
|
193
|
+
close: endInput,
|
|
194
|
+
abort: endInput,
|
|
195
|
+
}),
|
|
196
|
+
stdout: stdout.readable,
|
|
197
|
+
stderr: stderr.readable,
|
|
198
|
+
wait: () => exited,
|
|
199
|
+
kill,
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
read: async (path) => (await ask({ op: "read", path })) ?? null,
|
|
203
|
+
async write(path, bytes, { mode } = {}) {
|
|
204
|
+
await ask({ op: "write", path, mode }, bytes);
|
|
205
|
+
},
|
|
206
|
+
async stop() {
|
|
207
|
+
// This connection, not one a call opens meanwhile after a drop.
|
|
208
|
+
const closing = connect();
|
|
209
|
+
try {
|
|
210
|
+
await ask({ op: "stop" });
|
|
211
|
+
} finally {
|
|
212
|
+
// The next call opens a new connection, not this closing one.
|
|
213
|
+
if (current === closing) current = undefined;
|
|
214
|
+
(await closing.catch(() => undefined))?.ws.close();
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The runner's protocol, over one WebSocket per box, shared by the runner (`sandbox-runner.ts`)
|
|
2
|
+
// and its client (`runner-box.ts`): text frames for control, as JSON, each carrying the `id` of
|
|
3
|
+
// the process or the request it is about; binary frames for bytes, `frame(id, stream, bytes)`.
|
|
4
|
+
|
|
5
|
+
/** The port the runner listens on in a box. */
|
|
6
|
+
export const RUNNER_PORT = 7700;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where the runner answers its health: not `/health`, which Agent37's edge answers itself on
|
|
10
|
+
* every port, so that path never reaches what listens there.
|
|
11
|
+
*/
|
|
12
|
+
export const RUNNER_HEALTH = "/runner/health";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The protocol's version, which the runner's health answers: a backend replaces a runner
|
|
16
|
+
* that answers another, one an earlier release started in a box that slept.
|
|
17
|
+
*/
|
|
18
|
+
export const RUNNER_PROTOCOL = 2;
|
|
19
|
+
|
|
20
|
+
/** What the runner's health (`RUNNER_HEALTH`) answers: its protocol, and its pid, which a backend replacing it ends. */
|
|
21
|
+
export interface RunnerHealth {
|
|
22
|
+
protocol: number;
|
|
23
|
+
pid: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What a binary frame's bytes are: a process's stdin, stdout or stderr, or a file read or written. */
|
|
27
|
+
export const STDIN = 0;
|
|
28
|
+
export const STDOUT = 1;
|
|
29
|
+
export const STDERR = 2;
|
|
30
|
+
export const FILE = 3;
|
|
31
|
+
|
|
32
|
+
/** What the client says, as text. A `write`'s bytes follow it as the frame `(id, FILE)`. */
|
|
33
|
+
export type RunnerRequest =
|
|
34
|
+
| { op: "run"; id: number; command: string; cwd?: string; env?: Record<string, string>; stdin: boolean }
|
|
35
|
+
| { op: "stdin-end"; id: number }
|
|
36
|
+
| { op: "kill"; id: number; signal: NodeJS.Signals }
|
|
37
|
+
| { op: "read"; id: number; path: string }
|
|
38
|
+
| { op: "write"; id: number; path: string; mode?: number }
|
|
39
|
+
| { op: "stop"; id: number };
|
|
40
|
+
|
|
41
|
+
/** What the runner answers, as text. A `read` that finds a file answers the frame `(id, FILE)`. */
|
|
42
|
+
export type RunnerAnswer =
|
|
43
|
+
| { op: "exit"; id: number; code: number }
|
|
44
|
+
| { op: "absent"; id: number }
|
|
45
|
+
| { op: "ok"; id: number }
|
|
46
|
+
| { op: "error"; id?: number; message: string };
|
|
47
|
+
|
|
48
|
+
/** A binary frame: `id` (4 bytes), `stream` (1 byte), then `bytes`. */
|
|
49
|
+
export function frame(id: number, stream: number, bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
50
|
+
const framed = new Uint8Array(5 + bytes.length);
|
|
51
|
+
new DataView(framed.buffer).setUint32(0, id);
|
|
52
|
+
framed[4] = stream;
|
|
53
|
+
framed.set(bytes, 5);
|
|
54
|
+
return framed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const unframe = (framed: Uint8Array) => ({
|
|
58
|
+
id: new DataView(framed.buffer, framed.byteOffset, framed.byteLength).getUint32(0),
|
|
59
|
+
stream: framed[4]!,
|
|
60
|
+
bytes: framed.subarray(5),
|
|
61
|
+
});
|
package/src/sandbox-contract.ts
CHANGED
|
@@ -80,14 +80,21 @@ export interface Box {
|
|
|
80
80
|
* vendor keeps its state, so no snapshot handle crosses the contract.
|
|
81
81
|
*/
|
|
82
82
|
export interface SandboxBackend {
|
|
83
|
-
/**
|
|
84
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The box `name`, made from `image` with `size` vCPUs when there is none (left out, the backend's
|
|
85
|
+
* own), woken or started when stopped.
|
|
86
|
+
*/
|
|
87
|
+
open(opts: { name: string; image?: string; size?: number }): Promise<Box>;
|
|
85
88
|
/** Deletes the box `name` and whatever the vendor kept of it: the next `open` finds it empty. */
|
|
86
89
|
discard(opts: { name: string }): Promise<void>;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
/**
|
|
90
|
-
|
|
92
|
+
/**
|
|
93
|
+
* What the app gives a connector's sandbox half (`ConnectorSetup.sandbox`): the secrets, as a model
|
|
94
|
+
* half reads them, and the declared fields of the resource whose boxes it opens, which hold the
|
|
95
|
+
* vendor's facts (its region, its type of machine) beside the image and size `open` takes.
|
|
96
|
+
*/
|
|
97
|
+
export type SandboxContext = Pick<LoginContext, "secrets"> & { declared: Record<string, unknown> };
|
|
91
98
|
|
|
92
99
|
/**
|
|
93
100
|
* Throws what is wrong with a resource type's `sandbox`: a type apply cannot change (not
|