@the-open-engine/zeroshot 6.5.0 → 6.7.0
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/bin/zeroshot-cluster-worker.js +16 -0
- package/cli/index.js +135 -12
- package/docs/openengine-cluster-protocol/v1/legacy-worker.md +117 -0
- package/lib/cluster-worker/contracts.js +194 -0
- package/lib/cluster-worker/engine-adapter.js +300 -0
- package/lib/cluster-worker/executable.js +229 -0
- package/lib/cluster-worker/index.d.ts +227 -0
- package/lib/cluster-worker/index.js +294 -0
- package/lib/cluster-worker/object-utils.js +17 -0
- package/lib/cluster-worker/process-stdio.js +37 -0
- package/lib/cluster-worker/profiles.js +81 -0
- package/lib/cluster-worker/runtime-engine.js +50 -0
- package/lib/cluster-worker/runtime-support.js +298 -0
- package/lib/cluster-worker/state-machine.js +147 -0
- package/lib/cluster-worker/terminal-normalizer.js +95 -0
- package/lib/cluster-worker/worker-internals.js +20 -0
- package/lib/detached-startup.js +127 -11
- package/lib/process-liveness.js +26 -0
- package/lib/start-cluster.js +93 -18
- package/package.json +10 -2
- package/protocol/openengine-cluster/v1/worker.schema.json +1174 -0
- package/scripts/assert-release-published.js +107 -0
- package/scripts/release-preflight.js +236 -0
- package/src/orchestrator.js +464 -61
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
bindProcessLifecycle,
|
|
6
|
+
redirectConsoleToStderr,
|
|
7
|
+
} = require('../lib/cluster-worker/process-stdio');
|
|
8
|
+
|
|
9
|
+
// stdout is the worker protocol transport. Route ambient engine logging away
|
|
10
|
+
// from it before constructing the production adapter.
|
|
11
|
+
redirectConsoleToStderr();
|
|
12
|
+
|
|
13
|
+
const { runClusterWorkerExecutable } = require('../lib/cluster-worker/executable');
|
|
14
|
+
|
|
15
|
+
const runtime = runClusterWorkerExecutable();
|
|
16
|
+
bindProcessLifecycle(runtime);
|
package/cli/index.js
CHANGED
|
@@ -73,9 +73,13 @@ const { runInspectCommand } = require('./commands/inspect');
|
|
|
73
73
|
const { runCmdproof } = require('./commands/cmdproof');
|
|
74
74
|
const {
|
|
75
75
|
markDetachedSetupFailed,
|
|
76
|
+
patchDetachedResumeCluster,
|
|
76
77
|
registerDetachedSetupCluster,
|
|
77
78
|
removeDetachedSetupCluster,
|
|
79
|
+
revertDetachedResumeCluster,
|
|
80
|
+
waitForResumeOwnership,
|
|
78
81
|
} = require('../lib/detached-startup');
|
|
82
|
+
const { isProcessRunning: isClusterProcessAlive } = require('../lib/process-liveness');
|
|
79
83
|
// Setup wizard removed - use: zeroshot settings set <key> <value>
|
|
80
84
|
const { checkForUpdates, printLegacyDistroNotice } = require('./lib/update-checker');
|
|
81
85
|
const { checkBinDirOnPath, printPathWarning } = require('../lib/path-check');
|
|
@@ -245,12 +249,12 @@ function printDetachedClusterStart(options, clusterId, logPath) {
|
|
|
245
249
|
console.log(`Attach: zeroshot attach ${clusterId}`);
|
|
246
250
|
}
|
|
247
251
|
|
|
248
|
-
function createDaemonLogFile(
|
|
252
|
+
function createDaemonLogFile(logName) {
|
|
249
253
|
const storageDir = path.join(os.homedir(), '.zeroshot');
|
|
250
254
|
if (!fs.existsSync(storageDir)) {
|
|
251
255
|
fs.mkdirSync(storageDir, { recursive: true });
|
|
252
256
|
}
|
|
253
|
-
const logPath = path.join(storageDir, `${
|
|
257
|
+
const logPath = path.join(storageDir, `${logName}-daemon.log`);
|
|
254
258
|
return fs.openSync(logPath, 'w');
|
|
255
259
|
}
|
|
256
260
|
|
|
@@ -291,17 +295,32 @@ function buildDaemonEnv(options, clusterId, targetCwd, stdinText) {
|
|
|
291
295
|
};
|
|
292
296
|
}
|
|
293
297
|
|
|
294
|
-
|
|
298
|
+
// Generic detached-child primitive shared by `run --detach` and
|
|
299
|
+
// `resume --detach`: spawn a re-invocation of this same CLI (same argv) with
|
|
300
|
+
// daemon env vars set, detach it from this process, and return its PID.
|
|
301
|
+
// Callers own registering/patching cluster state around the spawn - this
|
|
302
|
+
// function knows nothing about run vs. resume.
|
|
303
|
+
function spawnDetachedChild(env, cwd, logFd) {
|
|
295
304
|
const { spawn } = require('child_process');
|
|
296
|
-
const logFd = createDaemonLogFile(clusterId);
|
|
297
|
-
const targetCwd = detectGitRepoRoot();
|
|
298
|
-
const logPath = path.join(os.homedir(), '.zeroshot', `${clusterId}-daemon.log`);
|
|
299
305
|
const daemon = spawn(process.execPath, process.argv.slice(1), {
|
|
300
306
|
detached: true,
|
|
301
307
|
stdio: ['ignore', logFd, logFd],
|
|
302
|
-
cwd
|
|
303
|
-
env
|
|
308
|
+
cwd,
|
|
309
|
+
env,
|
|
304
310
|
});
|
|
311
|
+
daemon.unref();
|
|
312
|
+
return daemon;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function spawnDetachedCluster(options, clusterId, stdinText) {
|
|
316
|
+
const logFd = createDaemonLogFile(clusterId);
|
|
317
|
+
const targetCwd = detectGitRepoRoot();
|
|
318
|
+
const logPath = path.join(os.homedir(), '.zeroshot', `${clusterId}-daemon.log`);
|
|
319
|
+
const daemon = spawnDetachedChild(
|
|
320
|
+
buildDaemonEnv(options, clusterId, targetCwd, stdinText),
|
|
321
|
+
targetCwd,
|
|
322
|
+
logFd
|
|
323
|
+
);
|
|
305
324
|
await registerDetachedSetupCluster({
|
|
306
325
|
clusterId,
|
|
307
326
|
pid: daemon.pid,
|
|
@@ -310,11 +329,53 @@ async function spawnDetachedCluster(options, clusterId, stdinText) {
|
|
|
310
329
|
runOptions: options,
|
|
311
330
|
cwd: targetCwd,
|
|
312
331
|
});
|
|
313
|
-
daemon.unref();
|
|
314
332
|
fs.closeSync(logFd);
|
|
315
333
|
printDetachedClusterStart(options, clusterId, logPath);
|
|
316
334
|
}
|
|
317
335
|
|
|
336
|
+
// Resume's daemon env is deliberately NOT buildDaemonEnv: run-only keys like
|
|
337
|
+
// ZEROSHOT_DOCKER/ZEROSHOT_WORKTREE/ZEROSHOT_PR are derived by resume from
|
|
338
|
+
// the cluster's own persisted state, and spreading them here would fight
|
|
339
|
+
// that. The resumed process needs only to know it's the daemon and which
|
|
340
|
+
// cluster it owns; `id`/`prompt` already travel as plain argv (commander
|
|
341
|
+
// re-parses the same argv in the child), not through env.
|
|
342
|
+
function buildResumeDaemonEnv(clusterId, targetCwd) {
|
|
343
|
+
return {
|
|
344
|
+
...process.env,
|
|
345
|
+
ZEROSHOT_DAEMON: '1',
|
|
346
|
+
ZEROSHOT_CLUSTER_ID: clusterId,
|
|
347
|
+
ZEROSHOT_CWD: targetCwd,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Parent-side half of `resume --detach`: spawn the daemon, then atomically
|
|
352
|
+
// hand it ownership of the cluster record (patchDetachedResumeCluster).
|
|
353
|
+
// If another live daemon already owns this cluster (a concurrent resume
|
|
354
|
+
// won the race), abort and kill the child we just spawned rather than
|
|
355
|
+
// leaving a duplicate process running.
|
|
356
|
+
async function spawnDetachedResume(clusterId) {
|
|
357
|
+
const logFd = createDaemonLogFile(`${clusterId}-resume`);
|
|
358
|
+
const targetCwd = detectGitRepoRoot();
|
|
359
|
+
const logPath = path.join(os.homedir(), '.zeroshot', `${clusterId}-resume-daemon.log`);
|
|
360
|
+
const storageDir = path.join(os.homedir(), '.zeroshot');
|
|
361
|
+
const daemon = spawnDetachedChild(buildResumeDaemonEnv(clusterId, targetCwd), targetCwd, logFd);
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
await patchDetachedResumeCluster({ clusterId, daemonPid: daemon.pid, storageDir });
|
|
365
|
+
} catch (patchError) {
|
|
366
|
+
try {
|
|
367
|
+
process.kill(daemon.pid, 'SIGTERM');
|
|
368
|
+
} catch {
|
|
369
|
+
// Already gone - nothing to clean up.
|
|
370
|
+
}
|
|
371
|
+
fs.closeSync(logFd);
|
|
372
|
+
throw patchError;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
fs.closeSync(logFd);
|
|
376
|
+
return { pid: daemon.pid, logPath };
|
|
377
|
+
}
|
|
378
|
+
|
|
318
379
|
function resolveClusterId(generateName) {
|
|
319
380
|
const clusterId = process.env.ZEROSHOT_CLUSTER_ID || generateName('cluster');
|
|
320
381
|
process.env.ZEROSHOT_CLUSTER_ID = clusterId;
|
|
@@ -3214,9 +3275,70 @@ program
|
|
|
3214
3275
|
provider: providerName,
|
|
3215
3276
|
});
|
|
3216
3277
|
|
|
3278
|
+
// === DETACH HANDOFF: hand the resume off to a daemon and return ===
|
|
3279
|
+
// This is the parent's half only - it never calls orchestrator.resume()
|
|
3280
|
+
// itself. Fail fast here (before spawning anything) on the same
|
|
3281
|
+
// liveness check orchestrator.resume() would otherwise apply, so a
|
|
3282
|
+
// cluster that's genuinely still running doesn't waste a daemon spawn.
|
|
3283
|
+
if (shouldRunDetached(options)) {
|
|
3284
|
+
if (cluster.state === 'running' && isClusterProcessAlive(cluster.pid)) {
|
|
3285
|
+
throw new Error(
|
|
3286
|
+
`Cluster ${id} is still running. Use 'zeroshot stop' first if you want to restart it.`
|
|
3287
|
+
);
|
|
3288
|
+
}
|
|
3289
|
+
orchestrator.close();
|
|
3290
|
+
const { pid, logPath } = await spawnDetachedResume(id);
|
|
3291
|
+
console.log(chalk.green(`✓ Resume daemon started for ${id}`));
|
|
3292
|
+
console.log(` Daemon PID: ${pid}`);
|
|
3293
|
+
console.log(` Daemon log: ${logPath}`);
|
|
3294
|
+
console.log('');
|
|
3295
|
+
console.log(chalk.dim(`Monitor: zeroshot logs ${id} -f`));
|
|
3296
|
+
console.log(chalk.dim(`Status: zeroshot status ${id}`));
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
// === DAEMON SIDE: this process IS the spawned resume daemon ===
|
|
3301
|
+
// Wait for the parent's atomic pid/state patch to name *this* PID as
|
|
3302
|
+
// owner before touching the cluster - closes the gap between spawn()
|
|
3303
|
+
// returning a PID and that patch landing, so a daemon that lost a
|
|
3304
|
+
// concurrent resume --detach race never gets to call resume() at all.
|
|
3305
|
+
const isResumeDaemon =
|
|
3306
|
+
Boolean(options.detach) &&
|
|
3307
|
+
process.env.ZEROSHOT_DAEMON === '1' &&
|
|
3308
|
+
process.env.ZEROSHOT_CLUSTER_ID === id;
|
|
3309
|
+
|
|
3310
|
+
if (isResumeDaemon) {
|
|
3311
|
+
const storageDir = path.join(os.homedir(), '.zeroshot');
|
|
3312
|
+
const owned = await waitForResumeOwnership({
|
|
3313
|
+
clusterId: id,
|
|
3314
|
+
daemonPid: process.pid,
|
|
3315
|
+
storageDir,
|
|
3316
|
+
});
|
|
3317
|
+
if (!owned) {
|
|
3318
|
+
console.error(
|
|
3319
|
+
chalk.red(
|
|
3320
|
+
`Resume daemon for ${id} never received ownership handoff from parent process; aborting.`
|
|
3321
|
+
)
|
|
3322
|
+
);
|
|
3323
|
+
process.exit(1);
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3217
3327
|
// Resume cluster
|
|
3218
3328
|
console.log(chalk.cyan(`Resuming cluster ${id}...`));
|
|
3219
|
-
|
|
3329
|
+
let result;
|
|
3330
|
+
try {
|
|
3331
|
+
result = await orchestrator.resume(id, prompt);
|
|
3332
|
+
} catch (resumeError) {
|
|
3333
|
+
if (isResumeDaemon) {
|
|
3334
|
+
await revertDetachedResumeCluster({
|
|
3335
|
+
clusterId: id,
|
|
3336
|
+
storageDir: path.join(os.homedir(), '.zeroshot'),
|
|
3337
|
+
error: resumeError,
|
|
3338
|
+
});
|
|
3339
|
+
}
|
|
3340
|
+
throw resumeError;
|
|
3341
|
+
}
|
|
3220
3342
|
|
|
3221
3343
|
console.log(chalk.green(`✓ Cluster resumed`));
|
|
3222
3344
|
if (result.resumeType === 'failure') {
|
|
@@ -3232,8 +3354,9 @@ program
|
|
|
3232
3354
|
}
|
|
3233
3355
|
}
|
|
3234
3356
|
|
|
3235
|
-
// === DAEMON MODE:
|
|
3236
|
-
if (
|
|
3357
|
+
// === DAEMON MODE: stay alive in the background, let signals stop the cluster ===
|
|
3358
|
+
if (isResumeDaemon) {
|
|
3359
|
+
setupDaemonCleanup(orchestrator, id);
|
|
3237
3360
|
console.log('');
|
|
3238
3361
|
console.log(chalk.dim(`Follow logs with: zeroshot logs ${id} -f`));
|
|
3239
3362
|
return;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Legacy Zeroshot cluster worker
|
|
2
|
+
|
|
3
|
+
`createLegacyClusterWorker()` exposes the current Node Zeroshot engine as the bounded
|
|
4
|
+
`legacy.zeroshot.ship@1` worker. It is an implementation binding for a parent process, not an Open
|
|
5
|
+
Engine Cluster Protocol transport or a graph executor.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
const { createLegacyClusterWorker } = require('@the-open-engine/zeroshot/lib/cluster-worker');
|
|
9
|
+
|
|
10
|
+
const worker = createLegacyClusterWorker({
|
|
11
|
+
profileRegistry,
|
|
12
|
+
artifactResolver,
|
|
13
|
+
artifactReceiptSink,
|
|
14
|
+
engineAdapter,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
await worker.start({
|
|
18
|
+
source: 'issue',
|
|
19
|
+
issue: 'https://github.com/example/project/issues/123',
|
|
20
|
+
artifacts: [],
|
|
21
|
+
isolationProfile: 'isolation.worktree@1',
|
|
22
|
+
providerProfile: 'provider.default@1',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
for await (const event of worker.events()) {
|
|
26
|
+
// Live lifecycle transitions only; no durable cursor or replay guarantee.
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const receipt = await worker.result();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The five public operations are `start(request)`, `status()`, `events()`, `stop()`, and `result()`.
|
|
33
|
+
One facade owns at most one cluster. There is no guidance, permission, authentication, writable
|
|
34
|
+
attach, or arbitrary-input operation.
|
|
35
|
+
|
|
36
|
+
## Closed input
|
|
37
|
+
|
|
38
|
+
Requests validate against the generated `LegacyShipRequest` schema. They select exactly one source:
|
|
39
|
+
|
|
40
|
+
- `issue`: nonempty `issue`, no prompt, and no artifacts;
|
|
41
|
+
- `prompt`: nonempty `prompt`, no issue, and no artifacts;
|
|
42
|
+
- `artifact`: one or more byte-free `ArtifactRef` receipts and no issue or prompt.
|
|
43
|
+
|
|
44
|
+
`isolationProfile` and `providerProfile` are opaque registry handles. Unknown fields are rejected,
|
|
45
|
+
so payloads cannot carry credentials, tokens, environment maps, endpoints, models, timeouts,
|
|
46
|
+
filesystem paths, commands, or launch flags. The production deployment registry resolves only
|
|
47
|
+
worktree, Docker, PR, or ship isolation. A missing, unknown, or non-isolated profile fails before
|
|
48
|
+
the engine allocates a ledger, worktree, or container.
|
|
49
|
+
|
|
50
|
+
Artifact input fails before allocation unless the embedding product injects a real resolver. After
|
|
51
|
+
the engine allocates the worktree or Docker workspace but before any agent starts, that resolver
|
|
52
|
+
materializes declared receipts as read-only content inside the allocated isolation and returns an
|
|
53
|
+
engine-private, byte-free manifest. Echoing receipts without making their content readable is not a
|
|
54
|
+
resolver. Artifact receipt sinks convert declared durable output to canonical byte-free receipts.
|
|
55
|
+
Without a sink, successful output contains an empty artifact list. Raw provider and agent output
|
|
56
|
+
remains engine diagnostic data and never enters the stable result.
|
|
57
|
+
|
|
58
|
+
## Lifecycle and termination
|
|
59
|
+
|
|
60
|
+
The monotonic lifecycle is `idle`, `starting`, `running`, optional `stopping`, then exactly one of
|
|
61
|
+
`completed`, `failed`, `timed_out`, `stopped`, or `malformed`. Completion and failure are folded from
|
|
62
|
+
the engine cluster record and durable `CLUSTER_COMPLETE` or `CLUSTER_FAILED` ledger messages. PID
|
|
63
|
+
liveness can add diagnostics but cannot manufacture success.
|
|
64
|
+
|
|
65
|
+
Completion, declared failure, malformed output, the registry-owned execution deadline, and explicit
|
|
66
|
+
stop race under one terminal latch. The first accepted authority is immutable; late messages do not
|
|
67
|
+
change the receipt or emit another terminal event. Profile resolution, artifact staging, engine
|
|
68
|
+
start, and artifact receipt collection all remain within that deadline and can be preempted by
|
|
69
|
+
explicit stop. Completion becomes authoritative only after its canonical receipt has been bounded
|
|
70
|
+
and validated, so a stalled receipt sink cannot suppress timeout or stop. Profile resolution,
|
|
71
|
+
artifact staging, and receipt collection receive one cancellation signal. If an injected operation
|
|
72
|
+
ignores that signal and completes late, its registry `release` or port `cleanup` hook retains
|
|
73
|
+
resource ownership and runs after the terminal receipt is fixed. A late operation or cleanup
|
|
74
|
+
failure is reported through the injected cleanup-failure reporter, or as a process warning when no
|
|
75
|
+
reporter is configured; it is never silently discarded or allowed to rewrite terminal truth.
|
|
76
|
+
|
|
77
|
+
A synchronous pre-allocation rejection leaves the facade idle and releases its one-start claim.
|
|
78
|
+
`stop()` and `result()` then reject as not started, and the caller may retry with a valid request.
|
|
79
|
+
|
|
80
|
+
`stop()` delegates to the engine stop path and waits no longer than the deployment profile's
|
|
81
|
+
shutdown deadline. It reports `effective: false` when the engine rejects or does not acknowledge
|
|
82
|
+
stop in time. The acknowledgement deadline does not abandon resource ownership: if engine
|
|
83
|
+
allocation occurs later while start is still pending, background cleanup stops that cluster. Stop
|
|
84
|
+
always reports
|
|
85
|
+
`externalEffectsRolledBack: false`: provider, repository, or tool side effects may already have
|
|
86
|
+
happened.
|
|
87
|
+
|
|
88
|
+
Engine completion must contain either a canonical `LegacyShipResult` or an explicit bounded summary.
|
|
89
|
+
Engine failures must contain a valid closed error-code/reason pair. Missing completion data, invalid
|
|
90
|
+
failure values, and invalid failure pairs terminate as `malformed`; the facade never fills them with
|
|
91
|
+
success or crash defaults. Engine status observation is synchronous so a durable-ledger observation
|
|
92
|
+
failure can immediately fail closed instead of becoming an unobserved rejected promise.
|
|
93
|
+
|
|
94
|
+
## Executable
|
|
95
|
+
|
|
96
|
+
`zeroshot-cluster-worker` reads bounded NDJSON commands from stdin and writes only NDJSON protocol
|
|
97
|
+
frames to stdout. Diagnostics use stderr. A process owns one worker resource.
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{"id":"1","method":"start","params":{"request":{"source":"prompt","prompt":"Run task","artifacts":[],"isolationProfile":"isolation.docker@1","providerProfile":"provider.default@1"}}}
|
|
101
|
+
{"id":"2","method":"status","params":{}}
|
|
102
|
+
{"id":"3","method":"events","params":{}}
|
|
103
|
+
{"id":"4","method":"result","params":{}}
|
|
104
|
+
{"id":"5","method":"stop","params":{}}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Responses are `{ "type": "response", "id", "ok", "result" }` or contain a closed error object.
|
|
108
|
+
An `events` subscription also receives `{ "type": "event", "id", "event" }` frames. Arrays,
|
|
109
|
+
malformed or oversized JSON, unknown methods, unknown parameters, and duplicate starts fail closed.
|
|
110
|
+
EOF, SIGINT, and SIGTERM request explicit stop and wait no longer than the shutdown deadline. They do
|
|
111
|
+
not imply rollback. Cleanup that settles inside that executable deadline is drained; if allocation
|
|
112
|
+
never registers or settles, the process invokes its internal adapter-release port and exits at the
|
|
113
|
+
deadline without adding a sixth public worker operation.
|
|
114
|
+
|
|
115
|
+
`result` and `stop` race the in-flight `start` operation against terminal receipt availability. A
|
|
116
|
+
startup timeout or allocated-engine start failure therefore returns canonical terminal truth even
|
|
117
|
+
when the adapter's original start promise never fulfills.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const Ajv2020 = require('ajv/dist/2020');
|
|
5
|
+
const workerSchema = require(
|
|
6
|
+
path.join('..', '..', 'protocol', 'openengine-cluster', 'v1', 'worker.schema.json')
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
const MAX_SUMMARY_BYTES = 4096;
|
|
10
|
+
const METHODS = Object.freeze(['start', 'status', 'events', 'stop', 'result']);
|
|
11
|
+
const TERMINAL_STATES = Object.freeze(['completed', 'failed', 'timed_out', 'stopped', 'malformed']);
|
|
12
|
+
|
|
13
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false, allowUnionTypes: true });
|
|
14
|
+
|
|
15
|
+
function compileDefinition(name) {
|
|
16
|
+
if (!workerSchema.$defs[name]) {
|
|
17
|
+
throw new Error(`Worker schema definition ${name} is missing`);
|
|
18
|
+
}
|
|
19
|
+
return ajv.compile({ $ref: `#/$defs/${name}`, $defs: workerSchema.$defs });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const validators = Object.freeze({
|
|
23
|
+
LegacyShipRequest: compileDefinition('LegacyShipRequest'),
|
|
24
|
+
LegacyShipResult: compileDefinition('LegacyShipResult'),
|
|
25
|
+
WorkerOutcome: compileDefinition('WorkerOutcome'),
|
|
26
|
+
ArtifactRef: compileDefinition('ArtifactRef'),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function formatErrors(errors) {
|
|
30
|
+
return (errors || []).map((error) => `${error.instancePath || '/'} ${error.message}`).join('; ');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function assertDefinition(name, value) {
|
|
34
|
+
const validate = validators[name];
|
|
35
|
+
if (!validate(value)) {
|
|
36
|
+
const error = new TypeError(`Invalid ${name}: ${formatErrors(validate.errors)}`);
|
|
37
|
+
error.code = 'INVALID_CONTRACT';
|
|
38
|
+
error.validationErrors = validate.errors;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function byteLength(value) {
|
|
45
|
+
return Buffer.byteLength(value, 'utf8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function assertBoundedSummary(summary) {
|
|
49
|
+
if (typeof summary !== 'string' || byteLength(summary) > MAX_SUMMARY_BYTES) {
|
|
50
|
+
throw new TypeError(`Summary must be a string of at most ${MAX_SUMMARY_BYTES} UTF-8 bytes`);
|
|
51
|
+
}
|
|
52
|
+
return summary;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateLegacyShipRequest(value) {
|
|
56
|
+
return assertDefinition('LegacyShipRequest', value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function validateLegacyShipResult(value) {
|
|
60
|
+
assertDefinition('LegacyShipResult', value);
|
|
61
|
+
assertBoundedSummary(value.summary);
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateWorkerOutcome(value) {
|
|
66
|
+
return assertDefinition('WorkerOutcome', value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validateArtifactRef(value) {
|
|
70
|
+
return assertDefinition('ArtifactRef', value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertPlainObject(value, label) {
|
|
74
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
75
|
+
throw new TypeError(`${label} must be an object`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function assertExactKeys(value, allowed, label) {
|
|
80
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
81
|
+
if (unknown.length > 0) {
|
|
82
|
+
throw new TypeError(`${label} contains unknown fields: ${unknown.join(', ')}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function validateCommandFrame(value) {
|
|
87
|
+
assertPlainObject(value, 'Command frame');
|
|
88
|
+
assertExactKeys(value, ['id', 'method', 'params'], 'Command frame');
|
|
89
|
+
if (
|
|
90
|
+
!(
|
|
91
|
+
(typeof value.id === 'string' && value.id.length > 0 && value.id.length <= 128) ||
|
|
92
|
+
(Number.isSafeInteger(value.id) && value.id >= 0)
|
|
93
|
+
)
|
|
94
|
+
) {
|
|
95
|
+
throw new TypeError(
|
|
96
|
+
'Command frame id must be a nonempty string up to 128 characters or a nonnegative integer'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (!METHODS.includes(value.method)) {
|
|
100
|
+
throw new TypeError(`Unknown worker method: ${String(value.method)}`);
|
|
101
|
+
}
|
|
102
|
+
assertPlainObject(value.params, 'Command frame params');
|
|
103
|
+
if (value.method === 'start') {
|
|
104
|
+
assertExactKeys(value.params, ['request'], 'start params');
|
|
105
|
+
if (!Object.prototype.hasOwnProperty.call(value.params, 'request')) {
|
|
106
|
+
throw new TypeError('start params require request');
|
|
107
|
+
}
|
|
108
|
+
validateLegacyShipRequest(value.params.request);
|
|
109
|
+
} else {
|
|
110
|
+
assertExactKeys(value.params, [], `${value.method} params`);
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validateLifecycleStatus(value) {
|
|
116
|
+
assertPlainObject(value, 'Lifecycle status');
|
|
117
|
+
assertExactKeys(
|
|
118
|
+
value,
|
|
119
|
+
['state', 'clusterId', 'sequence', 'stopRequested', 'terminal'],
|
|
120
|
+
'Lifecycle status'
|
|
121
|
+
);
|
|
122
|
+
const states = ['idle', 'starting', 'running', 'stopping', ...TERMINAL_STATES];
|
|
123
|
+
if (!states.includes(value.state)) throw new TypeError(`Invalid lifecycle state: ${value.state}`);
|
|
124
|
+
if (value.clusterId !== null && typeof value.clusterId !== 'string') {
|
|
125
|
+
throw new TypeError('Lifecycle clusterId must be a string or null');
|
|
126
|
+
}
|
|
127
|
+
if (!Number.isSafeInteger(value.sequence) || value.sequence < 0) {
|
|
128
|
+
throw new TypeError('Lifecycle sequence must be a nonnegative integer');
|
|
129
|
+
}
|
|
130
|
+
if (typeof value.stopRequested !== 'boolean') {
|
|
131
|
+
throw new TypeError('Lifecycle stopRequested must be boolean');
|
|
132
|
+
}
|
|
133
|
+
if (value.terminal !== TERMINAL_STATES.includes(value.state)) {
|
|
134
|
+
throw new TypeError('Lifecycle terminal flag does not match state');
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function validateCompletedReceipt(value) {
|
|
140
|
+
assertExactKeys(value, ['state', 'clusterId', 'finishedAt', 'result'], 'Terminal receipt');
|
|
141
|
+
validateLegacyShipResult(value.result);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validateStoppedReceipt(value) {
|
|
145
|
+
assertExactKeys(value, ['state', 'clusterId', 'finishedAt', 'stop'], 'Terminal receipt');
|
|
146
|
+
assertPlainObject(value.stop, 'Stop receipt');
|
|
147
|
+
assertExactKeys(
|
|
148
|
+
value.stop,
|
|
149
|
+
['requested', 'effective', 'externalEffectsRolledBack'],
|
|
150
|
+
'Stop receipt'
|
|
151
|
+
);
|
|
152
|
+
const valid =
|
|
153
|
+
value.stop.requested === true &&
|
|
154
|
+
typeof value.stop.effective === 'boolean' &&
|
|
155
|
+
value.stop.externalEffectsRolledBack === false;
|
|
156
|
+
if (!valid) throw new TypeError('Invalid stop receipt');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function validateFailureReceipt(value) {
|
|
160
|
+
assertExactKeys(value, ['state', 'clusterId', 'finishedAt', 'outcome'], 'Terminal receipt');
|
|
161
|
+
validateWorkerOutcome(value.outcome);
|
|
162
|
+
if (value.outcome.status !== 'error')
|
|
163
|
+
throw new TypeError('Failure receipt requires error outcome');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function validateTerminalReceipt(value) {
|
|
167
|
+
assertPlainObject(value, 'Terminal receipt');
|
|
168
|
+
if (!TERMINAL_STATES.includes(value.state)) {
|
|
169
|
+
throw new TypeError(`Invalid terminal receipt state: ${value.state}`);
|
|
170
|
+
}
|
|
171
|
+
if (typeof value.clusterId !== 'string' || !value.clusterId) {
|
|
172
|
+
throw new TypeError('Terminal receipt requires clusterId');
|
|
173
|
+
}
|
|
174
|
+
if (!Number.isFinite(value.finishedAt)) {
|
|
175
|
+
throw new TypeError('Terminal receipt requires numeric finishedAt');
|
|
176
|
+
}
|
|
177
|
+
if (value.state === 'completed') validateCompletedReceipt(value);
|
|
178
|
+
else if (value.state === 'stopped') validateStoppedReceipt(value);
|
|
179
|
+
else validateFailureReceipt(value);
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = {
|
|
184
|
+
MAX_SUMMARY_BYTES,
|
|
185
|
+
METHODS,
|
|
186
|
+
TERMINAL_STATES,
|
|
187
|
+
validateLegacyShipRequest,
|
|
188
|
+
validateLegacyShipResult,
|
|
189
|
+
validateWorkerOutcome,
|
|
190
|
+
validateArtifactRef,
|
|
191
|
+
validateCommandFrame,
|
|
192
|
+
validateLifecycleStatus,
|
|
193
|
+
validateTerminalReceipt,
|
|
194
|
+
};
|