hoi4-agent-tools 3.0.9 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +10 -2
  3. package/dist/bin/stdio.js +23 -12
  4. package/dist/hoi4_agent_tools/core/analysis-checkpoints.js +82 -0
  5. package/dist/hoi4_agent_tools/core/analysis-evidence.js +9 -0
  6. package/dist/hoi4_agent_tools/core/analysis-job-operations.js +38 -0
  7. package/dist/hoi4_agent_tools/core/artifact-resource.js +173 -0
  8. package/dist/hoi4_agent_tools/core/artifacts.js +84 -2
  9. package/dist/hoi4_agent_tools/core/condition-evaluator.js +16 -0
  10. package/dist/hoi4_agent_tools/core/dependency-pages.js +216 -0
  11. package/dist/hoi4_agent_tools/core/dependency-walk.js +266 -0
  12. package/dist/hoi4_agent_tools/core/domain-validation.js +188 -0
  13. package/dist/hoi4_agent_tools/core/engine.js +48 -5
  14. package/dist/hoi4_agent_tools/core/helper-expansion.js +186 -0
  15. package/dist/hoi4_agent_tools/core/impact-comparison.js +49 -0
  16. package/dist/hoi4_agent_tools/core/impact-graph.js +301 -0
  17. package/dist/hoi4_agent_tools/core/impact-semantic.js +256 -0
  18. package/dist/hoi4_agent_tools/core/index-segments.js +137 -0
  19. package/dist/hoi4_agent_tools/core/index.js +47 -4
  20. package/dist/hoi4_agent_tools/core/job-controls.js +109 -0
  21. package/dist/hoi4_agent_tools/core/job-executor.js +190 -0
  22. package/dist/hoi4_agent_tools/core/job-service.js +112 -0
  23. package/dist/hoi4_agent_tools/core/job-store.js +641 -0
  24. package/dist/hoi4_agent_tools/core/job-transactions.js +126 -0
  25. package/dist/hoi4_agent_tools/core/job-worker-host.js +202 -0
  26. package/dist/hoi4_agent_tools/core/job-worker.js +64 -0
  27. package/dist/hoi4_agent_tools/core/operation-context.js +31 -0
  28. package/dist/hoi4_agent_tools/core/operation-result.js +412 -0
  29. package/dist/hoi4_agent_tools/core/operation-tasks.js +387 -0
  30. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +383 -0
  31. package/dist/hoi4_agent_tools/core/result.js +1 -0
  32. package/dist/hoi4_agent_tools/core/scanner.js +15 -75
  33. package/dist/hoi4_agent_tools/core/semantic-dependencies.js +233 -0
  34. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +43 -11
  35. package/dist/hoi4_agent_tools/core/source/cache.js +107 -0
  36. package/dist/hoi4_agent_tools/core/source/parser.js +11 -1
  37. package/dist/hoi4_agent_tools/core/source-overlay.js +125 -0
  38. package/dist/hoi4_agent_tools/core/transaction-execution.js +134 -0
  39. package/dist/hoi4_agent_tools/core/transactions.js +80 -27
  40. package/dist/hoi4_agent_tools/decision/ai.js +104 -0
  41. package/dist/hoi4_agent_tools/decision/comparison.js +106 -0
  42. package/dist/hoi4_agent_tools/decision/cost.js +177 -0
  43. package/dist/hoi4_agent_tools/decision/evaluation.js +256 -0
  44. package/dist/hoi4_agent_tools/decision/lifecycle.js +114 -0
  45. package/dist/hoi4_agent_tools/decision/service.js +172 -0
  46. package/dist/hoi4_agent_tools/decision/source-inventory.js +182 -0
  47. package/dist/hoi4_agent_tools/event/graph.js +214 -290
  48. package/dist/hoi4_agent_tools/event/helper-expansion.js +33 -0
  49. package/dist/hoi4_agent_tools/event/job-operations.js +51 -0
  50. package/dist/hoi4_agent_tools/event/operations.js +179 -0
  51. package/dist/hoi4_agent_tools/event/service.js +67 -15
  52. package/dist/hoi4_agent_tools/event/source-analysis.js +5 -4
  53. package/dist/hoi4_agent_tools/focus/job-operations.js +77 -0
  54. package/dist/hoi4_agent_tools/focus/tool-operations.js +669 -0
  55. package/dist/hoi4_agent_tools/focus/tool-support.js +123 -0
  56. package/dist/hoi4_agent_tools/gui/job-operations.js +81 -0
  57. package/dist/hoi4_agent_tools/gui/tool-operations.js +270 -0
  58. package/dist/hoi4_agent_tools/impact/scenario-suites.js +74 -0
  59. package/dist/hoi4_agent_tools/impact/service.js +127 -0
  60. package/dist/hoi4_agent_tools/map/job-operations.js +76 -0
  61. package/dist/hoi4_agent_tools/map/tool-operations.js +493 -0
  62. package/dist/hoi4_agent_tools/mcp/prompts/probability.js +25 -0
  63. package/dist/hoi4_agent_tools/mcp/prompts/register-modern.js +29 -0
  64. package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +34 -0
  65. package/dist/hoi4_agent_tools/mcp/resources/register.js +4 -150
  66. package/dist/hoi4_agent_tools/mcp/server/base-tools.js +3 -219
  67. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +188 -0
  68. package/dist/hoi4_agent_tools/mcp/server/create.js +18 -6
  69. package/dist/hoi4_agent_tools/mcp/server/instructions.js +2 -0
  70. package/dist/hoi4_agent_tools/mcp/server/job-task-store.js +67 -0
  71. package/dist/hoi4_agent_tools/mcp/server/legacy-task-tools.js +20 -0
  72. package/dist/hoi4_agent_tools/mcp/server/modern-tasks.js +168 -0
  73. package/dist/hoi4_agent_tools/mcp/server/progress.js +17 -15
  74. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +45 -0
  75. package/dist/hoi4_agent_tools/mcp/server/result.js +2 -412
  76. package/dist/hoi4_agent_tools/mcp/server/task-request-context.js +9 -0
  77. package/dist/hoi4_agent_tools/mcp/server/task-tool-catalog.js +16 -0
  78. package/dist/hoi4_agent_tools/mcp/server/task-tool-definition.js +1 -0
  79. package/dist/hoi4_agent_tools/mcp/server/transaction-execution.js +1 -123
  80. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +65 -0
  81. package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +38 -21
  82. package/dist/hoi4_agent_tools/mcp/tools/event.js +24 -315
  83. package/dist/hoi4_agent_tools/mcp/tools/focus.js +34 -1039
  84. package/dist/hoi4_agent_tools/mcp/tools/gui.js +19 -504
  85. package/dist/hoi4_agent_tools/mcp/tools/job.js +46 -0
  86. package/dist/hoi4_agent_tools/mcp/tools/map.js +21 -600
  87. package/dist/hoi4_agent_tools/mcp/tools/probability.js +39 -463
  88. package/dist/hoi4_agent_tools/mcp/tools/technology.js +40 -304
  89. package/dist/hoi4_agent_tools/mcp/transports/http.js +58 -1
  90. package/dist/hoi4_agent_tools/mcp/transports/modern-task-routing.js +109 -0
  91. package/dist/hoi4_agent_tools/mcp/transports/negotiated-stdio.js +121 -0
  92. package/dist/hoi4_agent_tools/probability/job-operations.js +58 -0
  93. package/dist/hoi4_agent_tools/probability/operations.js +183 -0
  94. package/dist/hoi4_agent_tools/probability/service.js +24 -3
  95. package/dist/hoi4_agent_tools/schemas/analysis.js +228 -0
  96. package/dist/hoi4_agent_tools/schemas/common.js +2 -0
  97. package/dist/hoi4_agent_tools/schemas/event.js +72 -1
  98. package/dist/hoi4_agent_tools/schemas/focus-requests.js +189 -0
  99. package/dist/hoi4_agent_tools/schemas/gui-requests.js +193 -0
  100. package/dist/hoi4_agent_tools/schemas/helper-expansion.js +58 -0
  101. package/dist/hoi4_agent_tools/schemas/map-requests.js +85 -0
  102. package/dist/hoi4_agent_tools/schemas/probability-requests.js +168 -0
  103. package/dist/hoi4_agent_tools/schemas/technology.js +90 -1
  104. package/dist/hoi4_agent_tools/schemas/transaction.js +1 -0
  105. package/dist/hoi4_agent_tools/technology/graph.js +83 -78
  106. package/dist/hoi4_agent_tools/technology/helper-expansion.js +33 -0
  107. package/dist/hoi4_agent_tools/technology/job-operations.js +60 -0
  108. package/dist/hoi4_agent_tools/technology/operations.js +125 -0
  109. package/dist/hoi4_agent_tools/technology/service.js +89 -15
  110. package/dist/hoi4_agent_tools/technology/source-analysis.js +2 -1
  111. package/dist/hoi4_agent_tools/version.js +1 -1
  112. package/docs/README.md +3 -0
  113. package/docs/analysis.md +100 -0
  114. package/docs/development.md +2 -0
  115. package/docs/events.md +7 -2
  116. package/docs/helper-expansion.md +78 -0
  117. package/docs/http.md +2 -0
  118. package/docs/jobs.md +86 -0
  119. package/docs/technology.md +6 -2
  120. package/package.json +15 -6
  121. package/schemas/configuration.schema.json +1 -1
  122. package/schemas/continuous-focus-palette.schema.json +1 -1
  123. package/schemas/custom-weighted-pool.schema.json +1 -1
  124. package/schemas/decision-inspect-input.schema.json +290 -0
  125. package/schemas/event-feature-manifest.schema.json +1 -1
  126. package/schemas/focus-plan.schema.json +1 -1
  127. package/schemas/focus-planning-sidecar.schema.json +1 -1
  128. package/schemas/gui-animation-source.schema.json +1 -1
  129. package/schemas/gui-helper.schema.json +1 -1
  130. package/schemas/gui-scenario.schema.json +1 -1
  131. package/schemas/helper-expansion-request.schema.json +44 -0
  132. package/schemas/helper-expansion-summary.schema.json +70 -0
  133. package/schemas/impact-inspect-input.schema.json +203 -0
  134. package/schemas/map-operation.schema.json +1 -1
  135. package/schemas/operation-result.schema.json +1 -1
  136. package/schemas/probability-analysis-result.schema.json +1 -1
  137. package/schemas/probability-compare-input.schema.json +1 -1
  138. package/schemas/probability-evaluate-input.schema.json +1 -1
  139. package/schemas/probability-inspect-input.schema.json +1 -1
  140. package/schemas/probability-render-input.schema.json +1 -1
  141. package/schemas/probability-scenario-set.schema.json +1 -1
  142. package/schemas/probability-sequence-input.schema.json +1 -1
  143. package/schemas/probability-simulate-input.schema.json +1 -1
  144. package/schemas/probability-sweep-input.schema.json +1 -1
  145. package/server.json +3 -3
@@ -0,0 +1,641 @@
1
+ import { lstat, mkdir, open, opendir, rename, unlink } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { hostname } from 'node:os';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { z } from 'zod/v4';
6
+ import { canonicalJson, hashCanonical, secureId } from './canonical.js';
7
+ import { ServiceError } from './result.js';
8
+ import { SharedRequestCapacity } from './shared-request-capacity.js';
9
+ import { containedGeneratedPath } from './workspace.js';
10
+ import { PACKAGE_VERSION } from '../version.js';
11
+ const digest = z.string().regex(/^[a-f0-9]{64}$/u);
12
+ const jobId = z.string().regex(/^job_[a-f0-9]{64}$/u);
13
+ const scopeSchema = z
14
+ .object({
15
+ workspaceId: z.string().min(1),
16
+ workspaceIdentity: digest,
17
+ rootFingerprint: digest,
18
+ principal: z.string().min(1).nullable(),
19
+ })
20
+ .strict();
21
+ const transactionBindingSchema = z
22
+ .object({
23
+ transactionId: z.string().regex(/^txn_[0-9a-f-]{36}$/u),
24
+ planHash: digest,
25
+ })
26
+ .strict();
27
+ const statusSchema = z.enum([
28
+ 'queued',
29
+ 'running',
30
+ 'reconciling',
31
+ 'completed',
32
+ 'cancelled',
33
+ 'failed',
34
+ ]);
35
+ const ownerSchema = z
36
+ .object({
37
+ token: z.string().regex(/^worker_[0-9a-f-]{36}$/u),
38
+ pid: z.number().int().positive(),
39
+ host: z.string().min(1),
40
+ processStartedAt: z.iso.datetime(),
41
+ })
42
+ .strict();
43
+ const requestSchema = z
44
+ .object({
45
+ toolName: z.string().regex(/^(?:hoi4|chaosx)\.[a-z_]+$/u),
46
+ arguments: z.record(z.string(), z.json()),
47
+ mutation: z.boolean(),
48
+ requestKey: z.string().min(1).max(256).optional(),
49
+ protocolTask: z
50
+ .object({
51
+ ttl: z.number().int().positive().nullable(),
52
+ pollInterval: z.number().int().min(50).max(60_000),
53
+ })
54
+ .strict()
55
+ .optional(),
56
+ })
57
+ .strict()
58
+ .refine((value) => !value.mutation || value.requestKey !== undefined, {
59
+ message: 'Background rewrites require a reusable request key',
60
+ });
61
+ const recordBaseSchema = z
62
+ .object({
63
+ version: z.literal(1),
64
+ id: jobId,
65
+ revision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
66
+ scope: scopeSchema,
67
+ request: requestSchema,
68
+ requestHash: digest,
69
+ toolVersion: z.string(),
70
+ status: statusSchema,
71
+ createdAt: z.iso.datetime(),
72
+ updatedAt: z.iso.datetime(),
73
+ taskVisibility: z
74
+ .object({ renewedAt: z.iso.datetime(), expiresAt: z.iso.datetime().nullable() })
75
+ .strict()
76
+ .optional(),
77
+ cancelRequested: z.boolean(),
78
+ owner: ownerSchema.optional(),
79
+ progress: z
80
+ .object({
81
+ completed: z.number().nonnegative(),
82
+ total: z.number().nonnegative().optional(),
83
+ message: z.string().max(4096),
84
+ })
85
+ .strict()
86
+ .optional(),
87
+ transaction: transactionBindingSchema.optional(),
88
+ writeRecipe: z.record(z.string(), z.json()).optional(),
89
+ checkpoint: z
90
+ .object({
91
+ sourceRevision: digest,
92
+ resourceUri: z.string().min(1).optional(),
93
+ cursor: z.string().min(1),
94
+ resultHash: digest.optional(),
95
+ stateHash: digest.optional(),
96
+ resources: z.array(z.string().min(1)).max(257).optional(),
97
+ })
98
+ .strict()
99
+ .optional(),
100
+ result: z.record(z.string(), z.json()).optional(),
101
+ failure: z
102
+ .object({ code: z.string().min(1), message: z.string().max(4096) })
103
+ .strict()
104
+ .optional(),
105
+ })
106
+ .strict();
107
+ const recordSchema = recordBaseSchema.superRefine((value, context) => {
108
+ if (value.status === 'completed' && value.result === undefined)
109
+ context.addIssue({ code: 'custom', message: 'Completed jobs require a recorded result' });
110
+ if (value.status === 'failed' && value.failure === undefined)
111
+ context.addIssue({
112
+ code: 'custom',
113
+ message: 'Failed jobs require recorded failure evidence',
114
+ });
115
+ if (value.transaction !== undefined && !value.request.mutation)
116
+ context.addIssue({
117
+ code: 'custom',
118
+ message: 'Read-only jobs cannot bind write transactions',
119
+ });
120
+ if (value.writeRecipe !== undefined &&
121
+ (!value.request.mutation || value.transaction === undefined))
122
+ context.addIssue({
123
+ code: 'custom',
124
+ message: 'A write result recipe requires a bound mutation transaction',
125
+ });
126
+ if (value.progress?.total !== undefined && value.progress.completed > value.progress.total)
127
+ context.addIssue({ code: 'custom', message: 'Completed progress exceeds total work' });
128
+ if (value.taskVisibility !== undefined &&
129
+ (!value.request.mutation ||
130
+ value.request.protocolTask === undefined ||
131
+ !['completed', 'failed', 'cancelled'].includes(value.status) ||
132
+ Date.parse(value.taskVisibility.renewedAt) < Date.parse(value.updatedAt) ||
133
+ (value.taskVisibility.expiresAt !== null &&
134
+ Date.parse(value.taskVisibility.expiresAt) <= Date.parse(value.taskVisibility.renewedAt))))
135
+ context.addIssue({
136
+ code: 'custom',
137
+ message: 'Renewed task visibility requires a terminal mutation receipt and a valid lifetime',
138
+ });
139
+ if (value.result !== undefined &&
140
+ value.status !== 'completed' &&
141
+ (value.request.mutation ||
142
+ value.checkpoint?.cursor !== 'result-ready' ||
143
+ value.checkpoint.resultHash !== hashCanonical(value.result)))
144
+ context.addIssue({
145
+ code: 'custom',
146
+ message: 'A staged read result requires an authenticated result-ready checkpoint',
147
+ });
148
+ });
149
+ const envelopeSchema = z.object({ record: recordSchema, authenticationTag: digest }).strict();
150
+ const updateSchema = recordBaseSchema
151
+ .pick({
152
+ status: true,
153
+ cancelRequested: true,
154
+ progress: true,
155
+ transaction: true,
156
+ writeRecipe: true,
157
+ checkpoint: true,
158
+ result: true,
159
+ failure: true,
160
+ })
161
+ .partial()
162
+ .strict();
163
+ /** Only authenticated read-result/frontier commitments authorize automatic worker recovery. */
164
+ export function readCheckpointIdentity(record) {
165
+ if (record.request.mutation ||
166
+ record.cancelRequested ||
167
+ ['completed', 'failed', 'cancelled'].includes(record.status))
168
+ return undefined;
169
+ const checkpoint = record.checkpoint;
170
+ if (checkpoint?.cursor === 'result-ready' &&
171
+ record.result !== undefined &&
172
+ checkpoint.resultHash === hashCanonical(record.result))
173
+ return `result:${checkpoint.resultHash}`;
174
+ if (checkpoint !== undefined &&
175
+ /^analysis:[a-f0-9]{64}$/u.test(checkpoint.cursor) &&
176
+ checkpoint.stateHash !== undefined &&
177
+ checkpoint.resourceUri !== undefined)
178
+ return `${checkpoint.cursor}:${checkpoint.sourceRevision}:${checkpoint.stateHash}`;
179
+ return undefined;
180
+ }
181
+ export function currentJobOwner() {
182
+ return {
183
+ token: secureId('worker'),
184
+ pid: process.pid,
185
+ host: hostname().toLowerCase(),
186
+ processStartedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(),
187
+ };
188
+ }
189
+ export function jobOwnerLiveness(owner) {
190
+ if (owner.host !== hostname().toLowerCase())
191
+ return 'unknown';
192
+ try {
193
+ process.kill(owner.pid, 0);
194
+ return 'alive';
195
+ }
196
+ catch (error) {
197
+ return error.code === 'ESRCH' ? 'dead' : 'unknown';
198
+ }
199
+ }
200
+ function jobRequestHash(scope, request, toolVersion) {
201
+ const { protocolTask: _protocolTask, ...executionRequest } = request;
202
+ return hashCanonical({ scope, request: executionRequest, toolVersion });
203
+ }
204
+ const transitions = {
205
+ queued: new Set(['queued', 'running', 'cancelled', 'failed']),
206
+ running: new Set(['running', 'reconciling', 'completed', 'cancelled', 'failed']),
207
+ reconciling: new Set(['reconciling', 'running', 'completed', 'cancelled', 'failed']),
208
+ completed: new Set(),
209
+ cancelled: new Set(),
210
+ failed: new Set(),
211
+ };
212
+ export function jobRetentionExpired(record, now = Date.now()) {
213
+ if (record.taskVisibility !== undefined)
214
+ return (record.taskVisibility.expiresAt !== null && Date.parse(record.taskVisibility.expiresAt) <= now);
215
+ const ttl = record.request.protocolTask?.ttl;
216
+ return (ttl !== undefined &&
217
+ ttl !== null &&
218
+ ['completed', 'cancelled', 'failed'].includes(record.status) &&
219
+ Date.parse(record.updatedAt) + ttl <= now);
220
+ }
221
+ /** Wire task timing is distinct from the immutable execution timestamps after an exact retry. */
222
+ export function jobTaskTiming(record) {
223
+ const visibility = record.taskVisibility;
224
+ const ttl = record.request.protocolTask?.ttl ?? null;
225
+ const completedLifetime = ['completed', 'failed', 'cancelled'].includes(record.status)
226
+ ? Math.max(0, Date.parse(record.updatedAt) - Date.parse(record.createdAt))
227
+ : 0;
228
+ return {
229
+ lastUpdatedAt: visibility?.renewedAt ?? record.updatedAt,
230
+ ttl: visibility === undefined
231
+ ? ttl === null
232
+ ? null
233
+ : ttl + completedLifetime
234
+ : visibility.expiresAt === null
235
+ ? null
236
+ : Date.parse(visibility.expiresAt) - Date.parse(record.createdAt),
237
+ };
238
+ }
239
+ async function assertUnlinked(file) {
240
+ try {
241
+ if ((await lstat(file)).isSymbolicLink())
242
+ throw new ServiceError('JOB_RECORD_UNSAFE', 'Persistent job storage cannot use symbolic links or junctions');
243
+ }
244
+ catch (error) {
245
+ if (error.code !== 'ENOENT')
246
+ throw error;
247
+ }
248
+ }
249
+ /** Atomic replacement with a finite retry for Windows sharing violations. */
250
+ export async function publishJobRecord(temporary, target, options = {}) {
251
+ const platform = options.platform ?? process.platform;
252
+ const publish = options.publish ?? rename;
253
+ const wait = options.wait ?? delay;
254
+ const maxAttempts = options.maxAttempts ?? 100;
255
+ if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1)
256
+ throw new RangeError('Job record publication attempts must be a positive integer');
257
+ for (let attempt = 1;; attempt += 1) {
258
+ try {
259
+ await publish(temporary, target);
260
+ return;
261
+ }
262
+ catch (error) {
263
+ const code = error.code ?? '';
264
+ if (platform !== 'win32' ||
265
+ !['EPERM', 'EBUSY', 'EACCES'].includes(code) ||
266
+ attempt >= maxAttempts)
267
+ throw error;
268
+ await wait(10);
269
+ }
270
+ }
271
+ }
272
+ /** Internal durable job records. Callers must obtain scopes from the authorized workspace resolver. */
273
+ export class JobStore {
274
+ state;
275
+ root;
276
+ lock;
277
+ maxRecordBytes;
278
+ constructor(state, root, lock, maxRecordBytes) {
279
+ this.state = state;
280
+ this.root = root;
281
+ this.lock = lock;
282
+ this.maxRecordBytes = maxRecordBytes;
283
+ }
284
+ static async create(state, maxRecordBytes = 4 * 1024 * 1024) {
285
+ if (!Number.isSafeInteger(maxRecordBytes) || maxRecordBytes < 1024)
286
+ throw new RangeError('Job record budget must be an integer of at least 1024 bytes');
287
+ await assertUnlinked(path.join(state.root, 'jobs'));
288
+ const root = await containedGeneratedPath(state.root, 'jobs');
289
+ await mkdir(root, { recursive: true, mode: 0o700 });
290
+ await assertUnlinked(path.join(state.root, 'jobs'));
291
+ await containedGeneratedPath(state.root, 'jobs');
292
+ return new JobStore(state, root, new SharedRequestCapacity(root, 1), maxRecordBytes);
293
+ }
294
+ async submit(scopeInput, requestInput, signal = new AbortController().signal) {
295
+ const scope = scopeSchema.parse(scopeInput);
296
+ const request = requestSchema.parse(requestInput);
297
+ const requestHash = jobRequestHash(scope, request, PACKAGE_VERSION);
298
+ // A caller-chosen retry key is not entropy. Keep the namespace stable across restarts
299
+ // while separating public job IDs from journal authentication and other HMAC uses.
300
+ const id = `job_${this.state.authenticateJournal({
301
+ kind: 'job-id.v2',
302
+ scope,
303
+ key: request.requestKey ?? secureId('request'),
304
+ })}`;
305
+ const legacyId = request.requestKey === undefined
306
+ ? undefined
307
+ : `job_${hashCanonical({ scope, key: request.requestKey })}`;
308
+ return this.lock.run(signal, async () => {
309
+ const current = await this.read(scope, id);
310
+ // Never lose an authenticated retry receipt merely because its identifier predates
311
+ // secret-derived IDs. Read both under the publication lock and fail closed on damage.
312
+ const legacy = legacyId === undefined ? undefined : await this.read(scope, legacyId);
313
+ if (current !== undefined && legacy !== undefined)
314
+ throw new ServiceError('JOB_REQUEST_KEY_AMBIGUOUS', 'The request key has multiple retained execution records; neither can be replayed');
315
+ const existing = current ?? legacy;
316
+ if (existing !== undefined) {
317
+ if (existing.requestHash !== requestHash)
318
+ throw new ServiceError('JOB_REQUEST_KEY_CONFLICT', 'The request key is already bound to different inputs or a different tool version');
319
+ const now = Date.now();
320
+ if (request.protocolTask !== undefined &&
321
+ existing.request.mutation &&
322
+ jobRetentionExpired(existing, now)) {
323
+ // Renew only the protocol view on an exact authorized retry. Execution identity,
324
+ // timestamps, result, transaction, and write recipe stay bound to the original work.
325
+ const renewed = recordSchema.parse({
326
+ ...existing,
327
+ revision: existing.revision + 1,
328
+ taskVisibility: {
329
+ renewedAt: new Date(now).toISOString(),
330
+ expiresAt: request.protocolTask.ttl === null
331
+ ? null
332
+ : new Date(now + request.protocolTask.ttl).toISOString(),
333
+ },
334
+ });
335
+ signal.throwIfAborted();
336
+ await this.write(renewed);
337
+ return { record: renewed, created: false };
338
+ }
339
+ return { record: existing, created: false };
340
+ }
341
+ const now = new Date().toISOString();
342
+ const record = recordSchema.parse({
343
+ version: 1,
344
+ id,
345
+ revision: 1,
346
+ scope,
347
+ request,
348
+ requestHash,
349
+ toolVersion: PACKAGE_VERSION,
350
+ status: 'queued',
351
+ createdAt: now,
352
+ updatedAt: now,
353
+ cancelRequested: false,
354
+ });
355
+ signal.throwIfAborted();
356
+ await this.write(record);
357
+ return { record, created: true };
358
+ });
359
+ }
360
+ async get(scope, id, signal) {
361
+ signal?.throwIfAborted();
362
+ const record = await this.read(scopeSchema.parse(scope), jobId.parse(id));
363
+ signal?.throwIfAborted();
364
+ if (record === undefined)
365
+ throw new ServiceError('JOB_NOT_FOUND', 'No job exists in the authorized workspace and principal scope');
366
+ return record;
367
+ }
368
+ /** Remove only an authenticated terminal record under the store lock. */
369
+ async removeTerminal(scopeInput, idInput, signal = new AbortController().signal) {
370
+ const scope = scopeSchema.parse(scopeInput);
371
+ const id = jobId.parse(idInput);
372
+ return this.lock.run(signal, async () => {
373
+ const record = await this.read(scope, id);
374
+ if (record === undefined)
375
+ return false;
376
+ if (!['completed', 'cancelled', 'failed'].includes(record.status))
377
+ throw new ServiceError('JOB_STATE_CONFLICT', 'Only a terminal job record can be removed by retention cleanup');
378
+ const file = await this.recordPath(scope, id);
379
+ for (let attempt = 1;; attempt += 1) {
380
+ signal.throwIfAborted();
381
+ try {
382
+ await unlink(file);
383
+ return true;
384
+ }
385
+ catch (error) {
386
+ const code = error.code ?? '';
387
+ if (code === 'ENOENT')
388
+ return false;
389
+ if (process.platform !== 'win32' ||
390
+ !['EPERM', 'EBUSY', 'EACCES'].includes(code) ||
391
+ attempt >= 100)
392
+ throw error;
393
+ await delay(10);
394
+ }
395
+ }
396
+ });
397
+ }
398
+ /** Claim execution once; an interrupted owner must be proven dead, never merely slow. */
399
+ async claim(scope, id, ownerInput, signal = new AbortController().signal) {
400
+ const owner = ownerSchema.parse(ownerInput);
401
+ return this.lock.run(signal, async () => {
402
+ const current = await this.get(scope, id, signal);
403
+ if (!['queued', 'running', 'reconciling'].includes(current.status))
404
+ throw new ServiceError('JOB_TERMINAL', 'The job already has a terminal outcome');
405
+ const recovery = current.status !== 'queued';
406
+ if (recovery) {
407
+ if (current.owner === undefined)
408
+ throw new ServiceError('JOB_OWNER_UNRESOLVED', 'Interrupted job ownership has no verifiable process evidence');
409
+ const liveness = jobOwnerLiveness(current.owner);
410
+ if (liveness !== 'dead')
411
+ throw new ServiceError(liveness === 'alive' ? 'JOB_OWNER_ACTIVE' : 'JOB_OWNER_UNRESOLVED', 'The previous execution owner has not been proven stopped');
412
+ }
413
+ const record = recordSchema.parse({
414
+ ...current,
415
+ owner,
416
+ status: recovery ? 'reconciling' : current.cancelRequested ? 'cancelled' : 'running',
417
+ revision: current.revision + 1,
418
+ updatedAt: new Date().toISOString(),
419
+ });
420
+ signal.throwIfAborted();
421
+ await this.write(record);
422
+ return { record, recovery };
423
+ });
424
+ }
425
+ /** Worker fencing: stale execution tokens cannot publish progress, results, or bindings. */
426
+ async updateOwned(scope, id, ownerToken, patch, signal = new AbortController().signal) {
427
+ for (;;) {
428
+ signal.throwIfAborted();
429
+ const current = await this.get(scope, id, signal);
430
+ if (current.owner?.token !== ownerToken)
431
+ throw new ServiceError('JOB_OWNER_CHANGED', 'The job is owned by another execution attempt');
432
+ try {
433
+ return await this.update(scope, id, current.revision, patch, signal);
434
+ }
435
+ catch (error) {
436
+ if (!(error instanceof ServiceError) || error.code !== 'JOB_REVISION_CONFLICT')
437
+ throw error;
438
+ }
439
+ }
440
+ }
441
+ /** Bounded-memory stable-key pagination; only the requested scope is enumerated. */
442
+ async list(scopeInput, options = {}) {
443
+ const scope = scopeSchema.parse(scopeInput);
444
+ const limit = options.limit ?? 50;
445
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
446
+ throw new ServiceError('JOB_PAGE_INVALID', 'Job page size must be between 1 and 100');
447
+ if (options.after !== undefined)
448
+ jobId.parse(options.after);
449
+ options.signal?.throwIfAborted();
450
+ await assertUnlinked(this.root);
451
+ await assertUnlinked(path.join(this.root, hashCanonical(scope)));
452
+ const directory = await containedGeneratedPath(this.root, hashCanonical(scope));
453
+ let entries;
454
+ try {
455
+ entries = await opendir(directory);
456
+ }
457
+ catch (error) {
458
+ if (error.code === 'ENOENT')
459
+ return { records: [] };
460
+ throw error;
461
+ }
462
+ const ids = [];
463
+ for await (const entry of entries) {
464
+ options.signal?.throwIfAborted();
465
+ if (entry.name.startsWith('.') && entry.name.endsWith('.tmp') && entry.isFile())
466
+ continue;
467
+ const id = entry.name.replace(/\.json$/u, '');
468
+ if (!entry.isFile() ||
469
+ entry.isSymbolicLink() ||
470
+ !entry.name.endsWith('.json') ||
471
+ !jobId.safeParse(id).success)
472
+ throw new ServiceError('JOB_RECORD_UNSAFE', 'Job scope contains an invalid record entry');
473
+ if (options.after !== undefined && id <= options.after)
474
+ continue;
475
+ const position = ids.findIndex((value) => value > id);
476
+ ids.splice(position < 0 ? ids.length : position, 0, id);
477
+ if (ids.length > limit + 1)
478
+ ids.pop();
479
+ }
480
+ const records = [];
481
+ for (const id of ids.slice(0, limit))
482
+ records.push(await this.get(scope, id, options.signal));
483
+ return { records, ...(ids.length > limit ? { next: records.at(-1).id } : {}) };
484
+ }
485
+ async update(scope, id, expectedRevision, update, signal = new AbortController().signal) {
486
+ const patch = updateSchema.parse(update);
487
+ return this.lock.run(signal, async () => {
488
+ const current = await this.get(scope, id, signal);
489
+ if (current.revision !== expectedRevision)
490
+ throw new ServiceError('JOB_REVISION_CONFLICT', 'Job state changed; retrieve its current revision before updating');
491
+ if (!transitions[current.status].has(patch.status ?? current.status))
492
+ throw new ServiceError('JOB_STATE_CONFLICT', 'The requested job state transition is not valid');
493
+ if (current.cancelRequested && patch.cancelRequested === false)
494
+ throw new ServiceError('JOB_STATE_CONFLICT', 'A recorded cancellation request cannot be withdrawn');
495
+ if (current.transaction !== undefined &&
496
+ Object.hasOwn(patch, 'transaction') &&
497
+ canonicalJson(current.transaction) !== canonicalJson(patch.transaction ?? null))
498
+ throw new ServiceError('JOB_TRANSACTION_CONFLICT', 'A job cannot be rebound to another transaction');
499
+ if (current.writeRecipe !== undefined &&
500
+ Object.hasOwn(patch, 'writeRecipe') &&
501
+ canonicalJson(current.writeRecipe) !== canonicalJson(patch.writeRecipe ?? null))
502
+ throw new ServiceError('JOB_WRITE_RECIPE_CONFLICT', 'A job cannot replace its authenticated write result recipe');
503
+ if (current.result !== undefined &&
504
+ Object.hasOwn(patch, 'result') &&
505
+ canonicalJson(current.result) !== canonicalJson(patch.result ?? null))
506
+ throw new ServiceError('JOB_RESULT_CONFLICT', 'A staged or completed job result is immutable');
507
+ const record = recordSchema.parse({
508
+ ...current,
509
+ ...patch,
510
+ revision: current.revision + 1,
511
+ updatedAt: new Date().toISOString(),
512
+ });
513
+ signal.throwIfAborted();
514
+ await this.write(record);
515
+ return record;
516
+ });
517
+ }
518
+ /** Operator-internal eviction guard; never returns another principal's job data. */
519
+ async checkpointResources(workspace, signal) {
520
+ const resources = new Set();
521
+ let inspected = 0;
522
+ const spend = () => {
523
+ signal?.throwIfAborted();
524
+ if (++inspected > 100_000 || resources.size > 100_000)
525
+ throw new ServiceError('JOB_RETENTION_LIMIT', 'Checkpoint retention enumeration exceeded its fixed work ceiling');
526
+ };
527
+ await assertUnlinked(this.root);
528
+ for await (const directory of await opendir(this.root)) {
529
+ spend();
530
+ if (directory.name === 'request-capacity')
531
+ continue;
532
+ if (directory.isSymbolicLink() ||
533
+ !directory.isDirectory() ||
534
+ !digest.safeParse(directory.name).success)
535
+ throw new ServiceError('JOB_RECORD_UNSAFE', 'Job storage contains an invalid scope entry');
536
+ const scopeRoot = await containedGeneratedPath(this.root, directory.name);
537
+ for await (const entry of await opendir(scopeRoot)) {
538
+ spend();
539
+ if (entry.isFile() && entry.name.startsWith('.') && entry.name.endsWith('.tmp'))
540
+ continue;
541
+ const id = entry.name.replace(/\.json$/u, '');
542
+ if (!entry.name.endsWith('.json') ||
543
+ !entry.isFile() ||
544
+ entry.isSymbolicLink() ||
545
+ !jobId.safeParse(id).success)
546
+ throw new ServiceError('JOB_RECORD_UNSAFE', 'Job scope contains an invalid record entry');
547
+ const file = await containedGeneratedPath(scopeRoot, entry.name);
548
+ const record = await this.readEnvelope(file, id, directory.name);
549
+ if (record?.scope.workspaceId !== workspace.workspaceId ||
550
+ record.scope.workspaceIdentity !== workspace.workspaceIdentity ||
551
+ record.scope.rootFingerprint !== workspace.rootFingerprint ||
552
+ ['completed', 'cancelled', 'failed'].includes(record.status))
553
+ continue;
554
+ if (record.checkpoint?.resourceUri !== undefined)
555
+ resources.add(record.checkpoint.resourceUri);
556
+ for (const uri of record.checkpoint?.resources ?? [])
557
+ resources.add(uri);
558
+ }
559
+ }
560
+ spend();
561
+ return resources;
562
+ }
563
+ async recordPath(scope, id) {
564
+ await assertUnlinked(this.root);
565
+ await assertUnlinked(path.join(this.root, hashCanonical(scope)));
566
+ await assertUnlinked(path.join(this.root, hashCanonical(scope), `${id}.json`));
567
+ return containedGeneratedPath(this.root, hashCanonical(scope), `${id}.json`);
568
+ }
569
+ async read(scope, id) {
570
+ const file = await this.recordPath(scope, id);
571
+ return this.readEnvelope(file, id, hashCanonical(scope));
572
+ }
573
+ async readEnvelope(file, id, scopeHash) {
574
+ let handle;
575
+ try {
576
+ handle = await open(file, 'r');
577
+ const metadata = await handle.stat();
578
+ if (!metadata.isFile() || metadata.size > this.maxRecordBytes)
579
+ throw new ServiceError('JOB_RECORD_INVALID', 'Persistent job record exceeds its storage budget or is not a file');
580
+ const parsed = envelopeSchema.safeParse(JSON.parse(await handle.readFile('utf8')));
581
+ if (!parsed.success ||
582
+ parsed.data.record.id !== id ||
583
+ hashCanonical(parsed.data.record.scope) !== scopeHash ||
584
+ !this.state.verifyJournal({ kind: 'job-record.v1', record: parsed.data.record }, parsed.data.authenticationTag))
585
+ throw new ServiceError('JOB_RECORD_INVALID', 'Persistent job record authentication or identity is invalid');
586
+ const record = parsed.data.record;
587
+ if (record.requestHash !== jobRequestHash(record.scope, record.request, record.toolVersion))
588
+ throw new ServiceError('JOB_RECORD_INVALID', 'Persistent job request identity is invalid');
589
+ return record;
590
+ }
591
+ catch (error) {
592
+ if (error.code === 'ENOENT')
593
+ return undefined;
594
+ if (error instanceof SyntaxError)
595
+ throw new ServiceError('JOB_RECORD_INVALID', 'Persistent job record is not valid JSON');
596
+ throw error;
597
+ }
598
+ finally {
599
+ await handle?.close();
600
+ }
601
+ }
602
+ async write(record) {
603
+ await this.recordPath(record.scope, record.id);
604
+ const directory = await containedGeneratedPath(this.root, hashCanonical(record.scope));
605
+ await mkdir(directory, { recursive: true, mode: 0o700 });
606
+ const target = await this.recordPath(record.scope, record.id);
607
+ const temporary = await containedGeneratedPath(directory, `.${secureId('job')}.tmp`);
608
+ const bytes = Buffer.from(canonicalJson({
609
+ record,
610
+ authenticationTag: this.state.authenticateJournal({ kind: 'job-record.v1', record }),
611
+ }));
612
+ if (bytes.length > this.maxRecordBytes)
613
+ throw new ServiceError('JOB_RECORD_LIMIT', 'Job record exceeds its metadata budget; large inputs and results must use linked resources');
614
+ let handle;
615
+ try {
616
+ handle = await open(temporary, 'wx', 0o600);
617
+ await handle.writeFile(bytes);
618
+ await handle.sync();
619
+ await handle.close();
620
+ handle = undefined;
621
+ await this.recordPath(record.scope, record.id);
622
+ await publishJobRecord(temporary, target);
623
+ if (process.platform !== 'win32') {
624
+ const directoryHandle = await open(directory, 'r');
625
+ try {
626
+ await directoryHandle.sync();
627
+ }
628
+ finally {
629
+ await directoryHandle.close();
630
+ }
631
+ }
632
+ }
633
+ finally {
634
+ await handle?.close();
635
+ await unlink(temporary).catch((error) => {
636
+ if (error.code !== 'ENOENT')
637
+ throw error;
638
+ });
639
+ }
640
+ }
641
+ }