@the-open-engine/zeroshot 6.6.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.
@@ -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(clusterId) {
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, `${clusterId}-daemon.log`);
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
- async function spawnDetachedCluster(options, clusterId, stdinText) {
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: targetCwd,
303
- env: buildDaemonEnv(options, clusterId, targetCwd, stdinText),
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
- const result = await orchestrator.resume(id, prompt);
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: Exit and let cluster run in background ===
3236
- if (options.detach) {
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
+ };