agents-handoff 0.0.0-stage → 2.0.2
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/CHANGELOG.md +150 -0
- package/LICENSE +21 -0
- package/README.md +110 -2
- package/SKILL.md +147 -0
- package/capability-registry.json +27 -0
- package/docs/ARCHITECTURE.md +164 -0
- package/docs/CHANGELOG.md +151 -0
- package/docs/CLI.md +196 -0
- package/docs/COMPATIBILITY.md +124 -0
- package/docs/CONTRIBUTING.md +134 -0
- package/docs/FORMAT.md +157 -0
- package/docs/INSTALL.md +179 -0
- package/docs/INTEGRATION.md +188 -0
- package/docs/LEVEL4.md +202 -0
- package/docs/LEVEL5.md +96 -0
- package/docs/PERMISSIONS.md +145 -0
- package/docs/PROVENANCE.md +83 -0
- package/docs/SECURITY.md +93 -0
- package/docs/SESSIONS.md +66 -0
- package/docs/TROUBLESHOOTING.md +158 -0
- package/docs/UNINSTALL.md +122 -0
- package/docs/UPGRADE.md +139 -0
- package/docs/_config.yml +16 -0
- package/docs/_data/nav.yml +36 -0
- package/docs/_layouts/default.html +31 -0
- package/docs/assets/style.css +88 -0
- package/docs/index.md +83 -0
- package/handoff.config.example.json +35 -0
- package/handoff.config.schema.json +117 -0
- package/install/CHANGELOG.md +48 -0
- package/install/README.md +76 -0
- package/install/install.mjs +856 -0
- package/install/package.json +39 -0
- package/package.json +66 -4
- package/permission-policy.json +33 -0
- package/refs/ADAPTERS.md +33 -0
- package/refs/bootstrap.md +59 -0
- package/refs/brief-checklist.md +79 -0
- package/refs/handbook.md +58 -0
- package/refs/protocol.md +117 -0
- package/refs/roles.md +75 -0
- package/refs/validator.md +73 -0
- package/schemas/handoff.schema.json +275 -0
- package/skill.json +147 -0
- package/templates/HANDOFF.llm.schema.json +144 -0
- package/templates/HANDOFF.template.md +40 -0
- package/tests/acceptance/acceptance.yaml +209 -0
- package/tests/fixtures/minimal-transcript.jsonl +2 -0
- package/tools/agent-handoff.mjs +410 -0
- package/tools/capability-registry.mjs +120 -0
- package/tools/handoff.mjs +398 -0
- package/tools/handoff.test.mjs +465 -0
- package/tools/lib/handoff-root.mjs +161 -0
- package/tools/runtime-engine.mjs +330 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// runtime-engine.mjs v1.1.0 — runtime MVP Tasks 5+6: permission engine, bounded
|
|
3
|
+
// execution engine skeleton, and checkpoint/resume. Zero-dep (node:* only),
|
|
4
|
+
// filesystem-backed policy (permission-policy.json implements
|
|
5
|
+
// docs/PERMISSION-BOUNDARY.md).
|
|
6
|
+
//
|
|
7
|
+
// ENFORCEMENT ORDER (the contract): every operation is EVALUATED first; execution
|
|
8
|
+
// happens ONLY on an ALLOWED verdict. DENIED and NEEDS_AUTH operations never run —
|
|
9
|
+
// the record shows verdict + enforced_before_execution=true.
|
|
10
|
+
//
|
|
11
|
+
// Commands:
|
|
12
|
+
// node tools/runtime-engine.mjs evaluate --risk R0..R4 --target <path> [--json]
|
|
13
|
+
// node tools/runtime-engine.mjs run --risk R0|R1 --op read --target <path>
|
|
14
|
+
// node tools/runtime-engine.mjs run --risk R1 --op spawn --args "<args for node>"
|
|
15
|
+
// node tools/runtime-engine.mjs policy
|
|
16
|
+
// node tools/runtime-engine.mjs job --session S --steps N [--fail-at K] [--json]
|
|
17
|
+
// node tools/runtime-engine.mjs resume --session S --steps N [--json]
|
|
18
|
+
// node tools/runtime-engine.mjs status --session S [--json]
|
|
19
|
+
// Exit codes: 0 ALLOWED/executed/completed; 2 usage/config error; 3 DENIED;
|
|
20
|
+
// 4 NEEDS_AUTH or missing checkpoint; 5 corrupt checkpoint (integrity failure);
|
|
21
|
+
// 137 simulated abrupt kill (job --fail-at); otherwise the child's captured exit
|
|
22
|
+
// code (bounded execution).
|
|
23
|
+
// Checkpoints (Task 6): after EACH bounded step of a job a durable checkpoint is
|
|
24
|
+
// written to .agent-handoff/checkpoints/<session>.json with a sha256 integrity
|
|
25
|
+
// seal. Resume reconstructs state from disk ONLY — zero shared memory. A missing
|
|
26
|
+
// checkpoint fails honestly (exit 4, never guessed); a tampered checkpoint fails
|
|
27
|
+
// the integrity seal (exit 5). Abrupt termination is simulated by process.exit(137)
|
|
28
|
+
// before the --fail-at step executes — deterministic, hermetic kill equivalent.
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import crypto from 'node:crypto';
|
|
32
|
+
import { spawnSync } from 'node:child_process';
|
|
33
|
+
|
|
34
|
+
const REPO = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..');
|
|
35
|
+
const DEFAULT_POLICY = path.join(REPO, 'permission-policy.json');
|
|
36
|
+
// State root: overridable so oracles run hermetically and the repo accumulates
|
|
37
|
+
// nothing. Env: AGENT_HANDOFF_STATE_DIR=<dir>
|
|
38
|
+
const STATE_DIR = process.env.AGENT_HANDOFF_STATE_DIR
|
|
39
|
+
? path.resolve(process.env.AGENT_HANDOFF_STATE_DIR)
|
|
40
|
+
: path.join(REPO, '.agent-handoff');
|
|
41
|
+
const EXEC_DIR = path.join(STATE_DIR, 'executions');
|
|
42
|
+
const CKPT_DIR = path.join(STATE_DIR, 'checkpoints');
|
|
43
|
+
const JOBS_DIR = path.join(STATE_DIR, 'jobs');
|
|
44
|
+
const sha = s => crypto.createHash('sha256').update(s).digest('hex');
|
|
45
|
+
const argOf = (n, f) => { const i = process.argv.indexOf(n); return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : (f ? f() : null); };
|
|
46
|
+
const hasFlag = n => process.argv.includes(n);
|
|
47
|
+
const out = (o) => { console.log(hasFlag('--json') ? JSON.stringify(o, null, 2) : JSON.stringify(o)); };
|
|
48
|
+
|
|
49
|
+
function loadPolicy(p) {
|
|
50
|
+
if (!fs.existsSync(p)) return { error: 'policy not found: ' + p };
|
|
51
|
+
try {
|
|
52
|
+
const pol = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
53
|
+
if (!pol.grants || !pol.risk_to_level) return { error: 'policy missing grants/risk_to_level: ' + p };
|
|
54
|
+
return { pol };
|
|
55
|
+
} catch (e) { return { error: 'policy invalid JSON: ' + p + ' (' + e.message + ')' }; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const rel = p => path.isAbsolute(p) ? path.normalize(p) : path.normalize(path.join(REPO, p));
|
|
59
|
+
function contains(root, target) {
|
|
60
|
+
const r = path.relative(path.normalize(root), path.normalize(target));
|
|
61
|
+
return r === '' || (!r.startsWith('..') && !path.isAbsolute(r));
|
|
62
|
+
}
|
|
63
|
+
// Classification precedence, most specific first:
|
|
64
|
+
// 1. denied_roots — an explicit denial always wins
|
|
65
|
+
// 2. the tool's OWN STATE_DIR — explicit, wherever AGENT_HANDOFF_STATE_DIR points
|
|
66
|
+
// 3. approved_workspaces — the operator's explicit allow-list ('.', repo-upstream, ...)
|
|
67
|
+
// 4. personal_data_roots — a BROAD default root (C:\Users, /home/<user>)
|
|
68
|
+
// 5. system_read_only_roots — broad default (C:\Windows, Program Files)
|
|
69
|
+
// 6. EXTERNAL_PATH
|
|
70
|
+
//
|
|
71
|
+
// (3) MUST come before (4): the broad default usually CONTAINS the workspace. A globally
|
|
72
|
+
// installed skill lives in ~/.agents/skills, %APPDATA%/... or ~/.config/..., all inside the
|
|
73
|
+
// personal-data root, so with the broad root winning, every in-workspace operation of a real
|
|
74
|
+
// install was denied at any risk — the acceptance-D read of the shipped fixture exited 3
|
|
75
|
+
// instead of 0 from every installed copy while passing in the dev tree at E:\. Specific
|
|
76
|
+
// approval beats the broad default; the broad default still governs everything OUTSIDE the
|
|
77
|
+
// workspace, and an explicit denied_roots entry still beats both.
|
|
78
|
+
function classify(pol, target) {
|
|
79
|
+
if (pol.denied_roots && pol.denied_roots.some(rt => contains(rel(rt), target))) return 'DENIED_ROOT';
|
|
80
|
+
if (target && contains(STATE_DIR, target)) return 'APPROVED_WORKSPACE';
|
|
81
|
+
if (pol.approved_workspaces && pol.approved_workspaces.some(rt => contains(rel(rt), target))) return 'APPROVED_WORKSPACE';
|
|
82
|
+
if (pol.personal_data_roots && pol.personal_data_roots.some(rt => contains(rel(rt), target))) return 'PERSONAL_DATA_PATH';
|
|
83
|
+
if (pol.system_read_only_roots && pol.system_read_only_roots.some(rt => contains(rel(rt), target))) return 'SYSTEM_PATH';
|
|
84
|
+
return 'EXTERNAL_PATH';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// evaluate: returns { verdict, reason, risk, level, classification }
|
|
88
|
+
// verdict is exactly ALLOWED | DENIED | NEEDS_AUTH. Unknown risk or unknown grant
|
|
89
|
+
// value is DENIED (default for unknown — never guessed allowed).
|
|
90
|
+
function evaluate(pol, risk, target) {
|
|
91
|
+
const level = pol.risk_to_level[risk];
|
|
92
|
+
if (!level) return { verdict: 'DENIED', reason: 'unknown risk class "' + risk + '" (default denial)', risk, level: null, classification: target ? classify(pol, target) : null };
|
|
93
|
+
// Absolute denials first: personal data and secrets-like paths are DENIED at any risk.
|
|
94
|
+
if (target) {
|
|
95
|
+
const cls = classify(pol, target);
|
|
96
|
+
if (cls === 'PERSONAL_DATA_PATH' || cls === 'DENIED_ROOT') return { verdict: 'DENIED', reason: 'target classified ' + cls + ' — denied at any risk level', risk, level, classification: cls };
|
|
97
|
+
if (level === 'READ_ONLY' || level === 'DISCOVERY_ONLY') {
|
|
98
|
+
if (cls === 'APPROVED_WORKSPACE' || cls === 'SYSTEM_PATH') return { verdict: 'ALLOWED', reason: cls + ' read at ' + risk, risk, level, classification: cls };
|
|
99
|
+
return { verdict: 'DENIED', reason: cls + ' not readable at ' + risk, risk, level, classification: cls };
|
|
100
|
+
}
|
|
101
|
+
if (cls !== 'APPROVED_WORKSPACE') return { verdict: 'DENIED', reason: cls + ' outside approved workspace — writes/effects only inside workspace', risk, level, classification: cls };
|
|
102
|
+
}
|
|
103
|
+
const grant = pol.grants[level];
|
|
104
|
+
if (grant === 'allow') return { verdict: 'ALLOWED', reason: level + ' granted at ' + risk, risk, level, classification: target ? classify(pol, target) : 'APPROVED_WORKSPACE' };
|
|
105
|
+
if (grant === 'needs_auth') return { verdict: 'NEEDS_AUTH', reason: level + ' requires explicit authorization at ' + risk, risk, level, classification: target ? classify(pol, target) : 'APPROVED_WORKSPACE' };
|
|
106
|
+
return { verdict: 'DENIED', reason: 'unknown grant "' + grant + '" for ' + level + ' (default denial)', risk, level, classification: target ? classify(pol, target) : null };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function record(rec) {
|
|
110
|
+
try {
|
|
111
|
+
fs.mkdirSync(EXEC_DIR, { recursive: true });
|
|
112
|
+
const id = new Date().toISOString().replace(/[:.]/g, '-') + '-' + process.pid + '-' + Math.random().toString(36).slice(2, 8);
|
|
113
|
+
const full = Object.assign({ id, enforced_before_execution: true }, rec);
|
|
114
|
+
const p = path.join(EXEC_DIR, id + '.json');
|
|
115
|
+
fs.writeFileSync(p, JSON.stringify(full, null, 2) + '\n');
|
|
116
|
+
// A state dir on another volume cannot be expressed relative to REPO: path.relative
|
|
117
|
+
// returns an ABSOLUTE path there, and consumers that blindly joined it onto REPO then
|
|
118
|
+
// failed with ENOENT. Store whatever actually resolves, and never a broken-looking
|
|
119
|
+
// relative path.
|
|
120
|
+
const relToRepo = path.relative(REPO, p);
|
|
121
|
+
full.record_path = path.isAbsolute(relToRepo) ? p : relToRepo;
|
|
122
|
+
return full;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
rec.record_error = String(e && e.message || e);
|
|
125
|
+
return rec;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function evaluateCmd() {
|
|
130
|
+
const polP = rel(argOf('--policy', () => DEFAULT_POLICY));
|
|
131
|
+
const { pol, error } = loadPolicy(polP);
|
|
132
|
+
if (error) { out({ error }); process.exit(2); }
|
|
133
|
+
const risk = argOf('--risk', () => null);
|
|
134
|
+
const target = argOf('--target', () => null);
|
|
135
|
+
if (!risk) { out({ error: 'usage: evaluate --risk R0..R4 [--target path]' }); process.exit(2); }
|
|
136
|
+
const v = evaluate(pol, risk, target ? rel(target) : null);
|
|
137
|
+
out(v);
|
|
138
|
+
process.exit(v.verdict === 'ALLOWED' ? 0 : v.verdict === 'DENIED' ? 3 : 4);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function runCmd() {
|
|
142
|
+
const polP = rel(argOf('--policy', () => DEFAULT_POLICY));
|
|
143
|
+
const { pol, error } = loadPolicy(polP);
|
|
144
|
+
if (error) { out({ error }); process.exit(2); }
|
|
145
|
+
const risk = argOf('--risk', () => null);
|
|
146
|
+
const op = argOf('--op', () => null);
|
|
147
|
+
const target = argOf('--target', () => null);
|
|
148
|
+
const args = argOf('--args', () => null);
|
|
149
|
+
if (!risk || !op) { out({ error: 'usage: run --risk R0|R1 --op read --target <path> | --op spawn --args "<node args>"' }); process.exit(2); }
|
|
150
|
+
|
|
151
|
+
if (op === 'read') {
|
|
152
|
+
if (!target) { out({ error: 'read op requires --target' }); process.exit(2); }
|
|
153
|
+
const t = rel(target);
|
|
154
|
+
const v = evaluate(pol, risk, t);
|
|
155
|
+
if (v.verdict !== 'ALLOWED') {
|
|
156
|
+
record({ op, risk, verdict: v.verdict, reason: v.reason, target, executed: false });
|
|
157
|
+
out(Object.assign(v, { executed: false }));
|
|
158
|
+
process.exit(v.verdict === 'DENIED' ? 3 : 4);
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const content = fs.readFileSync(t);
|
|
162
|
+
const rec = record({ op, risk, verdict: 'ALLOWED', reason: v.reason, target, executed: true, bytes: content.length, content_sha256: sha(content) });
|
|
163
|
+
out(Object.assign({ executed: true, bytes: content.length, content_sha256: sha(content) }, { record: rec.record_path }));
|
|
164
|
+
process.exit(0);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
const rec = record({ op, risk, verdict: 'ALLOWED', reason: v.reason, target, executed: false, execution_error: String(e && e.message || e) });
|
|
167
|
+
out({ executed: false, error: String(e && e.message || e), record: rec.record_path });
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (op === 'spawn') {
|
|
173
|
+
// Bounded: only the local node binary, args from --args, cwd pinned to the
|
|
174
|
+
// approved workspace, hard timeout, output captured and digested.
|
|
175
|
+
const v = evaluate(pol, risk, REPO); // workspace-rooted operation
|
|
176
|
+
if (v.verdict !== 'ALLOWED') {
|
|
177
|
+
record({ op, risk, verdict: v.verdict, reason: v.reason, executed: false, args });
|
|
178
|
+
out(Object.assign(v, { executed: false }));
|
|
179
|
+
process.exit(v.verdict === 'DENIED' ? 3 : 4);
|
|
180
|
+
}
|
|
181
|
+
if (!args || !args.trim()) { out({ error: 'spawn op requires --args' }); process.exit(2); }
|
|
182
|
+
const childArgs = args.split(' ').filter(Boolean);
|
|
183
|
+
const started = new Date().toISOString();
|
|
184
|
+
const r = spawnSync(process.execPath, childArgs, { cwd: REPO, encoding: 'utf8', timeout: 15000 });
|
|
185
|
+
const rec = record({
|
|
186
|
+
op, risk, verdict: 'ALLOWED', reason: v.reason, executed: true,
|
|
187
|
+
command: 'node', args: childArgs, started,
|
|
188
|
+
ended: new Date().toISOString(),
|
|
189
|
+
exit_code: r.status, signal: r.signal || null,
|
|
190
|
+
stdout_sha256: sha(r.stdout || ''), stderr_sha256: sha(r.stderr || ''),
|
|
191
|
+
stdout_bytes: (r.stdout || '').length, stderr_bytes: (r.stderr || '').length,
|
|
192
|
+
timed_out: Boolean(r.error && String(r.error).includes('ETIMEDOUT')) || r.signal === 'SIGTERM',
|
|
193
|
+
});
|
|
194
|
+
out({ executed: true, exit_code: r.status, signal: r.signal || null, record: rec.record_path });
|
|
195
|
+
process.exit(r.status === null ? 1 : r.status);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
out({ error: 'unknown op "' + op + '" (skeleton supports: read, spawn)' });
|
|
199
|
+
process.exit(2);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---- Task 6: durable checkpoint + resume (zero shared memory) ----
|
|
203
|
+
|
|
204
|
+
const ckptPath = session => path.join(CKPT_DIR, String(session).replace(/[^\w.-]/g, '_') + '.json');
|
|
205
|
+
const workLog = session => path.join(JOBS_DIR, String(session).replace(/[^\w.-]/g, '_'), 'work.log');
|
|
206
|
+
|
|
207
|
+
function writeCheckpoint(session, ckpt) {
|
|
208
|
+
fs.mkdirSync(CKPT_DIR, { recursive: true });
|
|
209
|
+
ckpt.updated_at = new Date().toISOString();
|
|
210
|
+
const body = Object.assign({}, ckpt);
|
|
211
|
+
delete body.checkpoint_sha256;
|
|
212
|
+
ckpt.checkpoint_sha256 = sha(JSON.stringify(body));
|
|
213
|
+
fs.writeFileSync(ckptPath(session), JSON.stringify(ckpt, null, 2) + '\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function readCheckpoint(session) {
|
|
217
|
+
const p = ckptPath(session);
|
|
218
|
+
if (!fs.existsSync(p)) return { error: 'no checkpoint for session "' + session + '" (resume requires durable state — never guessed)', code: 4 };
|
|
219
|
+
let ckpt;
|
|
220
|
+
try { ckpt = JSON.parse(fs.readFileSync(p, 'utf8')); }
|
|
221
|
+
catch (e) { return { error: 'corrupt checkpoint (not valid JSON): ' + p, code: 5 }; }
|
|
222
|
+
const body = Object.assign({}, ckpt);
|
|
223
|
+
const expect = body.checkpoint_sha256;
|
|
224
|
+
delete body.checkpoint_sha256;
|
|
225
|
+
if (!expect || sha(JSON.stringify(body)) !== expect) return { error: 'corrupt checkpoint (integrity seal mismatch): ' + p, code: 5 };
|
|
226
|
+
return { ckpt };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function execStep(session, seq, pol) {
|
|
230
|
+
// Each bounded step: permission-gated (R1 workspace write), then the step work
|
|
231
|
+
// itself — an append to the session work log. No in-memory carryover between
|
|
232
|
+
// runs: everything resume needs lives in the checkpoint + work log on disk.
|
|
233
|
+
const v = evaluate(pol, 'R1', JOBS_DIR);
|
|
234
|
+
if (v.verdict !== 'ALLOWED') return { error: 'step ' + seq + ' not permitted: ' + v.reason, code: v.verdict === 'DENIED' ? 3 : 4 };
|
|
235
|
+
fs.mkdirSync(path.dirname(workLog(session)), { recursive: true });
|
|
236
|
+
fs.appendFileSync(workLog(session), 'step ' + seq + ' at ' + new Date().toISOString() + '\n');
|
|
237
|
+
return { ok: true };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function parseIntArg(name, min) {
|
|
241
|
+
const raw = argOf(name, () => null);
|
|
242
|
+
const n = Number(raw);
|
|
243
|
+
if (!raw || !Number.isInteger(n) || n < min) { out({ error: name + ' must be an integer >= ' + min }); process.exit(2); }
|
|
244
|
+
return n;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function jobCmd() {
|
|
248
|
+
const { pol, error } = loadPolicy(rel(argOf('--policy', () => DEFAULT_POLICY)));
|
|
249
|
+
if (error) { out({ error }); process.exit(2); }
|
|
250
|
+
const session = argOf('--session', () => null);
|
|
251
|
+
if (!session) { out({ error: 'job requires --session' }); process.exit(2); }
|
|
252
|
+
const steps = parseIntArg('--steps', 1);
|
|
253
|
+
const failAt = argOf('--fail-at', () => null);
|
|
254
|
+
if (failAt !== null) {
|
|
255
|
+
const k = Number(failAt);
|
|
256
|
+
if (!Number.isInteger(k) || k < 2 || k > steps) { out({ error: '--fail-at must be an integer in [2,' + steps + ']' }); process.exit(2); }
|
|
257
|
+
}
|
|
258
|
+
const existing = readCheckpoint(session);
|
|
259
|
+
if (existing.ckpt) { out({ error: 'checkpoint already exists for session "' + session + '" — use resume' }); process.exit(2); }
|
|
260
|
+
runJob(session, steps, failAt === null ? null : Number(failAt), pol, { seq: 0, steps: [] });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function resumeCmd() {
|
|
264
|
+
const { pol, error } = loadPolicy(rel(argOf('--policy', () => DEFAULT_POLICY)));
|
|
265
|
+
if (error) { out({ error }); process.exit(2); }
|
|
266
|
+
const session = argOf('--session', () => null);
|
|
267
|
+
if (!session) { out({ error: 'resume requires --session' }); process.exit(2); }
|
|
268
|
+
const steps = parseIntArg('--steps', 1);
|
|
269
|
+
const { ckpt, error: ckptError, code } = readCheckpoint(session);
|
|
270
|
+
if (ckptError) { out({ error: ckptError }); process.exit(code); }
|
|
271
|
+
if (ckpt.completed) { out({ session, already_completed: true, seq: ckpt.seq, steps_total: ckpt.steps_total }); process.exit(0); }
|
|
272
|
+
if (ckpt.steps_total && steps !== ckpt.steps_total) {
|
|
273
|
+
out({ error: '--steps ' + steps + ' mismatches the durable checkpoint (steps_total ' + ckpt.steps_total + ') — resume must match the recorded job shape' });
|
|
274
|
+
process.exit(2);
|
|
275
|
+
}
|
|
276
|
+
runJob(session, steps, null, pol, ckpt);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function runJob(session, steps, failAt, pol, ckpt) {
|
|
280
|
+
let seq = ckpt.seq || 0;
|
|
281
|
+
ckpt.steps = ckpt.steps || [];
|
|
282
|
+
while (seq < steps) {
|
|
283
|
+
const next = seq + 1;
|
|
284
|
+
if (failAt !== null && next === failAt) {
|
|
285
|
+
// Simulated abrupt kill BEFORE executing this step and BEFORE any checkpoint
|
|
286
|
+
// for it: the last durable state is seq = next-1. A real crash (SIGKILL,
|
|
287
|
+
// power loss) leaves exactly the same on-disk state.
|
|
288
|
+
out({ session, killed_at_step: next, checkpoint_seq: seq, note: 'simulated abrupt kill (exit 137) — durable checkpoint intact at seq ' + seq });
|
|
289
|
+
process.exit(137);
|
|
290
|
+
}
|
|
291
|
+
const r = execStep(session, next, pol);
|
|
292
|
+
if (r.error) { out({ error: r.error }); process.exit(r.code); }
|
|
293
|
+
seq = next;
|
|
294
|
+
ckpt.seq = seq;
|
|
295
|
+
ckpt.steps.push({ seq, ts: new Date().toISOString(), op: 'work-log-append' });
|
|
296
|
+
ckpt.steps_total = steps;
|
|
297
|
+
ckpt.session = session;
|
|
298
|
+
writeCheckpoint(session, ckpt);
|
|
299
|
+
}
|
|
300
|
+
ckpt.completed = true;
|
|
301
|
+
writeCheckpoint(session, ckpt);
|
|
302
|
+
out({ session, completed: true, steps, seq, work_log: path.relative(REPO, workLog(session)) });
|
|
303
|
+
process.exit(0);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function statusCmd() {
|
|
307
|
+
const session = argOf('--session', () => null);
|
|
308
|
+
if (!session) { out({ error: 'status requires --session' }); process.exit(2); }
|
|
309
|
+
const { ckpt, error, code } = readCheckpoint(session);
|
|
310
|
+
if (error) { out({ error }); process.exit(code); }
|
|
311
|
+
const log = fs.existsSync(workLog(session)) ? fs.readFileSync(workLog(session), 'utf8').split(/\r?\n/).filter(Boolean) : [];
|
|
312
|
+
out({ session, seq: ckpt.seq, steps_total: ckpt.steps_total || null, completed: Boolean(ckpt.completed), work_log_lines: log.length });
|
|
313
|
+
process.exit(0);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function policyCmd() {
|
|
317
|
+
const { pol, error } = loadPolicy(rel(argOf('--policy', () => DEFAULT_POLICY)));
|
|
318
|
+
if (error) { out({ error }); process.exit(2); }
|
|
319
|
+
out(pol);
|
|
320
|
+
process.exit(0);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const cmd = process.argv[2];
|
|
324
|
+
if (cmd === 'evaluate') evaluateCmd();
|
|
325
|
+
else if (cmd === 'run') runCmd();
|
|
326
|
+
else if (cmd === 'job') jobCmd();
|
|
327
|
+
else if (cmd === 'resume') resumeCmd();
|
|
328
|
+
else if (cmd === 'status') statusCmd();
|
|
329
|
+
else if (cmd === 'policy') policyCmd();
|
|
330
|
+
else { out({ error: 'usage: runtime-engine.mjs evaluate|run|job|resume|status|policy' }); process.exit(2); }
|