agentxchain 2.155.73 → 2.156.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 (38) hide show
  1. package/bin/agentxchain.js +22 -0
  2. package/dashboard/app.js +54 -0
  3. package/dashboard/components/org-audit-trail.js +161 -0
  4. package/dashboard/components/org-history.js +140 -0
  5. package/dashboard/components/org-overview.js +145 -0
  6. package/dashboard/components/org-runs.js +168 -0
  7. package/dashboard/index.html +4 -0
  8. package/package.json +2 -1
  9. package/src/commands/ci-report.js +80 -0
  10. package/src/commands/doctor.js +22 -1
  11. package/src/commands/intake-approve.js +1 -0
  12. package/src/commands/replay.js +1 -0
  13. package/src/commands/run.js +47 -0
  14. package/src/commands/serve.js +64 -0
  15. package/src/commands/step.js +16 -0
  16. package/src/commands/verify.js +1 -0
  17. package/src/lib/adapters/local-cli-adapter.js +147 -0
  18. package/src/lib/api/execution-worker.js +192 -0
  19. package/src/lib/api/hosted-runner.js +494 -0
  20. package/src/lib/api/job-queue.js +152 -0
  21. package/src/lib/api/org-state-aggregator.js +428 -0
  22. package/src/lib/api/project-registry.js +148 -0
  23. package/src/lib/api/protocol-bridge.js +476 -0
  24. package/src/lib/approval-policy.js +12 -0
  25. package/src/lib/ci-reporter.js +188 -0
  26. package/src/lib/claude-local-auth.js +75 -1
  27. package/src/lib/connector-probe.js +21 -0
  28. package/src/lib/continuous-run.js +9 -0
  29. package/src/lib/dashboard/bridge-server.js +10 -5
  30. package/src/lib/governed-state.js +1 -1
  31. package/src/lib/intake.js +32 -6
  32. package/src/lib/normalized-config.js +2 -0
  33. package/src/lib/scope-overlap.js +214 -0
  34. package/src/lib/turn-checkpoint.js +21 -0
  35. package/src/lib/turn-result-validator.js +5 -0
  36. package/src/lib/validation.js +11 -3
  37. package/src/lib/verification-replay.js +125 -4
  38. package/src/lib/vision-reader.js +7 -2
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Hosted Runner — HTTP server implementing control plane API routes.
3
+ *
4
+ * Composes protocol-bridge.js for all protocol operations, job-queue.js for
5
+ * execution scheduling, and execution-worker.js for governed turn dispatch.
6
+ * Uses node:http with zero external dependencies.
7
+ *
8
+ * Follows the same structural pattern as dashboard/bridge-server.js but
9
+ * integrates with the protocol bridge instead of state file reads.
10
+ *
11
+ * @module hosted-runner
12
+ */
13
+
14
+ import { createServer } from 'node:http';
15
+ import { readFileSync, existsSync } from 'node:fs';
16
+ import { join, dirname, extname } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ import { createProjectRegistry } from './project-registry.js';
20
+ import { createOrgAggregator } from './org-state-aggregator.js';
21
+
22
+ import {
23
+ createRun, getRunState, listRuns, cancelRun,
24
+ getTurns, getTurn, acceptTurnResult, rejectTurnResult,
25
+ approveTransition, checkpointTurn, retryTurn,
26
+ getEvents, getDecisions, getGates, exportRun,
27
+ ProtocolError, NotFoundError, ValidationError,
28
+ AuthorizationError, ConflictError,
29
+ } from './protocol-bridge.js';
30
+
31
+ import { createJobQueue } from './job-queue.js';
32
+ import { createExecutionWorker } from './execution-worker.js';
33
+
34
+ const MAX_BODY_SIZE = 1_048_576; // 1MB
35
+
36
+ // Package version for health endpoint
37
+ let _pkgVersion = null;
38
+ function getPackageVersion() {
39
+ if (_pkgVersion) return _pkgVersion;
40
+ try {
41
+ const __dirname = dirname(fileURLToPath(import.meta.url));
42
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', '..', 'package.json'), 'utf8'));
43
+ _pkgVersion = pkg.version;
44
+ } catch {
45
+ _pkgVersion = 'unknown';
46
+ }
47
+ return _pkgVersion;
48
+ }
49
+
50
+ // ── Route Matching ────────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Match a URL path against a pattern with :param placeholders.
54
+ * Returns null if no match, or an object of extracted params.
55
+ */
56
+ function matchRoute(pattern, pathname) {
57
+ const patternParts = pattern.split('/');
58
+ const pathParts = pathname.split('/');
59
+ if (patternParts.length !== pathParts.length) return null;
60
+
61
+ const params = {};
62
+ for (let i = 0; i < patternParts.length; i++) {
63
+ if (patternParts[i].startsWith(':')) {
64
+ params[patternParts[i].slice(1)] = decodeURIComponent(pathParts[i]);
65
+ } else if (patternParts[i] !== pathParts[i]) {
66
+ return null;
67
+ }
68
+ }
69
+ return params;
70
+ }
71
+
72
+ // ── Route Table ───────────────────────────────────────────────────────────────
73
+
74
+ function buildRoutes(root, config, queue, registry, aggregator) {
75
+ return [
76
+ {
77
+ method: 'GET',
78
+ pattern: '/health',
79
+ handler: () => ({ status: 200, body: { status: 'ok', version: getPackageVersion() } }),
80
+ },
81
+ {
82
+ method: 'POST',
83
+ pattern: '/v1/projects/:proj_id/runs',
84
+ handler: (params, body) => {
85
+ const state = createRun(root, config, body || {});
86
+ // Enqueue first turn for execution worker
87
+ if (state && state.run_id) {
88
+ const role = state.active_turns
89
+ ? Object.values(state.active_turns)[0]?.assigned_role
90
+ : null;
91
+ if (role) {
92
+ queue.enqueue({
93
+ run_id: state.run_id,
94
+ project_id: params.proj_id,
95
+ role,
96
+ runtime_class: 'api_proxy',
97
+ });
98
+ }
99
+ }
100
+ return { status: 201, body: { data: state } };
101
+ },
102
+ },
103
+ {
104
+ method: 'GET',
105
+ pattern: '/v1/projects/:proj_id/runs',
106
+ handler: (params, body, query) => {
107
+ const result = listRuns(root, query.cursor || null, parseInt(query.limit || '25', 10));
108
+ return { status: 200, body: result };
109
+ },
110
+ },
111
+ {
112
+ method: 'GET',
113
+ pattern: '/v1/runs/:run_id',
114
+ handler: (params) => {
115
+ const state = getRunState(root, config);
116
+ return { status: 200, body: { data: state } };
117
+ },
118
+ },
119
+ {
120
+ method: 'POST',
121
+ pattern: '/v1/runs/:run_id/cancel',
122
+ handler: (params, body) => {
123
+ const reason = body?.reason || 'cancelled via API';
124
+ const result = cancelRun(root, reason);
125
+ return { status: 200, body: { data: result } };
126
+ },
127
+ },
128
+ {
129
+ method: 'GET',
130
+ pattern: '/v1/runs/:run_id/turns',
131
+ handler: (params, body, query) => {
132
+ const result = getTurns(root, query.cursor || null, parseInt(query.limit || '25', 10));
133
+ return { status: 200, body: result };
134
+ },
135
+ },
136
+ {
137
+ method: 'GET',
138
+ pattern: '/v1/runs/:run_id/turns/:turn_id',
139
+ handler: (params) => {
140
+ const result = getTurn(root, params.turn_id);
141
+ return { status: 200, body: { data: result } };
142
+ },
143
+ },
144
+ {
145
+ method: 'POST',
146
+ pattern: '/v1/runs/:run_id/turns/:turn_id/accept',
147
+ handler: (params, body) => {
148
+ const result = acceptTurnResult(root, config, params.turn_id, body || {});
149
+ return { status: 200, body: { data: result } };
150
+ },
151
+ },
152
+ {
153
+ method: 'POST',
154
+ pattern: '/v1/runs/:run_id/turns/:turn_id/reject',
155
+ handler: (params, body) => {
156
+ const reason = body?.reason || 'rejected via API';
157
+ const result = rejectTurnResult(root, config, params.turn_id, reason, body || {});
158
+ return { status: 200, body: { data: result } };
159
+ },
160
+ },
161
+ {
162
+ method: 'POST',
163
+ pattern: '/v1/runs/:run_id/approve-transition',
164
+ handler: (params, body) => {
165
+ const result = approveTransition(root, config, body || {});
166
+ return { status: 200, body: { data: result } };
167
+ },
168
+ },
169
+ {
170
+ method: 'POST',
171
+ pattern: '/v1/runs/:run_id/checkpoint',
172
+ handler: (params, body) => {
173
+ const turnId = body?.turn_id || params.turn_id;
174
+ const result = checkpointTurn(root, turnId);
175
+ return { status: 200, body: { data: result } };
176
+ },
177
+ },
178
+ {
179
+ method: 'POST',
180
+ pattern: '/v1/runs/:run_id/retry',
181
+ handler: (params, body) => {
182
+ const turnId = body?.turn_id;
183
+ const result = retryTurn(root, config, turnId);
184
+ return { status: 200, body: { data: result } };
185
+ },
186
+ },
187
+ {
188
+ method: 'GET',
189
+ pattern: '/v1/runs/:run_id/events',
190
+ handler: (params, body, query) => {
191
+ const result = getEvents(root, query.cursor || null, parseInt(query.limit || '25', 10));
192
+ return { status: 200, body: result };
193
+ },
194
+ },
195
+ {
196
+ method: 'GET',
197
+ pattern: '/v1/runs/:run_id/decisions',
198
+ handler: () => {
199
+ const result = getDecisions(root);
200
+ return { status: 200, body: result };
201
+ },
202
+ },
203
+ {
204
+ method: 'GET',
205
+ pattern: '/v1/runs/:run_id/gates',
206
+ handler: () => {
207
+ const result = getGates(root, config);
208
+ return { status: 200, body: result };
209
+ },
210
+ },
211
+ {
212
+ method: 'GET',
213
+ pattern: '/v1/runs/:run_id/export',
214
+ handler: () => {
215
+ const result = exportRun(root);
216
+ return { status: 200, body: { data: result } };
217
+ },
218
+ },
219
+ // ── Org Routes ──────────────────────────────────────────────────────────
220
+ {
221
+ method: 'POST',
222
+ pattern: '/v1/org/projects',
223
+ handler: (_params, body) => {
224
+ if (!body?.root) {
225
+ return { status: 422, body: { error: { code: 'validation_error', message: 'root is required' } } };
226
+ }
227
+ try {
228
+ const entry = registry.register(body.root, body.name);
229
+ return { status: 201, body: { data: entry } };
230
+ } catch (err) {
231
+ return { status: 422, body: { error: { code: 'validation_error', message: err.message } } };
232
+ }
233
+ },
234
+ },
235
+ {
236
+ method: 'GET',
237
+ pattern: '/v1/org/projects',
238
+ handler: () => {
239
+ return { status: 200, body: { data: registry.list() } };
240
+ },
241
+ },
242
+ {
243
+ method: 'DELETE',
244
+ pattern: '/v1/org/projects/:proj_id',
245
+ handler: (params) => {
246
+ registry.unregister(params.proj_id);
247
+ return { status: 200, body: { ok: true } };
248
+ },
249
+ },
250
+ {
251
+ method: 'GET',
252
+ pattern: '/v1/org/overview',
253
+ handler: () => {
254
+ return { status: 200, body: { data: aggregator.getOverview() } };
255
+ },
256
+ },
257
+ {
258
+ method: 'GET',
259
+ pattern: '/v1/org/runs',
260
+ handler: (_params, _body, query) => {
261
+ return { status: 200, body: aggregator.getRuns(query) };
262
+ },
263
+ },
264
+ {
265
+ method: 'GET',
266
+ pattern: '/v1/org/decisions',
267
+ handler: (_params, _body, query) => {
268
+ return { status: 200, body: aggregator.getDecisions(query) };
269
+ },
270
+ },
271
+ {
272
+ method: 'GET',
273
+ pattern: '/v1/org/history',
274
+ handler: (_params, _body, query) => {
275
+ return { status: 200, body: aggregator.getRunHistory(query) };
276
+ },
277
+ },
278
+ {
279
+ method: 'GET',
280
+ pattern: '/v1/org/audit-trail',
281
+ handler: (_params, _body, query) => {
282
+ return { status: 200, body: aggregator.getAuditTrail(query) };
283
+ },
284
+ },
285
+ ];
286
+ }
287
+
288
+ // ── Static File Serving ──────────────────────────────────────────────────────
289
+
290
+ const MIME_TYPES = {
291
+ '.html': 'text/html',
292
+ '.js': 'application/javascript',
293
+ '.css': 'text/css',
294
+ '.json': 'application/json',
295
+ };
296
+
297
+ function getDashboardDir() {
298
+ const __dirname = dirname(fileURLToPath(import.meta.url));
299
+ return join(__dirname, '..', '..', '..', 'dashboard');
300
+ }
301
+
302
+ function serveDashboardFile(pathname, res) {
303
+ const dashDir = getDashboardDir();
304
+ const filePath = pathname === '/' ? join(dashDir, 'index.html') : join(dashDir, pathname.slice(1));
305
+
306
+ // Prevent directory traversal
307
+ if (!filePath.startsWith(dashDir)) return false;
308
+ if (!existsSync(filePath)) return false;
309
+
310
+ try {
311
+ const content = readFileSync(filePath);
312
+ const ext = extname(filePath);
313
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
314
+ res.writeHead(200, { 'Content-Type': contentType });
315
+ res.end(content);
316
+ return true;
317
+ } catch {
318
+ return false;
319
+ }
320
+ }
321
+
322
+ // ── Error → HTTP Status Mapping ───────────────────────────────────────────────
323
+
324
+ function mapErrorToResponse(err) {
325
+ if (err instanceof NotFoundError) {
326
+ return { status: 404, body: { error: { code: err.code, message: err.message } } };
327
+ }
328
+ if (err instanceof ValidationError) {
329
+ return { status: 422, body: { error: { code: err.code, message: err.message } } };
330
+ }
331
+ if (err instanceof ProtocolError) {
332
+ return { status: 409, body: { error: { code: err.code, message: err.message } } };
333
+ }
334
+ if (err instanceof ConflictError) {
335
+ return { status: 409, body: { error: { code: err.code, message: err.message } } };
336
+ }
337
+ if (err instanceof AuthorizationError) {
338
+ return { status: 403, body: { error: { code: err.code, message: err.message } } };
339
+ }
340
+ return { status: 500, body: { error: { code: 'internal_error', message: 'Internal server error' } } };
341
+ }
342
+
343
+ // ── Request Body Parser ───────────────────────────────────────────────────────
344
+
345
+ function parseBody(req) {
346
+ return new Promise((resolve, reject) => {
347
+ const chunks = [];
348
+ let size = 0;
349
+ req.on('data', (chunk) => {
350
+ size += chunk.length;
351
+ if (size > MAX_BODY_SIZE) {
352
+ reject(new Error('Request body too large'));
353
+ req.destroy();
354
+ return;
355
+ }
356
+ chunks.push(chunk);
357
+ });
358
+ req.on('end', () => {
359
+ if (size === 0) {
360
+ resolve(null);
361
+ return;
362
+ }
363
+ try {
364
+ const raw = Buffer.concat(chunks).toString('utf8');
365
+ resolve(JSON.parse(raw));
366
+ } catch (err) {
367
+ reject(new Error(`Invalid JSON: ${err.message}`));
368
+ }
369
+ });
370
+ req.on('error', reject);
371
+ });
372
+ }
373
+
374
+ // ── Query String Parser ───────────────────────────────────────────────────────
375
+
376
+ function parseQuery(url) {
377
+ const idx = url.indexOf('?');
378
+ if (idx === -1) return {};
379
+ const qs = url.slice(idx + 1);
380
+ const params = {};
381
+ for (const pair of qs.split('&')) {
382
+ const [key, val] = pair.split('=');
383
+ if (key) params[decodeURIComponent(key)] = val ? decodeURIComponent(val) : '';
384
+ }
385
+ return params;
386
+ }
387
+
388
+ // ── Server Factory ────────────────────────────────────────────────────────────
389
+
390
+ /**
391
+ * Create a hosted runner instance.
392
+ * @param {object} options
393
+ * @param {string} options.root - project root directory
394
+ * @param {object} options.config - normalized config
395
+ * @param {number} [options.port=4100] - server port
396
+ * @param {string} [options.host='127.0.0.1'] - bind address
397
+ * @param {string[]} [options.projects=[]] - additional project root paths
398
+ * @returns {{ server: http.Server, start(): Promise<void>, stop(): Promise<void>, worker: object, queue: object, registry: object }}
399
+ */
400
+ export function createHostedRunner(options) {
401
+ const {
402
+ root,
403
+ config,
404
+ port = 4100,
405
+ host = '127.0.0.1',
406
+ projects = [],
407
+ } = options;
408
+
409
+ const queue = createJobQueue();
410
+ const worker = createExecutionWorker({ root, config, queue });
411
+
412
+ const registry = createProjectRegistry(root);
413
+ for (const projRoot of projects) {
414
+ try { registry.register(projRoot); } catch { /* skip invalid */ }
415
+ }
416
+ const aggregator = createOrgAggregator(registry);
417
+ const routes = buildRoutes(root, config, queue, registry, aggregator);
418
+
419
+ const server = createServer(async (req, res) => {
420
+ const pathname = req.url.split('?')[0];
421
+ const method = req.method.toUpperCase();
422
+ const query = parseQuery(req.url);
423
+
424
+ // Find matching route
425
+ let matched = null;
426
+ let params = null;
427
+ for (const route of routes) {
428
+ if (route.method !== method) continue;
429
+ const m = matchRoute(route.pattern, pathname);
430
+ if (m) {
431
+ matched = route;
432
+ params = m;
433
+ break;
434
+ }
435
+ }
436
+
437
+ if (!matched) {
438
+ // Static file serving for dashboard
439
+ if (method === 'GET' && !pathname.startsWith('/v1/') && pathname !== '/health') {
440
+ if (serveDashboardFile(pathname, res)) return;
441
+ }
442
+ res.writeHead(404, { 'Content-Type': 'application/json' });
443
+ res.end(JSON.stringify({ error: { code: 'not_found', message: `No route for ${method} ${pathname}` } }));
444
+ return;
445
+ }
446
+
447
+ try {
448
+ let body = null;
449
+ if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
450
+ body = await parseBody(req);
451
+ }
452
+
453
+ const result = await matched.handler(params, body, query);
454
+ res.writeHead(result.status, { 'Content-Type': 'application/json' });
455
+ res.end(JSON.stringify(result.body));
456
+ } catch (err) {
457
+ if (err.message === 'Request body too large') {
458
+ res.writeHead(413, { 'Content-Type': 'application/json' });
459
+ res.end(JSON.stringify({ error: { code: 'payload_too_large', message: 'Request body exceeds 1MB limit' } }));
460
+ return;
461
+ }
462
+ if (err.message?.startsWith('Invalid JSON:')) {
463
+ res.writeHead(400, { 'Content-Type': 'application/json' });
464
+ res.end(JSON.stringify({ error: { code: 'invalid_json', message: err.message } }));
465
+ return;
466
+ }
467
+ const mapped = mapErrorToResponse(err);
468
+ res.writeHead(mapped.status, { 'Content-Type': 'application/json' });
469
+ res.end(JSON.stringify(mapped.body));
470
+ }
471
+ });
472
+
473
+ async function start() {
474
+ return new Promise((resolve) => {
475
+ server.listen(port, host, () => {
476
+ worker.start();
477
+ resolve();
478
+ });
479
+ });
480
+ }
481
+
482
+ async function stop() {
483
+ worker.stop();
484
+ return new Promise((resolve) => {
485
+ const timeout = setTimeout(() => resolve(), 5000);
486
+ server.close(() => {
487
+ clearTimeout(timeout);
488
+ resolve();
489
+ });
490
+ });
491
+ }
492
+
493
+ return { server, start, stop, worker, queue, registry };
494
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Job Queue — in-memory FIFO queue with lease management.
3
+ *
4
+ * Implements execution plane spec behavior rules:
5
+ * - Rule 2: FIFO ordering within project
6
+ * - Rule 3: Exclusive leases with heartbeat-based liveness
7
+ * - Rule 4: Explicit finalization, crash-closed recovery (no auto-retry)
8
+ *
9
+ * @module job-queue
10
+ */
11
+
12
+ import { randomUUID } from 'node:crypto';
13
+
14
+ const DEFAULT_LEASE_DURATION_MS = 600_000; // 10min (api_proxy)
15
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
16
+ const DEFAULT_STALE_THRESHOLD_MULTIPLIER = 2;
17
+
18
+ /**
19
+ * Create an in-memory job queue.
20
+ * @param {object} [options]
21
+ * @param {number} [options.defaultLeaseDurationMs=600000]
22
+ * @param {number} [options.heartbeatIntervalMs=30000]
23
+ * @param {number} [options.staleThresholdMultiplier=2]
24
+ * @returns {JobQueue}
25
+ */
26
+ export function createJobQueue(options = {}) {
27
+ const leaseDurationMs = options.defaultLeaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
28
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
29
+ const staleMultiplier = options.staleThresholdMultiplier ?? DEFAULT_STALE_THRESHOLD_MULTIPLIER;
30
+ const staleThresholdMs = heartbeatIntervalMs * staleMultiplier;
31
+
32
+ /** @type {Map<string, Job>} */
33
+ const jobs = new Map();
34
+
35
+ /** @type {Map<string, ExecutionLease>} */
36
+ const leases = new Map();
37
+
38
+ function getLeaseDuration(runtimeClass) {
39
+ if (runtimeClass === 'local_cli') return 1_800_000; // 30min
40
+ return leaseDurationMs; // 10min default (api_proxy)
41
+ }
42
+
43
+ function enqueue(jobDef) {
44
+ const job = {
45
+ job_id: jobDef.job_id || randomUUID(),
46
+ project_id: jobDef.project_id || null,
47
+ run_id: jobDef.run_id,
48
+ turn_id: jobDef.turn_id || null,
49
+ role: jobDef.role,
50
+ runtime_id: jobDef.runtime_id || null,
51
+ runtime_class: jobDef.runtime_class || 'api_proxy',
52
+ enqueued_at: Date.now(),
53
+ status: 'waiting',
54
+ lease: null,
55
+ };
56
+ jobs.set(job.job_id, job);
57
+ return job.job_id;
58
+ }
59
+
60
+ function claim(workerId, runtimeClass) {
61
+ for (const job of jobs.values()) {
62
+ if (job.status !== 'waiting') continue;
63
+ if (runtimeClass && job.runtime_class !== runtimeClass) continue;
64
+
65
+ const now = Date.now();
66
+ const duration = getLeaseDuration(job.runtime_class);
67
+ const lease = {
68
+ lease_id: randomUUID(),
69
+ job_id: job.job_id,
70
+ worker_id: workerId,
71
+ claimed_at: now,
72
+ expires_at: now + duration,
73
+ heartbeat_at: now,
74
+ attempt: (job.lease?.attempt || 0) + 1,
75
+ };
76
+
77
+ job.status = 'claimed';
78
+ job.lease = lease;
79
+ leases.set(lease.lease_id, lease);
80
+
81
+ return { job, lease };
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function heartbeat(leaseId) {
87
+ const lease = leases.get(leaseId);
88
+ if (!lease) return false;
89
+ const job = jobs.get(lease.job_id);
90
+ if (!job || job.status !== 'claimed') return false;
91
+ if (Date.now() > lease.expires_at) return false;
92
+ lease.heartbeat_at = Date.now();
93
+ return true;
94
+ }
95
+
96
+ function finalize(leaseId, result) {
97
+ const lease = leases.get(leaseId);
98
+ if (!lease) return false;
99
+ const job = jobs.get(lease.job_id);
100
+ if (!job) return false;
101
+
102
+ job.status = result === 'completed' ? 'completed' : 'failed';
103
+ leases.delete(leaseId);
104
+ return true;
105
+ }
106
+
107
+ function expireStaleLeases() {
108
+ const now = Date.now();
109
+ const expired = [];
110
+ for (const [leaseId, lease] of leases) {
111
+ const elapsed = now - lease.heartbeat_at;
112
+ if (elapsed > staleThresholdMs) {
113
+ const job = jobs.get(lease.job_id);
114
+ if (job && job.status === 'claimed') {
115
+ job.status = 'needs_recovery';
116
+ expired.push(lease.job_id);
117
+ }
118
+ leases.delete(leaseId);
119
+ }
120
+ }
121
+ return expired;
122
+ }
123
+
124
+ function getJobs(filter) {
125
+ const result = [];
126
+ for (const job of jobs.values()) {
127
+ if (filter && filter.status && job.status !== filter.status) continue;
128
+ if (filter && filter.run_id && job.run_id !== filter.run_id) continue;
129
+ result.push(job);
130
+ }
131
+ return result;
132
+ }
133
+
134
+ function getStatus() {
135
+ const counts = { total: 0, waiting: 0, claimed: 0, completed: 0, failed: 0, needs_recovery: 0 };
136
+ for (const job of jobs.values()) {
137
+ counts.total++;
138
+ counts[job.status] = (counts[job.status] || 0) + 1;
139
+ }
140
+ return counts;
141
+ }
142
+
143
+ return {
144
+ enqueue,
145
+ claim,
146
+ heartbeat,
147
+ finalize,
148
+ expireStaleLeases,
149
+ getJobs,
150
+ getStatus,
151
+ };
152
+ }