blun-king-cli 9.1.563 → 9.1.565
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/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
- package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
- package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
- package/agent-spine-plugin/.mcp.json +8 -8
- package/agent-spine-plugin/CONTRIBUTING.md +52 -0
- package/agent-spine-plugin/LICENSE +186 -186
- package/agent-spine-plugin/README.md +394 -0
- package/agent-spine-plugin/SECURITY.md +47 -0
- package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
- package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
- package/agent-spine-plugin/bin/agentspine.js +7 -7
- package/agent-spine-plugin/blun.plugin.json +33 -33
- package/agent-spine-plugin/hooks/codex.json +47 -47
- package/agent-spine-plugin/hooks/hooks.json +106 -106
- package/agent-spine-plugin/hooks/version.json +5 -5
- package/agent-spine-plugin/package.json +69 -69
- package/agent-spine-plugin/scripts/check-hosts.js +199 -199
- package/agent-spine-plugin/skill/SKILL.md +76 -76
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
- package/agent-spine-plugin/src/cli.js +1488 -1442
- package/agent-spine-plugin/src/hook.js +886 -812
- package/agent-spine-plugin/src/index.js +93 -93
- package/agent-spine-plugin/src/lib/acceptance.js +333 -333
- package/agent-spine-plugin/src/lib/attention.js +755 -755
- package/agent-spine-plugin/src/lib/audit.js +351 -342
- package/agent-spine-plugin/src/lib/authentication.js +515 -515
- package/agent-spine-plugin/src/lib/briefing.js +317 -317
- package/agent-spine-plugin/src/lib/catalog.js +167 -167
- package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
- package/agent-spine-plugin/src/lib/context.js +154 -154
- package/agent-spine-plugin/src/lib/continuity.js +338 -338
- package/agent-spine-plugin/src/lib/coordination.js +577 -577
- package/agent-spine-plugin/src/lib/documents.js +217 -217
- package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
- package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
- package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
- package/agent-spine-plugin/src/lib/graph.js +337 -337
- package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
- package/agent-spine-plugin/src/lib/https-transport.js +392 -392
- package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
- package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
- package/agent-spine-plugin/src/lib/learning.js +6923 -6466
- package/agent-spine-plugin/src/lib/object-transport.js +206 -206
- package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
- package/agent-spine-plugin/src/lib/paths.js +109 -109
- package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
- package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
- package/agent-spine-plugin/src/lib/preflight.js +702 -702
- package/agent-spine-plugin/src/lib/runtime.js +13 -13
- package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
- package/agent-spine-plugin/src/lib/sharing.js +969 -969
- package/agent-spine-plugin/src/lib/source-roots.js +529 -482
- package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
- package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
- package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
- package/agent-spine-plugin/src/mcp.js +597 -572
- package/agent-spine-plugin/src/version.js +1 -1
- package/agent-spine-plugin/src/worker.js +202 -195
- package/bin/active-steer-priority-policy.cjs +24 -0
- package/bin/launcher-runtime.js +8 -1
- package/bin/mnemo-tool-agent-policy.cjs +22 -0
- package/bin/thinking-activity-status-policy.cjs +132 -0
- package/bin/thinking-only-guard.cjs +75 -0
- package/bin/tool-call-loop-policy.cjs +53 -0
- package/bin/turn-thinking-policy.cjs +25 -1
- package/blun.mjs +554 -64
- package/package.json +4 -1
- package/standard-skills/translate-native/LICENSE +21 -21
- package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
- package/standard-skills/translate-native/references/native-orthography.md +79 -79
- package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
- package/standard-skills/translate-native/references/structured-content.md +72 -72
- package/standard-skills/translate-native/references/translationese-review.md +77 -77
- package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
- package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
- package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
- package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
- package/standard-skills/translate-native/scripts/language_quality.py +377 -377
- package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
- package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
- package/standard-tools/language-guard/blun_language_guard.py +697 -697
- package/standard-tools/language-guard/check_diacritics.py +353 -353
- package/standard-tools/language-guard/guard_service_client.py +264 -264
- package/standard-tools/language-guard/language_quality.py +377 -377
- package/standard-tools/language-guard/translation_guard.py +916 -916
|
@@ -1,819 +1,892 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, open, opendir, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { buildCatalog } from "./catalog.js";
|
|
5
|
-
import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
|
|
6
|
-
import { loadCoordination } from "./coordination.js";
|
|
7
|
-
import { loadGraph } from "./graph.js";
|
|
8
|
-
import { projectStateDir } from "./paths.js";
|
|
9
|
-
|
|
10
|
-
const POLICY_SCHEMA = "agentspine.execution-policy/v1";
|
|
11
|
-
const JOB_SCHEMA = "agentspine.selfstarter/v1";
|
|
12
|
-
const ACTIONS = new Set(["start", "resume", "effect"]);
|
|
13
|
-
const JOB_STATUSES = new Set(["waiting", "running", "blocked", "completed", "cancelled", "exhausted"]);
|
|
14
|
-
const HOSTS = new Set(["claude", "codex"]);
|
|
15
|
-
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
16
|
-
const CAPABILITY_RE = /^tool:[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
17
|
-
const DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
18
|
-
const MAX_STATE_BYTES = 5 * 1024 * 1024;
|
|
19
|
-
const MAX_WORKSPACE_FILES = 10000;
|
|
20
|
-
const MAX_WORKSPACE_BYTES = 64 * 1024 * 1024;
|
|
21
|
-
const CONFIRMATION = "local-owner-confirmed";
|
|
22
|
-
const EXCLUDED_NAMES = new Set([".git", "node_modules", ".DS_Store"]);
|
|
23
|
-
const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret)\s*[:=]\s*\S{8,}/i;
|
|
24
|
-
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
await
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
&&
|
|
279
|
-
&&
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (!
|
|
304
|
-
if (
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
||
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
return
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
state.
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
};
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
if (!
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
function
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
(
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
function
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
job.
|
|
542
|
-
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
async function
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
if (!
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
job.
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
job.
|
|
706
|
-
job.
|
|
707
|
-
job.
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
job
|
|
728
|
-
job
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
});
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
if (
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
job
|
|
778
|
-
job.
|
|
779
|
-
job.
|
|
780
|
-
job.
|
|
781
|
-
job.
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
return {
|
|
819
|
-
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, open, opendir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { buildCatalog } from "./catalog.js";
|
|
5
|
+
import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
|
|
6
|
+
import { loadCoordination } from "./coordination.js";
|
|
7
|
+
import { loadGraph } from "./graph.js";
|
|
8
|
+
import { projectStateDir } from "./paths.js";
|
|
9
|
+
|
|
10
|
+
const POLICY_SCHEMA = "agentspine.execution-policy/v1";
|
|
11
|
+
const JOB_SCHEMA = "agentspine.selfstarter/v1";
|
|
12
|
+
const ACTIONS = new Set(["start", "resume", "effect"]);
|
|
13
|
+
const JOB_STATUSES = new Set(["waiting", "running", "blocked", "completed", "cancelled", "exhausted"]);
|
|
14
|
+
const HOSTS = new Set(["claude", "codex"]);
|
|
15
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
16
|
+
const CAPABILITY_RE = /^tool:[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
17
|
+
const DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
18
|
+
const MAX_STATE_BYTES = 5 * 1024 * 1024;
|
|
19
|
+
const MAX_WORKSPACE_FILES = 10000;
|
|
20
|
+
const MAX_WORKSPACE_BYTES = 64 * 1024 * 1024;
|
|
21
|
+
const CONFIRMATION = "local-owner-confirmed";
|
|
22
|
+
const EXCLUDED_NAMES = new Set([".git", "node_modules", ".DS_Store"]);
|
|
23
|
+
const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret)\s*[:=]\s*\S{8,}/i;
|
|
24
|
+
|
|
25
|
+
function workspaceScanError(error) { error.agentSpineScan = true; return error; }
|
|
26
|
+
|
|
27
|
+
function emptyPolicy(root) {
|
|
28
|
+
return { schema: POLICY_SCHEMA, root, revision: 0, grants: [], history: [] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function emptyJobs(root) {
|
|
32
|
+
return { schema: JOB_SCHEMA, root, jobs: [], history: [], receipts: [] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function timestamp(value, field = "date", nullable = false) {
|
|
36
|
+
if ((value === null || value === undefined || value === "") && nullable) return null;
|
|
37
|
+
const parsed = value instanceof Date ? value : new Date(value);
|
|
38
|
+
if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
|
|
39
|
+
return parsed.toISOString();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stableId(value, field, nullable = false) {
|
|
43
|
+
if ((value === null || value === undefined || value === "") && nullable) return null;
|
|
44
|
+
if (typeof value !== "string" || !ID_RE.test(value)) throw new Error(`${field} must be a stable whitespace-free ID`);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function safeText(value, field, maximum, nullable = false) {
|
|
49
|
+
if ((value === null || value === undefined || value === "") && nullable) return null;
|
|
50
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
|
|
51
|
+
const result = value.trim().slice(0, maximum);
|
|
52
|
+
if (SECRET_RE.test(result)) throw new Error(`${field} appears to contain a secret and cannot enter self-starter state`);
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function integer(value, field, minimum, maximum) {
|
|
57
|
+
const parsed = Number(value);
|
|
58
|
+
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) throw new Error(`${field} must be an integer between ${minimum} and ${maximum}`);
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function capabilities(value) {
|
|
63
|
+
if (!Array.isArray(value) || !value.length || value.some((item) => typeof item !== "string" || !CAPABILITY_RE.test(item))) {
|
|
64
|
+
throw new Error("capabilities must contain exact tool:<name> values and cannot use wildcards");
|
|
65
|
+
}
|
|
66
|
+
const unique = [...new Set(value)].sort();
|
|
67
|
+
if (unique.some((item) => item.includes("*"))) throw new Error("capability wildcards are forbidden");
|
|
68
|
+
return unique;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizePolicy(value, root) {
|
|
72
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== POLICY_SCHEMA || value.root !== root
|
|
73
|
+
|| !Number.isInteger(value.revision) || value.revision < 0 || !Array.isArray(value.grants) || !Array.isArray(value.history)) {
|
|
74
|
+
throw new Error("execution policy structure is invalid; self-starter is disabled until repaired");
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeJobs(value, root) {
|
|
80
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== JOB_SCHEMA || value.root !== root
|
|
81
|
+
|| !Array.isArray(value.jobs) || !Array.isArray(value.history) || !Array.isArray(value.receipts)) {
|
|
82
|
+
throw new Error("self-starter checkpoint structure is invalid; automatic execution is disabled until repaired");
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function readJson(path, root, normalize, empty) {
|
|
88
|
+
try {
|
|
89
|
+
const metadata = await stat(path);
|
|
90
|
+
if (metadata.size > MAX_STATE_BYTES) throw new Error("self-starter state exceeds the 5 MiB read limit");
|
|
91
|
+
return normalize(JSON.parse(await readFile(path, "utf8")), root);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error.code !== "ENOENT") throw error;
|
|
94
|
+
return empty(root);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function saveJson(value, path) {
|
|
99
|
+
const content = `${JSON.stringify(value, null, 2)}\n`;
|
|
100
|
+
if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("self-starter state exceeds 5 MiB; purge completed jobs first");
|
|
101
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
102
|
+
try {
|
|
103
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
104
|
+
await replaceFileWithRetry(temporary, path);
|
|
105
|
+
} finally {
|
|
106
|
+
await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function acquireLock(path) {
|
|
111
|
+
const lockPath = `${path}.lock`;
|
|
112
|
+
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
113
|
+
try {
|
|
114
|
+
return { handle: await open(lockPath, "wx", 0o600), lockPath };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (!isFileLockContention(error)) throw error;
|
|
117
|
+
try {
|
|
118
|
+
const metadata = await stat(lockPath);
|
|
119
|
+
if (Date.now() - metadata.mtimeMs > 90000) await unlink(lockPath);
|
|
120
|
+
} catch (lockError) {
|
|
121
|
+
if (lockError.code !== "ENOENT") throw lockError;
|
|
122
|
+
}
|
|
123
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
throw new Error("self-starter state is busy; retry later");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function withLock(path, read, operation, save = true) {
|
|
130
|
+
const { handle, lockPath } = await acquireLock(path);
|
|
131
|
+
try {
|
|
132
|
+
const state = await read();
|
|
133
|
+
const result = await operation(state);
|
|
134
|
+
if (save) await saveJson(state, path);
|
|
135
|
+
return result;
|
|
136
|
+
} finally {
|
|
137
|
+
await handle.close();
|
|
138
|
+
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function pathsFor(root, providedCatalog = null) {
|
|
143
|
+
let catalog = providedCatalog;
|
|
144
|
+
if (!catalog) {
|
|
145
|
+
try {
|
|
146
|
+
catalog = await buildCatalog(root);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (typeof error?.code === "string" && (error.path || error.syscall)) throw workspaceScanError(error);
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const directory = await projectStateDir(catalog.root);
|
|
153
|
+
return {
|
|
154
|
+
catalog,
|
|
155
|
+
executionPolicyPath: join(directory, "execution-policy.json"),
|
|
156
|
+
selfstarterPath: join(directory, "selfstarter.json")
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function skippableTraversalError(error) {
|
|
161
|
+
return ["EPERM", "EACCES", "ENOENT"].includes(error?.code);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function skippedPath(skipped, path, error, operation) {
|
|
165
|
+
skipped.push({ path, code: error.code, operation });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function collectWorkspaceFiles(root) {
|
|
169
|
+
const files = [];
|
|
170
|
+
const skipped = [];
|
|
171
|
+
let totalBytes = 0;
|
|
172
|
+
async function walk(directory) {
|
|
173
|
+
let stream;
|
|
174
|
+
try {
|
|
175
|
+
stream = await opendir(directory);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (!skippableTraversalError(error)) throw workspaceScanError(error);
|
|
178
|
+
skippedPath(skipped, error.path || directory, error, "opendir");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const entries = [];
|
|
182
|
+
try {
|
|
183
|
+
for await (const entry of stream) entries.push(entry);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (!skippableTraversalError(error)) throw workspaceScanError(error);
|
|
186
|
+
skippedPath(skipped, directory, error, "readdir");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
if (EXCLUDED_NAMES.has(entry.name)) continue;
|
|
192
|
+
const path = join(directory, entry.name);
|
|
193
|
+
let metadata;
|
|
194
|
+
try {
|
|
195
|
+
metadata = await lstat(path);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (!skippableTraversalError(error)) throw workspaceScanError(error);
|
|
198
|
+
skippedPath(skipped, error.path || path, error, "lstat");
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (metadata.isSymbolicLink()) {
|
|
202
|
+
try {
|
|
203
|
+
await realpath(path);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (!skippableTraversalError(error)) throw workspaceScanError(error);
|
|
206
|
+
skippedPath(skipped, error.path || path, error, "realpath");
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`workspace fingerprint rejects symbolic link: ${relative(root, path)}`);
|
|
210
|
+
}
|
|
211
|
+
if (metadata.isDirectory()) await walk(path);
|
|
212
|
+
else if (metadata.isFile()) {
|
|
213
|
+
files.push({ path, relativePath: relative(root, path).split(sep).join("/"), size: metadata.size });
|
|
214
|
+
totalBytes += metadata.size;
|
|
215
|
+
if (files.length > MAX_WORKSPACE_FILES || totalBytes > MAX_WORKSPACE_BYTES) {
|
|
216
|
+
throw new Error("workspace exceeds the self-starter fingerprint limit");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
await walk(root);
|
|
222
|
+
return { files, skipped: skipped.sort((a, b) => a.path.localeCompare(b.path) || a.operation.localeCompare(b.operation)) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function workspaceFingerprint(inputRoot = process.cwd()) {
|
|
226
|
+
const root = resolve(inputRoot);
|
|
227
|
+
const collected = await collectWorkspaceFiles(root);
|
|
228
|
+
const files = [];
|
|
229
|
+
const skipped = [...collected.skipped];
|
|
230
|
+
const hash = createHash("sha256");
|
|
231
|
+
for (const file of collected.files) {
|
|
232
|
+
let content;
|
|
233
|
+
try {
|
|
234
|
+
content = await readFile(file.path);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (!skippableTraversalError(error)) throw workspaceScanError(error);
|
|
237
|
+
skippedPath(skipped, error.path || file.path, error, "readFile");
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
files.push(file);
|
|
241
|
+
hash.update(file.relativePath).update("\0").update(String(file.size)).update("\0");
|
|
242
|
+
hash.update(content).update("\0");
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
digest: hash.digest("hex"), files: files.length, bytes: files.reduce((sum, file) => sum + file.size, 0),
|
|
246
|
+
skipped: skipped.sort((a, b) => a.path.localeCompare(b.path) || a.operation.localeCompare(b.operation))
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function incompleteWorkspaceScan(skipped) {
|
|
251
|
+
const first = skipped[0];
|
|
252
|
+
const error = new Error(`workspace scan skipped ${skipped.length} inaccessible path${skipped.length === 1 ? "" : "s"}: ${first.path}`);
|
|
253
|
+
error.code = "AGENTSPINE_SCAN_INCOMPLETE";
|
|
254
|
+
error.path = first.path;
|
|
255
|
+
error.syscall = first.operation;
|
|
256
|
+
error.skipped = skipped;
|
|
257
|
+
return error;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function knownActor(graph, id) {
|
|
261
|
+
return graph.entities.find((entity) => entity.id === id && ["person", "agent"].includes(entity.kind));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function currentTask(coordination, id) {
|
|
265
|
+
return coordination.tasks.find((task) => task.id === id);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function referenceState(catalog) {
|
|
269
|
+
const [{ graph }, { coordination }] = await Promise.all([
|
|
270
|
+
loadGraph(catalog.root, catalog), loadCoordination(catalog.root, catalog)
|
|
271
|
+
]);
|
|
272
|
+
return { graph, coordination };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function validateGrantReferences(grant, graph, coordination) {
|
|
276
|
+
const task = currentTask(coordination, grant.taskId);
|
|
277
|
+
return Boolean(knownActor(graph, grant.actorId) && knownActor(graph, grant.targetId) && task
|
|
278
|
+
&& task.assigneeId === grant.actorId && task.projectId === grant.projectId && task.groupId === grant.groupId
|
|
279
|
+
&& ["open", "in-progress"].includes(task.status));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function executionPolicyFindingsInternal(policy, graph, coordination, { checkCurrentReferences = true } = {}) {
|
|
283
|
+
const findings = [];
|
|
284
|
+
if (policy.schema !== POLICY_SCHEMA || !Number.isInteger(policy.revision) || policy.revision < 0) findings.push("invalid-execution-policy");
|
|
285
|
+
if (new Set(policy.grants.map((grant) => grant.id)).size !== policy.grants.length) findings.push("duplicate-execution-grant-id");
|
|
286
|
+
const records = [
|
|
287
|
+
...policy.grants.map((grant) => ({ grant, current: true })),
|
|
288
|
+
...policy.history.map((entry) => entry.value).filter(Boolean).map((grant) => ({ grant, current: false }))
|
|
289
|
+
];
|
|
290
|
+
for (const { grant, current } of records) {
|
|
291
|
+
const valid = ID_RE.test(grant.id || "") && ID_RE.test(grant.jobId || "") && ID_RE.test(grant.taskId || "")
|
|
292
|
+
&& ID_RE.test(grant.actorId || "") && ID_RE.test(grant.targetId || "") && ID_RE.test(grant.projectId || "")
|
|
293
|
+
&& (grant.groupId === null || ID_RE.test(grant.groupId || "")) && HOSTS.has(grant.host)
|
|
294
|
+
&& JSON.stringify(grant.actions) === JSON.stringify(["effect", "resume", "start"])
|
|
295
|
+
&& Array.isArray(grant.capabilities) && grant.capabilities.length > 0
|
|
296
|
+
&& JSON.stringify(grant.capabilities) === JSON.stringify([...new Set(grant.capabilities)].sort())
|
|
297
|
+
&& grant.capabilities.every((item) => CAPABILITY_RE.test(item) && !item.includes("*"))
|
|
298
|
+
&& typeof grant.active === "boolean" && typeof grant.reason === "string" && !SECRET_RE.test(grant.reason)
|
|
299
|
+
&& Number.isInteger(grant.revision) && grant.revision > 0 && grant.revision <= policy.revision
|
|
300
|
+
&& grant.source === "explicit-local-owner-policy" && grant.authority === "explicit-local-execution-policy"
|
|
301
|
+
&& typeof grant.createdAt === "string" && typeof grant.updatedAt === "string"
|
|
302
|
+
&& (grant.expiresAt === null || Number.isFinite(new Date(grant.expiresAt).getTime()));
|
|
303
|
+
if (!valid) findings.push(`invalid-execution-grant:${grant.id || "unknown"}`);
|
|
304
|
+
if (checkCurrentReferences && current && grant.active && !validateGrantReferences(grant, graph, coordination)) {
|
|
305
|
+
findings.push(`stale-execution-grant:${grant.id || "unknown"}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (policy.history.some((entry) => entry.kind !== "execution-grant" || !ID_RE.test(entry.recordId || "")
|
|
309
|
+
|| !Number.isFinite(new Date(entry.supersededAt).getTime())
|
|
310
|
+
|| entry.authority !== "explicit-local-execution-policy" || !entry.value)) findings.push("invalid-execution-policy-history");
|
|
311
|
+
return findings;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function receiptDigest(value) {
|
|
315
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function validLease(value) {
|
|
319
|
+
return value === null || (value && ID_RE.test(value.sessionId || "") && HOSTS.has(value.host)
|
|
320
|
+
&& Number.isFinite(new Date(value.acquiredAt).getTime()) && Number.isFinite(new Date(value.expiresAt).getTime())
|
|
321
|
+
&& new Date(value.expiresAt) > new Date(value.acquiredAt) && DIGEST_RE.test(value.workspaceDigest || ""));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function validPending(value) {
|
|
325
|
+
return value === null || (value && ID_RE.test(value.toolUseId || "") && CAPABILITY_RE.test(value.capability || "")
|
|
326
|
+
&& DIGEST_RE.test(value.beforeDigest || "") && Number.isFinite(new Date(value.authorizedAt).getTime())
|
|
327
|
+
&& ID_RE.test(value.sessionId || ""));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function validBlocker(value) {
|
|
331
|
+
return value === null || (value && typeof value.code === "string" && value.code.length > 0 && value.code.length <= 128
|
|
332
|
+
&& Number.isFinite(new Date(value.at).getTime()) && value.authority === "execution-state-only");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function validJobRecord(job) {
|
|
336
|
+
return job && ID_RE.test(job.id || "") && ID_RE.test(job.taskId || "") && ID_RE.test(job.actorId || "")
|
|
337
|
+
&& ID_RE.test(job.targetId || "") && ID_RE.test(job.projectId || "")
|
|
338
|
+
&& (job.groupId === null || ID_RE.test(job.groupId || "")) && HOSTS.has(job.host) && JOB_STATUSES.has(job.status)
|
|
339
|
+
&& Array.isArray(job.capabilities) && job.capabilities.length > 0
|
|
340
|
+
&& JSON.stringify(job.capabilities) === JSON.stringify([...new Set(job.capabilities)].sort())
|
|
341
|
+
&& job.capabilities.every((item) => CAPABILITY_RE.test(item) && !item.includes("*"))
|
|
342
|
+
&& ID_RE.test(job.grantId || "") && DIGEST_RE.test(job.checkpoint?.workspaceDigest || "")
|
|
343
|
+
&& Number.isInteger(job.checkpoint?.sequence) && job.checkpoint.sequence >= 0
|
|
344
|
+
&& Number.isInteger(job.checkpoint?.workspaceFiles) && job.checkpoint.workspaceFiles >= 0
|
|
345
|
+
&& Number.isInteger(job.checkpoint?.workspaceBytes) && job.checkpoint.workspaceBytes >= 0
|
|
346
|
+
&& (job.checkpoint?.lastToolUseId === null || ID_RE.test(job.checkpoint?.lastToolUseId || ""))
|
|
347
|
+
&& (job.checkpoint?.resultDigest === null || DIGEST_RE.test(job.checkpoint?.resultDigest || ""))
|
|
348
|
+
&& Number.isInteger(job.checkpoint?.retryCount) && job.checkpoint.retryCount >= 0
|
|
349
|
+
&& (job.checkpoint?.nextRetryAt === null || Number.isFinite(new Date(job.checkpoint?.nextRetryAt).getTime()))
|
|
350
|
+
&& Number.isFinite(new Date(job.checkpoint?.updatedAt).getTime())
|
|
351
|
+
&& Number.isInteger(job.maxRetries) && job.maxRetries >= 0 && job.maxRetries <= 10
|
|
352
|
+
&& Number.isInteger(job.leaseSeconds) && job.leaseSeconds >= 15 && job.leaseSeconds <= 900
|
|
353
|
+
&& Number.isInteger(job.baseRetrySeconds) && job.baseRetrySeconds >= 1 && job.baseRetrySeconds <= 3600
|
|
354
|
+
&& validLease(job.lease) && validPending(job.pendingEffect) && validBlocker(job.lastBlocker)
|
|
355
|
+
&& job.authority === "execution-state-only" && Number.isFinite(new Date(job.createdAt).getTime())
|
|
356
|
+
&& Number.isFinite(new Date(job.updatedAt).getTime());
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function validReceipt(receipt) {
|
|
360
|
+
if (!receipt || !ID_RE.test(receipt.id || "") || !ID_RE.test(receipt.jobId || "") || typeof receipt.event !== "string"
|
|
361
|
+
|| !Number.isFinite(new Date(receipt.at).getTime()) || !receipt.details || typeof receipt.details !== "object"
|
|
362
|
+
|| Array.isArray(receipt.details) || !DIGEST_RE.test(receipt.digest || "") || receipt.authority !== "execution-state-only") return false;
|
|
363
|
+
const { digest, ...material } = receipt;
|
|
364
|
+
return receiptDigest(material) === digest;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function selfstarterFindingsInternal(state, policy, graph, coordination) {
|
|
368
|
+
const findings = [];
|
|
369
|
+
const grants = new Map(policy.grants.map((grant) => [grant.id, grant]));
|
|
370
|
+
if (state.schema !== JOB_SCHEMA) findings.push("invalid-selfstarter-state");
|
|
371
|
+
if (new Set(state.jobs.map((job) => job.id)).size !== state.jobs.length) findings.push("duplicate-job-id");
|
|
372
|
+
for (const job of state.jobs) {
|
|
373
|
+
const grant = grants.get(job.grantId);
|
|
374
|
+
const task = currentTask(coordination, job.taskId);
|
|
375
|
+
if (!validJobRecord(job)) findings.push(`invalid-job:${job.id || "unknown"}`);
|
|
376
|
+
if (!grant || grant.jobId !== job.id || grant.taskId !== job.taskId || grant.actorId !== job.actorId
|
|
377
|
+
|| grant.targetId !== job.targetId || grant.projectId !== job.projectId || grant.groupId !== job.groupId
|
|
378
|
+
|| grant.host !== job.host || JSON.stringify(grant.capabilities) !== JSON.stringify(job.capabilities)) findings.push(`job-grant-mismatch:${job.id || "unknown"}`);
|
|
379
|
+
if (!task || task.assigneeId !== job.actorId || task.projectId !== job.projectId || task.groupId !== job.groupId) findings.push(`job-task-mismatch:${job.id || "unknown"}`);
|
|
380
|
+
}
|
|
381
|
+
if (state.history.some((entry) => entry.kind !== "selfstarter-job" || !ID_RE.test(entry.recordId || "")
|
|
382
|
+
|| typeof entry.event !== "string" || !Number.isFinite(new Date(entry.supersededAt).getTime())
|
|
383
|
+
|| entry.authority !== "execution-state-only" || !validJobRecord(entry.value))) findings.push("invalid-job-history");
|
|
384
|
+
if (state.receipts.some((receipt) => !validReceipt(receipt))) findings.push("invalid-job-receipt");
|
|
385
|
+
return findings;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function executionPolicyFindings(policy, graph, coordination) {
|
|
389
|
+
return executionPolicyFindingsInternal(policy, graph, coordination);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function selfstarterFindings(state, policy, graph, coordination) {
|
|
393
|
+
return selfstarterFindingsInternal(state, policy, graph, coordination);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export async function loadExecutionPolicy(root = process.cwd(), providedCatalog = null) {
|
|
397
|
+
const paths = await pathsFor(root, providedCatalog);
|
|
398
|
+
return { policy: await readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy), ...paths };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function loadSelfstarter(root = process.cwd(), providedCatalog = null) {
|
|
402
|
+
const paths = await pathsFor(root, providedCatalog);
|
|
403
|
+
return { state: await readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs), ...paths };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function resolveSessionJob({
|
|
407
|
+
root = process.cwd(), actorId = null, projectId = null, groupId = null, taskId = null,
|
|
408
|
+
host, sessionId, action = "effect", now = new Date()
|
|
409
|
+
}) {
|
|
410
|
+
if (!ACTIONS.has(action)) throw new Error("session job resolution requires start, resume, or effect");
|
|
411
|
+
if (!HOSTS.has(host)) throw new Error("self-starter host must be claude or codex");
|
|
412
|
+
const session = stableId(sessionId, "sessionId");
|
|
413
|
+
const at = timestamp(now, "now");
|
|
414
|
+
const paths = await pathsFor(root);
|
|
415
|
+
const [policy, state] = await Promise.all([
|
|
416
|
+
readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
417
|
+
readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs)
|
|
418
|
+
]);
|
|
419
|
+
const { graph, coordination } = await referenceState(paths.catalog);
|
|
420
|
+
const findings = [
|
|
421
|
+
...executionPolicyFindingsInternal(policy, graph, coordination, { checkCurrentReferences: false }),
|
|
422
|
+
...selfstarterFindingsInternal(state, policy, graph, coordination)
|
|
423
|
+
];
|
|
424
|
+
if (findings.length) throw new Error(`self-starter failed closed: ${findings.join(", ")}`);
|
|
425
|
+
const candidates = state.jobs.filter((job) => job.lease?.sessionId === session);
|
|
426
|
+
if (!candidates.length) return null;
|
|
427
|
+
if (candidates.length !== 1) throw new Error("host session has multiple self-starter leases");
|
|
428
|
+
const job = candidates[0];
|
|
429
|
+
if (leaseExpired(job, at)) throw new Error(`job ${job.id} lease expired`);
|
|
430
|
+
for (const [value, expected, label] of [
|
|
431
|
+
[actorId, job.actorId, "actor"], [projectId, job.projectId, "project"],
|
|
432
|
+
[taskId, job.taskId, "task"], [groupId, job.groupId, "group"], [host, job.host, "host"]
|
|
433
|
+
]) {
|
|
434
|
+
if (value !== null && value !== undefined && value !== expected) throw new Error(`self-starter ${label} scope mismatch for ${job.id}`);
|
|
435
|
+
}
|
|
436
|
+
exactGrant(policy, job, action, at);
|
|
437
|
+
if (!currentTaskValid(job, coordination)) throw new Error("self-starter task or assignment changed");
|
|
438
|
+
return {
|
|
439
|
+
jobId: job.id, actorId: job.actorId, projectId: job.projectId, groupId: job.groupId,
|
|
440
|
+
taskId: job.taskId, host: job.host, sessionId: session
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function inspectSelfstarter(root = process.cwd(), providedCatalog = null) {
|
|
445
|
+
const paths = await pathsFor(root, providedCatalog);
|
|
446
|
+
let policy = emptyPolicy(paths.catalog.root);
|
|
447
|
+
let state = emptyJobs(paths.catalog.root);
|
|
448
|
+
const errors = [];
|
|
449
|
+
try { policy = await readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy); } catch (error) { errors.push(`policy:${error.message}`); }
|
|
450
|
+
try { state = await readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs); } catch (error) { errors.push(`state:${error.message}`); }
|
|
451
|
+
return { policy, state, errors, ...paths };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function requireOwnerConfirmation(confirmation) {
|
|
455
|
+
if (confirmation !== CONFIRMATION) throw new Error("self-starter policy changes require explicit local owner confirmation");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function preservePolicy(policy, grant, at) {
|
|
459
|
+
policy.history.push({ kind: "execution-grant", recordId: grant.id, supersededAt: at, value: { ...grant }, authority: "explicit-local-execution-policy" });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function preserveJob(state, job, event, at) {
|
|
463
|
+
state.history.push({ kind: "selfstarter-job", event, recordId: job.id, supersededAt: at, value: structuredClone(job), authority: "execution-state-only" });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function appendReceipt(state, job, id, event, at, details = {}) {
|
|
467
|
+
const existing = state.receipts.find((receipt) => receipt.id === id);
|
|
468
|
+
const material = { id, jobId: job.id, event, at, details, authority: "execution-state-only" };
|
|
469
|
+
const digest = receiptDigest(material);
|
|
470
|
+
if (existing) {
|
|
471
|
+
if (existing.digest !== digest) throw new Error("self-starter receipt collision; execution stopped");
|
|
472
|
+
return { receipt: existing, duplicate: true };
|
|
473
|
+
}
|
|
474
|
+
const receipt = { ...material, digest };
|
|
475
|
+
state.receipts.push(receipt);
|
|
476
|
+
return { receipt, duplicate: false };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function grantExecution({
|
|
480
|
+
root = process.cwd(), id = `execution-grant:${randomUUID()}`, actorId, actions = ["start", "resume", "effect"],
|
|
481
|
+
jobId, taskId, targetId, projectId, groupId = null, host, capabilities: requestedCapabilities,
|
|
482
|
+
reason, expiresAt = null, confirmation, now = new Date()
|
|
483
|
+
}) {
|
|
484
|
+
requireOwnerConfirmation(confirmation);
|
|
485
|
+
const grant = {
|
|
486
|
+
id: stableId(id, "grantId"), actorId: stableId(actorId, "actorId"),
|
|
487
|
+
actions: [...new Set(actions)].sort(), jobId: stableId(jobId, "jobId"), taskId: stableId(taskId, "taskId"),
|
|
488
|
+
targetId: stableId(targetId, "targetId"), projectId: stableId(projectId, "projectId"),
|
|
489
|
+
groupId: stableId(groupId, "groupId", true), host, capabilities: capabilities(requestedCapabilities),
|
|
490
|
+
reason: safeText(reason, "reason", 500), active: true, expiresAt: timestamp(expiresAt, "expiresAt", true)
|
|
491
|
+
};
|
|
492
|
+
if (grant.actions.length !== 3 || grant.actions.some((action) => !ACTIONS.has(action))) throw new Error("a self-starter grant must explicitly include start, resume, and effect");
|
|
493
|
+
if (!HOSTS.has(host)) throw new Error("host must be claude or codex");
|
|
494
|
+
const at = timestamp(now, "now");
|
|
495
|
+
if (grant.expiresAt && new Date(grant.expiresAt) <= new Date(at)) throw new Error("execution grant expiry must be in the future");
|
|
496
|
+
const { catalog, executionPolicyPath } = await pathsFor(root);
|
|
497
|
+
const { graph, coordination } = await referenceState(catalog);
|
|
498
|
+
if (!validateGrantReferences(grant, graph, coordination)) throw new Error("execution grant must match a current assigned task and its exact scope");
|
|
499
|
+
return withLock(executionPolicyPath, () => readJson(executionPolicyPath, catalog.root, normalizePolicy, emptyPolicy), (policy) => {
|
|
500
|
+
const findings = executionPolicyFindingsInternal(policy, graph, coordination, { checkCurrentReferences: false });
|
|
501
|
+
if (findings.length) throw new Error(`execution policy failed closed: ${findings.join(", ")}`);
|
|
502
|
+
if (policy.grants.some((item) => item.id === grant.id || item.jobId === grant.jobId)) throw new Error("execution grant and job IDs are immutable and unique");
|
|
503
|
+
policy.revision += 1;
|
|
504
|
+
Object.assign(grant, {
|
|
505
|
+
revision: policy.revision, createdAt: at, updatedAt: at,
|
|
506
|
+
source: "explicit-local-owner-policy", authority: "explicit-local-execution-policy"
|
|
507
|
+
});
|
|
508
|
+
policy.grants.push(grant);
|
|
509
|
+
policy.grants.sort((a, b) => a.id.localeCompare(b.id));
|
|
510
|
+
return { grant, policyRevision: policy.revision, executionPolicyPath };
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export async function revokeExecution({ root = process.cwd(), id, reason, confirmation, now = new Date() }) {
|
|
515
|
+
requireOwnerConfirmation(confirmation);
|
|
516
|
+
stableId(id, "grantId");
|
|
517
|
+
const at = timestamp(now, "now");
|
|
518
|
+
const revokeReason = safeText(reason, "reason", 500);
|
|
519
|
+
const { catalog, executionPolicyPath } = await pathsFor(root);
|
|
520
|
+
const { graph, coordination } = await referenceState(catalog);
|
|
521
|
+
return withLock(executionPolicyPath, () => readJson(executionPolicyPath, catalog.root, normalizePolicy, emptyPolicy), (policy) => {
|
|
522
|
+
const findings = executionPolicyFindingsInternal(policy, graph, coordination);
|
|
523
|
+
if (findings.length) throw new Error(`execution policy failed closed: ${findings.join(", ")}`);
|
|
524
|
+
const previous = policy.grants.find((grant) => grant.id === id);
|
|
525
|
+
if (!previous || !previous.active) throw new Error("execution grant is unknown or already revoked");
|
|
526
|
+
preservePolicy(policy, previous, at);
|
|
527
|
+
policy.revision += 1;
|
|
528
|
+
const grant = { ...previous, active: false, revokedAt: at, revokeReason, revision: policy.revision, updatedAt: at };
|
|
529
|
+
policy.grants = policy.grants.map((item) => item.id === id ? grant : item);
|
|
530
|
+
return { grant, policyRevision: policy.revision, executionPolicyPath };
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function exactGrant(policy, job, action, at) {
|
|
535
|
+
const grant = policy.grants.find((item) => item.id === job.grantId);
|
|
536
|
+
if (!grant || !grant.active || !grant.actions.includes(action)) throw new Error(`default deny: no current exact ${action} grant for ${job.id}`);
|
|
537
|
+
if (grant.expiresAt && new Date(grant.expiresAt) <= new Date(at)) throw new Error(`execution grant expired for ${job.id}`);
|
|
538
|
+
const matches = grant.actorId === job.actorId && grant.jobId === job.id && grant.taskId === job.taskId
|
|
539
|
+
&& grant.targetId === job.targetId && grant.projectId === job.projectId && grant.groupId === job.groupId
|
|
540
|
+
&& grant.host === job.host && JSON.stringify(grant.capabilities) === JSON.stringify(job.capabilities);
|
|
541
|
+
if (!matches) throw new Error(`execution grant scope mismatch for ${job.id}`);
|
|
542
|
+
return grant;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export async function registerJob({
|
|
546
|
+
root = process.cwd(), id, grantId, maxRetries = 3, leaseSeconds = 120,
|
|
547
|
+
baseRetrySeconds = 5, confirmation, now = new Date()
|
|
548
|
+
}) {
|
|
549
|
+
requireOwnerConfirmation(confirmation);
|
|
550
|
+
const jobId = stableId(id, "jobId");
|
|
551
|
+
const at = timestamp(now, "now");
|
|
552
|
+
const paths = await pathsFor(root);
|
|
553
|
+
const fingerprint = await workspaceFingerprint(paths.catalog.root);
|
|
554
|
+
const { graph, coordination } = await referenceState(paths.catalog);
|
|
555
|
+
return withLock(paths.executionPolicyPath, () => readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy), (policy) => withLock(
|
|
556
|
+
paths.selfstarterPath,
|
|
557
|
+
() => readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs),
|
|
558
|
+
(state) => {
|
|
559
|
+
const policyIssues = executionPolicyFindingsInternal(policy, graph, coordination);
|
|
560
|
+
const stateIssues = selfstarterFindingsInternal(state, policy, graph, coordination);
|
|
561
|
+
if (policyIssues.length || stateIssues.length) throw new Error(`self-starter registration failed closed: ${[...policyIssues, ...stateIssues].join(", ")}`);
|
|
562
|
+
const grant = policy.grants.find((item) => item.id === grantId && item.jobId === jobId);
|
|
563
|
+
if (!grant) throw new Error("registration requires the exact current execution grant");
|
|
564
|
+
if (state.jobs.some((job) => job.id === jobId)) throw new Error("job IDs are immutable");
|
|
565
|
+
const template = {
|
|
566
|
+
id: jobId, taskId: grant.taskId, actorId: grant.actorId, targetId: grant.targetId,
|
|
567
|
+
projectId: grant.projectId, groupId: grant.groupId, host: grant.host,
|
|
568
|
+
capabilities: [...grant.capabilities], grantId: grant.id
|
|
569
|
+
};
|
|
570
|
+
exactGrant(policy, template, "start", at);
|
|
571
|
+
if (!validateGrantReferences(grant, graph, coordination)) throw new Error("execution grant no longer matches the current task");
|
|
572
|
+
const job = {
|
|
573
|
+
...template, status: "waiting",
|
|
574
|
+
checkpoint: {
|
|
575
|
+
sequence: 0, workspaceDigest: fingerprint.digest, workspaceFiles: fingerprint.files,
|
|
576
|
+
workspaceBytes: fingerprint.bytes, lastToolUseId: null, resultDigest: null,
|
|
577
|
+
retryCount: 0, nextRetryAt: null, updatedAt: at
|
|
578
|
+
},
|
|
579
|
+
lease: null, pendingEffect: null,
|
|
580
|
+
maxRetries: integer(maxRetries, "maxRetries", 0, 10),
|
|
581
|
+
leaseSeconds: integer(leaseSeconds, "leaseSeconds", 15, 900),
|
|
582
|
+
baseRetrySeconds: integer(baseRetrySeconds, "baseRetrySeconds", 1, 3600),
|
|
583
|
+
lastBlocker: null, createdAt: at, updatedAt: at, authority: "execution-state-only"
|
|
584
|
+
};
|
|
585
|
+
state.jobs.push(job);
|
|
586
|
+
state.jobs.sort((a, b) => a.id.localeCompare(b.id));
|
|
587
|
+
appendReceipt(state, job, `receipt:registered:${receiptDigest({ id: job.id, at }).slice(0, 20)}`, "registered", at, { checkpoint: job.checkpoint.workspaceDigest });
|
|
588
|
+
return { job, selfstarterPath: paths.selfstarterPath, executionPolicyPath: paths.executionPolicyPath };
|
|
589
|
+
}
|
|
590
|
+
), false);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function ensureScope(job, scope) {
|
|
594
|
+
const matches = job.actorId === scope.actorId && job.projectId === scope.projectId
|
|
595
|
+
&& job.groupId === (scope.groupId ?? null) && job.host === scope.host
|
|
596
|
+
&& (!scope.taskId || job.taskId === scope.taskId) && (!scope.jobId || job.id === scope.jobId);
|
|
597
|
+
if (!matches) throw new Error(`self-starter scope mismatch for ${job.id}`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function currentTaskValid(job, coordination) {
|
|
601
|
+
const task = currentTask(coordination, job.taskId);
|
|
602
|
+
return task && task.assigneeId === job.actorId && task.projectId === job.projectId && task.groupId === job.groupId
|
|
603
|
+
&& ["open", "in-progress"].includes(task.status);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function leaseExpired(job, at) {
|
|
607
|
+
return job.lease && new Date(job.lease.expiresAt) <= new Date(at);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function blocker(job, code, at) {
|
|
611
|
+
job.status = code === "retry-budget-exhausted" ? "exhausted" : "blocked";
|
|
612
|
+
job.lastBlocker = { code, at, authority: "execution-state-only" };
|
|
613
|
+
job.lease = null;
|
|
614
|
+
job.updatedAt = at;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function lockedStates(root, operation) {
|
|
618
|
+
const paths = await pathsFor(root);
|
|
619
|
+
const { graph, coordination } = await referenceState(paths.catalog);
|
|
620
|
+
return withLock(paths.executionPolicyPath, () => readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy), (policy) => withLock(
|
|
621
|
+
paths.selfstarterPath,
|
|
622
|
+
() => readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs),
|
|
623
|
+
(state) => {
|
|
624
|
+
const findings = [
|
|
625
|
+
...executionPolicyFindingsInternal(policy, graph, coordination, { checkCurrentReferences: false }),
|
|
626
|
+
...selfstarterFindingsInternal(state, policy, graph, coordination)
|
|
627
|
+
];
|
|
628
|
+
if (findings.length) throw new Error(`self-starter failed closed: ${findings.join(", ")}`);
|
|
629
|
+
return operation({ paths, policy, state, graph, coordination });
|
|
630
|
+
}
|
|
631
|
+
), false);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export async function startOrResumeJob({ root = process.cwd(), actorId, projectId, groupId = null, taskId = null, jobId = null, host, sessionId, now = new Date() }) {
|
|
635
|
+
const scope = {
|
|
636
|
+
actorId: stableId(actorId, "actorId"), projectId: stableId(projectId, "projectId"),
|
|
637
|
+
groupId: stableId(groupId, "groupId", true), taskId: stableId(taskId, "taskId", true),
|
|
638
|
+
jobId: stableId(jobId, "jobId", true), host, sessionId: stableId(sessionId, "sessionId")
|
|
639
|
+
};
|
|
640
|
+
if (!HOSTS.has(host)) throw new Error("self-starter host must be claude or codex");
|
|
641
|
+
const at = timestamp(now, "now");
|
|
642
|
+
const fingerprint = await workspaceFingerprint(root);
|
|
643
|
+
return lockedStates(root, ({ policy, state, coordination, paths }) => {
|
|
644
|
+
const candidates = state.jobs.filter((job) => !["completed", "cancelled", "exhausted"].includes(job.status)
|
|
645
|
+
&& job.actorId === scope.actorId && job.projectId === scope.projectId && job.groupId === scope.groupId
|
|
646
|
+
&& job.host === host && (!scope.taskId || job.taskId === scope.taskId) && (!scope.jobId || job.id === scope.jobId));
|
|
647
|
+
if (!candidates.length) return { job: null, reason: "no-exact-waiting-job", selfstarterPath: paths.selfstarterPath };
|
|
648
|
+
if (candidates.length !== 1) throw new Error("multiple eligible self-starter jobs require an exact job or task scope");
|
|
649
|
+
const job = candidates[0];
|
|
650
|
+
ensureScope(job, scope);
|
|
651
|
+
if (!currentTaskValid(job, coordination)) {
|
|
652
|
+
preserveJob(state, job, "task-invalid", at);
|
|
653
|
+
blocker(job, "task-or-assignment-changed", at);
|
|
654
|
+
appendReceipt(state, job, `receipt:blocked:${receiptDigest({ id: job.id, at, code: job.lastBlocker.code }).slice(0, 20)}`, "blocked", at, { code: job.lastBlocker.code });
|
|
655
|
+
return { job: null, blocked: true, reason: job.lastBlocker.code, selfstarterPath: paths.selfstarterPath };
|
|
656
|
+
}
|
|
657
|
+
if (job.lease && !leaseExpired(job, at) && job.lease.sessionId !== scope.sessionId) {
|
|
658
|
+
throw new Error(`job ${job.id} has an active lease`);
|
|
659
|
+
}
|
|
660
|
+
const hasPriorRun = Boolean(job.lease) || state.receipts.some((receipt) => receipt.jobId === job.id && ["start", "resume"].includes(receipt.event));
|
|
661
|
+
const action = job.checkpoint.sequence === 0 && !hasPriorRun ? "start" : "resume";
|
|
662
|
+
exactGrant(policy, job, action, at);
|
|
663
|
+
if (job.checkpoint.nextRetryAt && new Date(job.checkpoint.nextRetryAt) > new Date(at)) {
|
|
664
|
+
return { job: null, blocked: true, reason: "retry-backoff-active", nextRetryAt: job.checkpoint.nextRetryAt, selfstarterPath: paths.selfstarterPath };
|
|
665
|
+
}
|
|
666
|
+
if (job.status === "blocked" && job.lastBlocker?.code === "host-reported-blocker") {
|
|
667
|
+
return { job: null, blocked: true, reason: "host-reported-blocker", selfstarterPath: paths.selfstarterPath };
|
|
668
|
+
}
|
|
669
|
+
if (job.pendingEffect && leaseExpired(job, at)) {
|
|
670
|
+
if (fingerprint.digest !== job.pendingEffect.beforeDigest) {
|
|
671
|
+
preserveJob(state, job, "crash-workspace-unknown", at);
|
|
672
|
+
blocker(job, "workspace-changed-after-uncheckpointed-effect", at);
|
|
673
|
+
appendReceipt(state, job, `receipt:blocked:${receiptDigest({ id: job.id, at, code: job.lastBlocker.code }).slice(0, 20)}`, "blocked", at, { code: job.lastBlocker.code });
|
|
674
|
+
return { job: null, blocked: true, reason: job.lastBlocker.code, selfstarterPath: paths.selfstarterPath };
|
|
675
|
+
}
|
|
676
|
+
preserveJob(state, job, "crash-recovered", at);
|
|
677
|
+
job.pendingEffect = null;
|
|
678
|
+
job.checkpoint.retryCount += 1;
|
|
679
|
+
if (job.checkpoint.retryCount > job.maxRetries) {
|
|
680
|
+
blocker(job, "retry-budget-exhausted", at);
|
|
681
|
+
return { job: null, blocked: true, reason: job.lastBlocker.code, selfstarterPath: paths.selfstarterPath };
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (fingerprint.digest !== job.checkpoint.workspaceDigest) {
|
|
685
|
+
preserveJob(state, job, "workspace-drift", at);
|
|
686
|
+
blocker(job, "workspace-changed-outside-checkpoint", at);
|
|
687
|
+
appendReceipt(state, job, `receipt:blocked:${receiptDigest({ id: job.id, at, code: job.lastBlocker.code }).slice(0, 20)}`, "blocked", at, { code: job.lastBlocker.code });
|
|
688
|
+
return { job: null, blocked: true, reason: job.lastBlocker.code, selfstarterPath: paths.selfstarterPath };
|
|
689
|
+
}
|
|
690
|
+
const receiptId = `receipt:${action}:${receiptDigest({ job: job.id, session: scope.sessionId }).slice(0, 20)}`;
|
|
691
|
+
const existing = state.receipts.find((receipt) => receipt.id === receiptId);
|
|
692
|
+
if (existing && job.lease?.sessionId === scope.sessionId) return { job, action, duplicate: true, receipt: existing, selfstarterPath: paths.selfstarterPath };
|
|
693
|
+
if (existing) return {
|
|
694
|
+
job: null, action, duplicate: true, blocked: true, reason: "session-lifecycle-already-closed",
|
|
695
|
+
receipt: existing, selfstarterPath: paths.selfstarterPath
|
|
696
|
+
};
|
|
697
|
+
preserveJob(state, job, action, at);
|
|
698
|
+
job.status = "running";
|
|
699
|
+
job.lastBlocker = null;
|
|
700
|
+
job.lease = {
|
|
701
|
+
sessionId: scope.sessionId, host, acquiredAt: at,
|
|
702
|
+
expiresAt: new Date(new Date(at).getTime() + job.leaseSeconds * 1000).toISOString(),
|
|
703
|
+
workspaceDigest: fingerprint.digest
|
|
704
|
+
};
|
|
705
|
+
job.updatedAt = at;
|
|
706
|
+
const { receipt } = appendReceipt(state, job, receiptId, action, at, { sessionId: scope.sessionId, workspaceDigest: fingerprint.digest });
|
|
707
|
+
return { job, action, duplicate: false, receipt, selfstarterPath: paths.selfstarterPath, authority: "explicit-local-execution-policy" };
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export async function authorizeJobEffect({
|
|
712
|
+
root = process.cwd(), jobId, actorId, projectId, groupId = null, taskId,
|
|
713
|
+
host, sessionId, toolName, toolUseId, now = new Date()
|
|
714
|
+
}) {
|
|
715
|
+
const scope = {
|
|
716
|
+
jobId: stableId(jobId, "jobId"), actorId: stableId(actorId, "actorId"),
|
|
717
|
+
projectId: stableId(projectId, "projectId"), groupId: stableId(groupId, "groupId", true),
|
|
718
|
+
taskId: stableId(taskId, "taskId"), host, sessionId: stableId(sessionId, "sessionId")
|
|
719
|
+
};
|
|
720
|
+
const capability = `tool:${stableId(toolName, "toolName")}`;
|
|
721
|
+
const deliveryId = stableId(toolUseId, "toolUseId");
|
|
722
|
+
const at = timestamp(now, "now");
|
|
723
|
+
const fingerprint = await workspaceFingerprint(root);
|
|
724
|
+
if (fingerprint.skipped.length) throw incompleteWorkspaceScan(fingerprint.skipped);
|
|
725
|
+
return lockedStates(root, ({ policy, state, coordination, paths }) => {
|
|
726
|
+
const job = state.jobs.find((item) => item.id === scope.jobId);
|
|
727
|
+
if (!job) throw new Error("unknown self-starter job");
|
|
728
|
+
ensureScope(job, scope);
|
|
729
|
+
exactGrant(policy, job, "effect", at);
|
|
730
|
+
if (!currentTaskValid(job, coordination)) throw new Error("self-starter task or assignment changed");
|
|
731
|
+
if (job.status !== "running" || !job.lease || leaseExpired(job, at) || job.lease.sessionId !== scope.sessionId) throw new Error("self-starter effect requires the current live lease");
|
|
732
|
+
if (!job.capabilities.includes(capability)) throw new Error(`default deny: capability ${capability} is not granted for ${job.id}`);
|
|
733
|
+
if (fingerprint.digest !== job.checkpoint.workspaceDigest || fingerprint.digest !== job.lease.workspaceDigest) throw new Error("workspace changed outside the current checkpoint");
|
|
734
|
+
if (job.pendingEffect) {
|
|
735
|
+
if (job.pendingEffect.toolUseId === deliveryId && job.pendingEffect.capability === capability) return { allowed: true, duplicate: true, job, pendingEffect: job.pendingEffect };
|
|
736
|
+
throw new Error("another self-starter effect is already pending");
|
|
737
|
+
}
|
|
738
|
+
preserveJob(state, job, "effect-authorized", at);
|
|
739
|
+
job.pendingEffect = { toolUseId: deliveryId, capability, beforeDigest: fingerprint.digest, authorizedAt: at, sessionId: scope.sessionId };
|
|
740
|
+
job.updatedAt = at;
|
|
741
|
+
const receiptId = `receipt:authorize:${receiptDigest({ job: job.id, toolUseId: deliveryId, capability }).slice(0, 20)}`;
|
|
742
|
+
const { receipt } = appendReceipt(state, job, receiptId, "effect-authorized", at, { toolUseId: deliveryId, capability, beforeDigest: fingerprint.digest });
|
|
743
|
+
return { allowed: true, duplicate: false, job, pendingEffect: job.pendingEffect, receipt, selfstarterPath: paths.selfstarterPath };
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function resultHash(value) {
|
|
748
|
+
let encoded;
|
|
749
|
+
try { encoded = JSON.stringify(value ?? null); } catch { encoded = "unserializable-result"; }
|
|
750
|
+
return createHash("sha256").update(encoded.slice(0, 65536)).digest("hex");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export async function checkpointJobEffect({
|
|
754
|
+
root = process.cwd(), jobId, actorId, projectId, groupId = null, taskId,
|
|
755
|
+
host, sessionId, toolName, toolUseId, success = true, result = null, now = new Date()
|
|
756
|
+
}) {
|
|
757
|
+
const scope = {
|
|
758
|
+
jobId: stableId(jobId, "jobId"), actorId: stableId(actorId, "actorId"),
|
|
759
|
+
projectId: stableId(projectId, "projectId"), groupId: stableId(groupId, "groupId", true),
|
|
760
|
+
taskId: stableId(taskId, "taskId"), host, sessionId: stableId(sessionId, "sessionId")
|
|
761
|
+
};
|
|
762
|
+
const capability = `tool:${stableId(toolName, "toolName")}`;
|
|
763
|
+
const deliveryId = stableId(toolUseId, "toolUseId");
|
|
764
|
+
const at = timestamp(now, "now");
|
|
765
|
+
const fingerprint = await workspaceFingerprint(root);
|
|
766
|
+
return lockedStates(root, ({ policy, state, coordination, paths }) => {
|
|
767
|
+
const job = state.jobs.find((item) => item.id === scope.jobId);
|
|
768
|
+
if (!job) throw new Error("unknown self-starter job");
|
|
769
|
+
ensureScope(job, scope);
|
|
770
|
+
exactGrant(policy, job, "effect", at);
|
|
771
|
+
if (!currentTaskValid(job, coordination)) throw new Error("self-starter task or assignment changed");
|
|
772
|
+
const receiptId = `receipt:checkpoint:${receiptDigest({ job: job.id, toolUseId: deliveryId }).slice(0, 20)}`;
|
|
773
|
+
const existing = state.receipts.find((receipt) => receipt.id === receiptId);
|
|
774
|
+
if (existing && job.checkpoint.lastToolUseId === deliveryId) return { job, duplicate: true, receipt: existing, selfstarterPath: paths.selfstarterPath };
|
|
775
|
+
if (!job.lease || job.lease.sessionId !== scope.sessionId || !job.pendingEffect
|
|
776
|
+
|| job.pendingEffect.toolUseId !== deliveryId || job.pendingEffect.capability !== capability) throw new Error("effect checkpoint does not match the pending authorization");
|
|
777
|
+
preserveJob(state, job, success ? "effect-succeeded" : "effect-failed", at);
|
|
778
|
+
job.checkpoint.sequence += 1;
|
|
779
|
+
job.checkpoint.workspaceDigest = fingerprint.digest;
|
|
780
|
+
job.checkpoint.workspaceFiles = fingerprint.files;
|
|
781
|
+
job.checkpoint.workspaceBytes = fingerprint.bytes;
|
|
782
|
+
job.checkpoint.lastToolUseId = deliveryId;
|
|
783
|
+
job.checkpoint.resultDigest = resultHash(result);
|
|
784
|
+
job.checkpoint.updatedAt = at;
|
|
785
|
+
if (success) {
|
|
786
|
+
job.checkpoint.retryCount = 0;
|
|
787
|
+
job.checkpoint.nextRetryAt = null;
|
|
788
|
+
job.lastBlocker = null;
|
|
789
|
+
job.lease.workspaceDigest = fingerprint.digest;
|
|
790
|
+
job.lease.expiresAt = new Date(new Date(at).getTime() + job.leaseSeconds * 1000).toISOString();
|
|
791
|
+
} else {
|
|
792
|
+
job.checkpoint.retryCount += 1;
|
|
793
|
+
if (job.checkpoint.retryCount > job.maxRetries) blocker(job, "retry-budget-exhausted", at);
|
|
794
|
+
else {
|
|
795
|
+
const seconds = Math.min(3600, job.baseRetrySeconds * (2 ** (job.checkpoint.retryCount - 1)));
|
|
796
|
+
job.checkpoint.nextRetryAt = new Date(new Date(at).getTime() + seconds * 1000).toISOString();
|
|
797
|
+
blocker(job, "effect-failed", at);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
job.pendingEffect = null;
|
|
801
|
+
job.updatedAt = at;
|
|
802
|
+
const { receipt } = appendReceipt(state, job, receiptId, success ? "effect-succeeded" : "effect-failed", at, {
|
|
803
|
+
toolUseId: deliveryId, capability, checkpointSequence: job.checkpoint.sequence,
|
|
804
|
+
workspaceDigest: fingerprint.digest, resultDigest: job.checkpoint.resultDigest, retryCount: job.checkpoint.retryCount
|
|
805
|
+
});
|
|
806
|
+
return { job, duplicate: false, receipt, selfstarterPath: paths.selfstarterPath };
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
export async function closeJobLease({ root = process.cwd(), jobId, actorId, projectId, groupId = null, taskId, host, sessionId, status = "waiting", now = new Date() }) {
|
|
811
|
+
if (!["waiting", "blocked", "completed"].includes(status)) throw new Error("closing status must be waiting, blocked, or completed");
|
|
812
|
+
const scope = {
|
|
813
|
+
jobId: stableId(jobId, "jobId"), actorId: stableId(actorId, "actorId"),
|
|
814
|
+
projectId: stableId(projectId, "projectId"), groupId: stableId(groupId, "groupId", true),
|
|
815
|
+
taskId: stableId(taskId, "taskId"), host, sessionId: stableId(sessionId, "sessionId")
|
|
816
|
+
};
|
|
817
|
+
const at = timestamp(now, "now");
|
|
818
|
+
return lockedStates(root, ({ policy, state, coordination, paths }) => {
|
|
819
|
+
const job = state.jobs.find((item) => item.id === scope.jobId);
|
|
820
|
+
if (!job) throw new Error("unknown self-starter job");
|
|
821
|
+
ensureScope(job, scope);
|
|
822
|
+
exactGrant(policy, job, "resume", at);
|
|
823
|
+
if (!currentTaskValid(job, coordination) && status !== "completed") throw new Error("self-starter task or assignment changed");
|
|
824
|
+
const receiptId = `receipt:close:${receiptDigest({ job: job.id, session: scope.sessionId, status }).slice(0, 20)}`;
|
|
825
|
+
const existing = state.receipts.find((receipt) => receipt.id === receiptId);
|
|
826
|
+
if (existing && !job.lease) return { job, duplicate: true, receipt: existing, selfstarterPath: paths.selfstarterPath };
|
|
827
|
+
if (!job.lease || job.lease.sessionId !== scope.sessionId) throw new Error("only the current lease holder may close a job");
|
|
828
|
+
if (job.pendingEffect) throw new Error("cannot close a job with an uncheckpointed effect");
|
|
829
|
+
preserveJob(state, job, "lease-closed", at);
|
|
830
|
+
job.status = status;
|
|
831
|
+
job.lease = null;
|
|
832
|
+
if (status === "blocked") job.lastBlocker = { code: "host-reported-blocker", at, authority: "execution-state-only" };
|
|
833
|
+
job.updatedAt = at;
|
|
834
|
+
const { receipt } = appendReceipt(state, job, receiptId, "lease-closed", at, { sessionId: scope.sessionId, status, checkpointSequence: job.checkpoint.sequence });
|
|
835
|
+
return { job, duplicate: false, receipt, selfstarterPath: paths.selfstarterPath };
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
export async function cancelJob({ root = process.cwd(), id, reason, confirmation, now = new Date() }) {
|
|
840
|
+
requireOwnerConfirmation(confirmation);
|
|
841
|
+
stableId(id, "jobId");
|
|
842
|
+
const at = timestamp(now, "now");
|
|
843
|
+
const cancelReason = safeText(reason, "reason", 500);
|
|
844
|
+
return lockedStates(root, ({ state, paths }) => {
|
|
845
|
+
const job = state.jobs.find((item) => item.id === id);
|
|
846
|
+
if (!job) throw new Error("unknown self-starter job");
|
|
847
|
+
if (["completed", "cancelled"].includes(job.status)) throw new Error("self-starter job is already terminal");
|
|
848
|
+
preserveJob(state, job, "cancelled", at);
|
|
849
|
+
job.status = "cancelled";
|
|
850
|
+
job.lease = null;
|
|
851
|
+
job.pendingEffect = null;
|
|
852
|
+
job.lastBlocker = { code: "owner-cancelled", at, authority: "execution-state-only" };
|
|
853
|
+
job.cancelReason = cancelReason;
|
|
854
|
+
job.updatedAt = at;
|
|
855
|
+
appendReceipt(state, job, `receipt:cancel:${receiptDigest({ id, at }).slice(0, 20)}`, "cancelled", at, { reasonDigest: resultHash(cancelReason) });
|
|
856
|
+
return { job, selfstarterPath: paths.selfstarterPath };
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export async function deleteJob({ root = process.cwd(), id, confirmation }) {
|
|
861
|
+
requireOwnerConfirmation(confirmation);
|
|
862
|
+
stableId(id, "jobId");
|
|
863
|
+
return lockedStates(root, ({ state, paths }) => {
|
|
864
|
+
const job = state.jobs.find((item) => item.id === id);
|
|
865
|
+
if (job?.lease || job?.pendingEffect) throw new Error("cannot delete a leased or uncheckpointed job");
|
|
866
|
+
const existed = Boolean(job);
|
|
867
|
+
state.jobs = state.jobs.filter((item) => item.id !== id);
|
|
868
|
+
state.history = state.history.filter((entry) => entry.recordId !== id && entry.value?.id !== id);
|
|
869
|
+
state.receipts = state.receipts.filter((receipt) => receipt.jobId !== id);
|
|
870
|
+
return { deleted: existed, id, selfstarterPath: paths.selfstarterPath };
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
export async function selfstarterContext({ root = process.cwd(), actorId = null, projectId = null, taskId = null, includeTerminal = false } = {}) {
|
|
875
|
+
const paths = await pathsFor(root);
|
|
876
|
+
const [policy, state] = await Promise.all([
|
|
877
|
+
readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
878
|
+
readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs)
|
|
879
|
+
]);
|
|
880
|
+
const { graph, coordination } = await referenceState(paths.catalog);
|
|
881
|
+
const findings = [...executionPolicyFindingsInternal(policy, graph, coordination), ...selfstarterFindingsInternal(state, policy, graph, coordination)];
|
|
882
|
+
if (findings.length) throw new Error(`self-starter failed closed: ${findings.join(", ")}`);
|
|
883
|
+
const items = state.jobs.filter((job) => (!actorId || job.actorId === actorId) && (!projectId || job.projectId === projectId)
|
|
884
|
+
&& (!taskId || job.taskId === taskId) && (includeTerminal || !["completed", "cancelled"].includes(job.status)))
|
|
885
|
+
.map((job) => ({
|
|
886
|
+
id: job.id, taskId: job.taskId, actorId: job.actorId, targetId: job.targetId,
|
|
887
|
+
projectId: job.projectId, groupId: job.groupId, host: job.host, status: job.status,
|
|
888
|
+
checkpoint: { ...job.checkpoint }, leased: Boolean(job.lease), pendingEffect: Boolean(job.pendingEffect),
|
|
889
|
+
lastBlocker: job.lastBlocker, updatedAt: job.updatedAt, authority: "execution-state-only"
|
|
890
|
+
}));
|
|
891
|
+
return { schema: "agentspine.selfstarter-context/v1", root: paths.catalog.root, items, authority: "execution-state-only" };
|
|
892
|
+
}
|