@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.
- package/README.md +16 -8
- 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 +24 -1
- package/dist/config.js +48 -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.d.ts +6 -3
- package/dist/description.js +5 -4
- 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 +7 -3
- package/dist/index.js +7 -3
- 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 +17 -0
- package/dist/local-sandbox.js +131 -0
- package/dist/local-sandbox.js.map +1 -0
- package/dist/op.d.ts +10 -2
- package/dist/op.js.map +1 -1
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +20 -4
- package/dist/paths.js.map +1 -1
- package/dist/resource.d.ts +7 -1
- package/dist/resource.js +5 -1
- package/dist/resource.js.map +1 -1
- package/dist/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 +109 -0
- package/dist/sandbox-contract.js +26 -0
- package/dist/sandbox-contract.js.map +1 -0
- package/dist/sandbox-runner.cjs +7 -0
- package/dist/scenario.js +16 -7
- package/dist/scenario.js.map +1 -1
- package/dist/schedules.d.ts +11 -3
- package/dist/schedules.js +15 -3
- package/dist/schedules.js.map +1 -1
- package/dist/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 +65 -16
- package/src/db/company.ts +9 -1
- package/src/describe.ts +1 -1
- package/src/description.ts +5 -4
- package/src/errors.ts +3 -2
- package/src/grid.ts +3 -1
- package/src/host-derive.ts +38 -18
- package/src/index.ts +15 -2
- package/src/ledger.ts +42 -2
- package/src/local-sandbox.ts +133 -0
- package/src/op.ts +10 -2
- package/src/paths.ts +22 -4
- package/src/resource.ts +16 -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 +121 -0
- package/src/sandbox-runner.ts +187 -0
- package/src/scenario.ts +16 -6
- package/src/schedules.ts +19 -4
- 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/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));
|
package/src/description.ts
CHANGED
|
@@ -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 `
|
|
302
|
-
* `Database`, `Wakeup`), and `source` for a type a host's `source` names (the
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
@@ -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,
|
|
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
|
|
7
|
-
// the oxlint plugin loads it.
|
|
7
|
+
// the directories and the one walk down (`filesUnder`). Node's `path` and `fs` only (and a
|
|
8
|
+
// bundle's registered roots, plain data), no parser: the oxlint plugin loads it.
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Where a data file imports `secret` from, and `sanoma`, the runtime's own resource constructors
|
|
@@ -70,8 +71,8 @@ export { APPS_DIR, appOfPath };
|
|
|
70
71
|
/** Each directory's company root (the nearest one with a `sanoma.config.ts`, short of a `node_modules`), once found. */
|
|
71
72
|
const roots = new Map<string, string>();
|
|
72
73
|
|
|
73
|
-
/** Whether `dir` is a company's root: it holds a `sanoma.config.ts
|
|
74
|
-
const isCompany = (dir: string) => existsSync(join(dir, "sanoma.config.ts"));
|
|
74
|
+
/** Whether `dir` is a company's root: it holds a `sanoma.config.ts`, or a bundle registered its snapshot (`useRootSnapshot`). */
|
|
75
|
+
const isCompany = (dir: string) => rootSnapshot(dir) !== undefined || existsSync(join(dir, "sanoma.config.ts"));
|
|
75
76
|
|
|
76
77
|
/**
|
|
77
78
|
* The app a file is in: `<app>` for a file under `<root>/apps/<app>/`, where the root is the
|
|
@@ -113,5 +114,22 @@ export const kindDirs = (root: string, kind: string): string[] => [
|
|
|
113
114
|
...appFolders(root).map((app) => join(app, kind)),
|
|
114
115
|
];
|
|
115
116
|
|
|
117
|
+
/**
|
|
118
|
+
* The files whose declarations join the config's, as `sanoma` loads it and the Worker's bundle
|
|
119
|
+
* imports them: each workflow source under `workflows/` and each app's (a test or a declaration
|
|
120
|
+
* file aside), then each `resources/data.ts` there is.
|
|
121
|
+
*/
|
|
122
|
+
export function declarationFiles(root: string): { workflows: string[]; data: string[] } {
|
|
123
|
+
return {
|
|
124
|
+
workflows: kindDirs(root, "workflows").flatMap((dir) => filesUnder(dir, isWorkflowSource)),
|
|
125
|
+
data: kindDirs(root, "resources")
|
|
126
|
+
.map((dir) => join(dir, COLLECTIONS_FILE))
|
|
127
|
+
.filter((path) => existsSync(path)),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A workflow file's name: `.ts`, but a test (`.test.ts`, `.spec.ts`) or a declaration. */
|
|
132
|
+
const isWorkflowSource = (name: string) => name.endsWith(".ts") && !/\.(test|spec|d)\.ts$/.test(name);
|
|
133
|
+
|
|
116
134
|
/** Where a company's scenarios are, under its root and each app's folder. */
|
|
117
135
|
export const SCENARIOS_DIR = "scenarios";
|
package/src/resource.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { checkDatabase, type DatabaseSpec } from "./database.ts";
|
|
|
4
4
|
import { isRunControlError } from "./errors.ts";
|
|
5
5
|
import { checkHost, hasPath, type HostSpec } from "./host.ts";
|
|
6
6
|
import { type Connector, type OpSpec, RESOURCE, VENDOR } from "./op.ts";
|
|
7
|
+
import { checkSandbox, type SandboxSpec } from "./sandbox-contract.ts";
|
|
7
8
|
import { isSanomaSecret, isSecret, type Secret } from "./secret.ts";
|
|
8
9
|
import { errorMessage } from "./shared.ts";
|
|
9
10
|
import { checkWakeup, type WakeupSpec } from "./wakeup.ts";
|
|
@@ -114,6 +115,11 @@ export interface ResourceSpec<S extends z.ZodObject = z.ZodObject> {
|
|
|
114
115
|
* is `writable`, not `stateless`, and its `afterCreate` calls the operation `storeUrl` names.
|
|
115
116
|
*/
|
|
116
117
|
database?: DatabaseSpec;
|
|
118
|
+
/**
|
|
119
|
+
* Set when the type meets the `Sandbox` contract: it runs a coding agent's boxes, which its
|
|
120
|
+
* connector's backend (`ConnectorSetup.sandbox`) opens. Such a type is `writable`.
|
|
121
|
+
*/
|
|
122
|
+
sandbox?: SandboxSpec;
|
|
117
123
|
/**
|
|
118
124
|
* True when its objects hold no data, so one gone at the vendor is created again by apply
|
|
119
125
|
* without asking (a host running the company's code); otherwise apply asks first, as a
|
|
@@ -366,6 +372,7 @@ export interface Resource<
|
|
|
366
372
|
| "host"
|
|
367
373
|
| "wakeup"
|
|
368
374
|
| "database"
|
|
375
|
+
| "sandbox"
|
|
369
376
|
| "stateless"
|
|
370
377
|
| "afterCreate"
|
|
371
378
|
| "vendorless"
|
|
@@ -373,7 +380,12 @@ export interface Resource<
|
|
|
373
380
|
>
|
|
374
381
|
>
|
|
375
382
|
>,
|
|
376
|
-
Readonly<
|
|
383
|
+
Readonly<
|
|
384
|
+
Pick<
|
|
385
|
+
ResourceSpec<S>,
|
|
386
|
+
"identity" | "find" | "host" | "wakeup" | "database" | "sandbox" | "afterCreate" | "formerly"
|
|
387
|
+
>
|
|
388
|
+
> {
|
|
377
389
|
(
|
|
378
390
|
desired: Declaring<z.input<S>, R>,
|
|
379
391
|
options?: { connection?: ConnectionRef<Id extends `${infer V}.${string}` ? V : string> },
|
|
@@ -710,12 +722,13 @@ export function defineResource<
|
|
|
710
722
|
>(
|
|
711
723
|
spec: ResourceSpec<S> & { vendor: V; type: T; fields: { references?: R }; writable?: W },
|
|
712
724
|
): Resource<S, `${V}.${T}`, R, W> {
|
|
713
|
-
const { vendor, type, schema, fields, find, identity, host, wakeup, database } = spec;
|
|
725
|
+
const { vendor, type, schema, fields, find, identity, host, wakeup, database, sandbox } = spec;
|
|
714
726
|
const writable = (spec.writable ?? false) as W;
|
|
715
727
|
const what: `${V}.${T}` = `${vendor}.${type}`;
|
|
716
728
|
if (host) checkHost(what, host, spec);
|
|
717
729
|
if (wakeup) checkWakeup(what, wakeup, spec);
|
|
718
730
|
if (database) checkDatabase(what, database, spec);
|
|
731
|
+
if (sandbox) checkSandbox(what, sandbox, spec);
|
|
719
732
|
if (spec.vendorless && (writable || host))
|
|
720
733
|
throw new Error(`${what}: a vendorless type has no object to change or host`);
|
|
721
734
|
const title = spec.title.toLowerCase();
|
|
@@ -908,6 +921,7 @@ export function defineResource<
|
|
|
908
921
|
...(host && { host }),
|
|
909
922
|
...(wakeup && { wakeup }),
|
|
910
923
|
...(database && { database }),
|
|
924
|
+
...(sandbox && { sandbox }),
|
|
911
925
|
stateless: spec.stateless ?? host !== undefined,
|
|
912
926
|
vendorless: spec.vendorless ?? false,
|
|
913
927
|
...(spec.afterCreate && { afterCreate: spec.afterCreate }),
|
package/src/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
|
}
|