llm-cli-gateway 2.13.2 → 2.14.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 (79) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +68 -29
  3. package/dist/acp/client.d.ts +29 -1
  4. package/dist/acp/client.js +78 -4
  5. package/dist/acp/errors.d.ts +9 -1
  6. package/dist/acp/errors.js +19 -0
  7. package/dist/acp/event-normalizer.d.ts +12 -0
  8. package/dist/acp/event-normalizer.js +16 -0
  9. package/dist/acp/flight-redaction.d.ts +3 -0
  10. package/dist/acp/flight-redaction.js +3 -0
  11. package/dist/acp/permission-bridge.js +11 -5
  12. package/dist/acp/process-manager.d.ts +2 -1
  13. package/dist/acp/process-manager.js +43 -4
  14. package/dist/acp/provider-registry.js +19 -11
  15. package/dist/acp/runtime.d.ts +8 -0
  16. package/dist/acp/runtime.js +47 -4
  17. package/dist/acp/types.d.ts +3083 -55
  18. package/dist/acp/types.js +242 -5
  19. package/dist/async-job-manager.d.ts +38 -1
  20. package/dist/async-job-manager.js +287 -20
  21. package/dist/codex-json-parser.d.ts +1 -0
  22. package/dist/codex-json-parser.js +6 -0
  23. package/dist/config.d.ts +19 -0
  24. package/dist/config.js +84 -1
  25. package/dist/flight-recorder.d.ts +2 -0
  26. package/dist/flight-recorder.js +20 -0
  27. package/dist/gemini-json-parser.d.ts +2 -0
  28. package/dist/gemini-json-parser.js +45 -8
  29. package/dist/grok-json-parser.d.ts +14 -0
  30. package/dist/grok-json-parser.js +156 -0
  31. package/dist/index.d.ts +47 -2
  32. package/dist/index.js +504 -122
  33. package/dist/job-store.d.ts +119 -11
  34. package/dist/job-store.js +372 -42
  35. package/dist/model-registry.d.ts +1 -0
  36. package/dist/model-registry.js +46 -0
  37. package/dist/oauth.js +2 -2
  38. package/dist/postgres-job-store-worker.d.ts +1 -0
  39. package/dist/postgres-job-store-worker.js +444 -0
  40. package/dist/pricing.d.ts +3 -2
  41. package/dist/provider-acp-capabilities.d.ts +52 -0
  42. package/dist/provider-acp-capabilities.js +101 -0
  43. package/dist/provider-admin-tools.d.ts +100 -0
  44. package/dist/provider-admin-tools.js +572 -0
  45. package/dist/provider-capability-cache.d.ts +46 -0
  46. package/dist/provider-capability-cache.js +248 -0
  47. package/dist/provider-capability-discovery.d.ts +85 -0
  48. package/dist/provider-capability-discovery.js +461 -0
  49. package/dist/provider-capability-resolver.d.ts +29 -0
  50. package/dist/provider-capability-resolver.js +92 -0
  51. package/dist/provider-definition-assertions.d.ts +9 -0
  52. package/dist/provider-definition-assertions.js +147 -0
  53. package/dist/provider-definitions.d.ts +127 -0
  54. package/dist/provider-definitions.js +758 -0
  55. package/dist/provider-help-parser.d.ts +34 -0
  56. package/dist/provider-help-parser.js +203 -0
  57. package/dist/provider-model-discovery.d.ts +30 -0
  58. package/dist/provider-model-discovery.js +229 -0
  59. package/dist/provider-output-metadata.d.ts +7 -0
  60. package/dist/provider-output-metadata.js +68 -0
  61. package/dist/provider-schema-builder.d.ts +22 -0
  62. package/dist/provider-schema-builder.js +55 -0
  63. package/dist/provider-surface-generator.d.ts +98 -0
  64. package/dist/provider-surface-generator.js +140 -0
  65. package/dist/provider-tool-capabilities.d.ts +3 -2
  66. package/dist/provider-tool-capabilities.js +77 -62
  67. package/dist/request-helpers.d.ts +37 -4
  68. package/dist/request-helpers.js +134 -0
  69. package/dist/resources.d.ts +6 -1
  70. package/dist/resources.js +64 -192
  71. package/dist/session-manager.js +18 -11
  72. package/dist/sqlite-driver.d.ts +1 -1
  73. package/dist/sqlite-driver.js +2 -1
  74. package/dist/stream-json-parser.d.ts +1 -0
  75. package/dist/stream-json-parser.js +3 -0
  76. package/dist/upstream-contracts.d.ts +17 -1
  77. package/dist/upstream-contracts.js +209 -36
  78. package/npm-shrinkwrap.json +2 -2
  79. package/package.json +8 -3
package/dist/job-store.js CHANGED
@@ -1,9 +1,12 @@
1
- import { chmodSync } from "fs";
1
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync } from "fs";
2
2
  import os from "os";
3
3
  import path from "path";
4
4
  import { createHash } from "crypto";
5
+ import { Worker } from "worker_threads";
6
+ import { fileURLToPath } from "url";
5
7
  import { openDatabase } from "./sqlite-driver.js";
6
8
  import { noopLogger } from "./logger.js";
9
+ import { DEFAULT_INSTANCE_LEASE_TTL_MS, DEFAULT_HTTP_JOB_GRACE_MS } from "./config.js";
7
10
  export function resolveJobStoreDbPath() {
8
11
  const configured = process.env.LLM_GATEWAY_JOBS_DB ?? process.env.LLM_GATEWAY_LOGS_DB;
9
12
  if (configured !== undefined) {
@@ -17,6 +20,7 @@ export function resolveJobStoreDbPath() {
17
20
  }
18
21
  const DEFAULT_RETENTION_DAYS = 30;
19
22
  const FAR_FUTURE_ISO = "9999-12-31T23:59:59.999Z";
23
+ const SQLITE_NOW_MS = "CAST(ROUND((julianday('now') - 2440587.5) * 86400000.0) AS INTEGER)";
20
24
  export function resolveJobRetentionMs() {
21
25
  const raw = process.env.LLM_GATEWAY_JOB_RETENTION_DAYS;
22
26
  const days = raw ? Number(raw) : DEFAULT_RETENTION_DAYS;
@@ -61,6 +65,8 @@ function rowToRecord(row) {
61
65
  transport: row.transport ?? "process",
62
66
  httpStatus: row.http_status ?? null,
63
67
  payloadJson: row.payload_json ?? null,
68
+ ownerInstance: row.owner_instance ?? null,
69
+ leaseDeadline: row.lease_deadline == null ? null : Number(row.lease_deadline),
64
70
  };
65
71
  }
66
72
  function ensureJobsOwnerColumn(db) {
@@ -83,6 +89,16 @@ function ensureJobsTransportColumns(db) {
83
89
  db.exec("ALTER TABLE jobs ADD COLUMN payload_json TEXT");
84
90
  }
85
91
  }
92
+ function ensureJobsLeaseColumns(db) {
93
+ const cols = db.prepare("PRAGMA table_info(jobs)").all();
94
+ const names = new Set(cols.map(col => col?.name));
95
+ if (!names.has("owner_instance")) {
96
+ db.exec("ALTER TABLE jobs ADD COLUMN owner_instance TEXT");
97
+ }
98
+ if (!names.has("lease_deadline")) {
99
+ db.exec("ALTER TABLE jobs ADD COLUMN lease_deadline INTEGER");
100
+ }
101
+ }
86
102
  export function isValidationRunStore(store) {
87
103
  return (typeof store === "object" &&
88
104
  store !== null &&
@@ -95,6 +111,7 @@ export class SqliteJobStore {
95
111
  db;
96
112
  retentionMs;
97
113
  dedupWindowMs;
114
+ leaseTtlMs;
98
115
  insertStmt;
99
116
  updateOutputStmt;
100
117
  updateCompleteStmt;
@@ -103,11 +120,21 @@ export class SqliteJobStore {
103
120
  selectRunningOrphansStmt;
104
121
  markOrphanedStmt;
105
122
  deleteExpiredStmt;
123
+ markRunningStmt;
124
+ registerInstanceStmt;
125
+ heartbeatInstanceStmt;
126
+ heartbeatJobsStmt;
127
+ deregisterInstanceStmt;
128
+ selectStaleCandidatesStmt;
129
+ orphanExpiredStmt;
130
+ advanceLeaseStmt;
131
+ gcInstancesStmt;
106
132
  constructor(dbPath, logger = noopLogger, options = {}) {
107
133
  this.logger = logger;
108
134
  this.db = openDatabase(dbPath);
109
135
  this.db.exec("PRAGMA journal_mode = WAL");
110
136
  this.db.exec("PRAGMA synchronous = NORMAL");
137
+ this.db.exec("PRAGMA busy_timeout = 5000");
111
138
  this.db.exec(`
112
139
  CREATE TABLE IF NOT EXISTS jobs (
113
140
  id TEXT PRIMARY KEY,
@@ -129,12 +156,25 @@ export class SqliteJobStore {
129
156
  owner_principal TEXT,
130
157
  transport TEXT NOT NULL DEFAULT 'process',
131
158
  http_status INTEGER,
132
- payload_json TEXT
159
+ payload_json TEXT,
160
+ owner_instance TEXT,
161
+ lease_deadline INTEGER
133
162
  );
134
163
  CREATE INDEX IF NOT EXISTS idx_jobs_request_key ON jobs(request_key);
135
164
  CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
136
165
  CREATE INDEX IF NOT EXISTS idx_jobs_expires_at ON jobs(expires_at);
137
166
  CREATE INDEX IF NOT EXISTS idx_jobs_request_key_finished ON jobs(request_key, finished_at);
167
+
168
+ CREATE TABLE IF NOT EXISTS gateway_instances (
169
+ instance_id TEXT PRIMARY KEY,
170
+ role TEXT,
171
+ hostname TEXT,
172
+ pid INTEGER,
173
+ started_at INTEGER NOT NULL,
174
+ last_heartbeat INTEGER NOT NULL
175
+ );
176
+ CREATE INDEX IF NOT EXISTS idx_gateway_instances_heartbeat
177
+ ON gateway_instances(last_heartbeat);
138
178
  `);
139
179
  this.db.exec(`
140
180
  CREATE TABLE IF NOT EXISTS validation_runs (
@@ -174,6 +214,8 @@ export class SqliteJobStore {
174
214
  `);
175
215
  ensureJobsOwnerColumn(this.db);
176
216
  ensureJobsTransportColumns(this.db);
217
+ ensureJobsLeaseColumns(this.db);
218
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_jobs_owner_status ON jobs(owner_instance, status)");
177
219
  if (process.platform !== "win32") {
178
220
  try {
179
221
  chmodSync(dbPath, 0o600);
@@ -183,15 +225,17 @@ export class SqliteJobStore {
183
225
  }
184
226
  this.retentionMs = options.retentionMs ?? resolveJobRetentionMs();
185
227
  this.dedupWindowMs = options.dedupWindowMs ?? resolveDedupWindowMs();
228
+ this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_INSTANCE_LEASE_TTL_MS;
186
229
  this.insertStmt = this.db.prepare(`
187
230
  INSERT INTO jobs (id, correlation_id, request_key, cli, args_json, output_format,
188
231
  status, exit_code, stdout, stderr, output_truncated, error,
189
232
  started_at, finished_at, pid, expires_at, owner_principal,
190
- transport, http_status, payload_json)
233
+ transport, http_status, payload_json, owner_instance, lease_deadline)
191
234
  VALUES (@id, @correlation_id, @request_key, @cli, @args_json, @output_format,
192
- @status, @exit_code, @stdout, @stderr, @output_truncated, @error,
235
+ 'queued', @exit_code, @stdout, @stderr, @output_truncated, @error,
193
236
  @started_at, @finished_at, @pid, @expires_at, @owner_principal,
194
- @transport, @http_status, @payload_json)
237
+ @transport, @http_status, @payload_json, @owner_instance,
238
+ ${SQLITE_NOW_MS} + @lease_ttl_ms)
195
239
  `);
196
240
  this.updateOutputStmt = this.db.prepare(`
197
241
  UPDATE jobs SET stdout = @stdout, stderr = @stderr, output_truncated = @output_truncated
@@ -201,15 +245,18 @@ export class SqliteJobStore {
201
245
  UPDATE jobs SET status = @status, exit_code = @exit_code, stdout = @stdout, stderr = @stderr,
202
246
  output_truncated = @output_truncated, error = @error,
203
247
  finished_at = @finished_at, expires_at = @expires_at,
204
- http_status = @http_status
205
- WHERE id = @id
248
+ http_status = @http_status, lease_deadline = NULL
249
+ WHERE id = @id AND status IN ('queued', 'running', 'orphaned')
206
250
  `);
207
251
  this.getByIdStmt = this.db.prepare(`SELECT * FROM jobs WHERE id = ?`);
208
252
  this.findByRequestKeyStmt = this.db.prepare(`
209
253
  SELECT * FROM jobs
210
254
  WHERE request_key = ?
211
255
  AND started_at >= ?
212
- AND status IN ('running', 'completed')
256
+ AND (
257
+ status IN ('running', 'completed')
258
+ OR (status = 'queued' AND lease_deadline IS NOT NULL AND lease_deadline >= ${SQLITE_NOW_MS})
259
+ )
213
260
  ORDER BY started_at DESC
214
261
  LIMIT 1
215
262
  `);
@@ -226,6 +273,56 @@ export class SqliteJobStore {
226
273
  WHERE status = 'running'
227
274
  `);
228
275
  this.deleteExpiredStmt = this.db.prepare(`DELETE FROM jobs WHERE expires_at < ?`);
276
+ this.markRunningStmt = this.db.prepare(`
277
+ UPDATE jobs
278
+ SET status = 'running', pid = @pid, lease_deadline = ${SQLITE_NOW_MS} + @lease_ttl_ms
279
+ WHERE id = @id AND status = 'queued'
280
+ `);
281
+ this.registerInstanceStmt = this.db.prepare(`
282
+ INSERT INTO gateway_instances (instance_id, role, hostname, pid, started_at, last_heartbeat)
283
+ VALUES (@instance_id, @role, @hostname, @pid, ${SQLITE_NOW_MS}, ${SQLITE_NOW_MS})
284
+ ON CONFLICT(instance_id) DO UPDATE SET
285
+ role = excluded.role, hostname = excluded.hostname, pid = excluded.pid,
286
+ last_heartbeat = excluded.last_heartbeat
287
+ `);
288
+ this.heartbeatInstanceStmt = this.db.prepare(`
289
+ UPDATE gateway_instances SET last_heartbeat = ${SQLITE_NOW_MS} WHERE instance_id = @instance_id
290
+ `);
291
+ this.heartbeatJobsStmt = this.db.prepare(`
292
+ UPDATE jobs SET lease_deadline = ${SQLITE_NOW_MS} + @lease_ttl_ms
293
+ WHERE owner_instance = @instance_id AND status IN ('queued', 'running')
294
+ `);
295
+ this.deregisterInstanceStmt = this.db.prepare(`DELETE FROM gateway_instances WHERE instance_id = @instance_id`);
296
+ this.selectStaleCandidatesStmt = this.db.prepare(`
297
+ SELECT j.id AS id, j.pid AS pid, j.transport AS transport,
298
+ j.owner_instance AS owner_instance, gi.hostname AS hostname
299
+ FROM jobs j
300
+ LEFT JOIN gateway_instances gi ON gi.instance_id = j.owner_instance
301
+ WHERE j.status IN ('queued', 'running')
302
+ AND j.transport = 'process'
303
+ AND j.pid IS NOT NULL
304
+ AND (j.lease_deadline IS NULL OR j.lease_deadline < ${SQLITE_NOW_MS})
305
+ `);
306
+ this.orphanExpiredStmt = this.db.prepare(`
307
+ UPDATE jobs
308
+ SET status = 'orphaned',
309
+ error = COALESCE(error, 'owning gateway instance is no longer alive'),
310
+ finished_at = COALESCE(finished_at, @now_iso),
311
+ expires_at = @expires_iso,
312
+ lease_deadline = NULL
313
+ WHERE status IN ('queued', 'running')
314
+ AND (lease_deadline IS NULL OR lease_deadline < ${SQLITE_NOW_MS})
315
+ AND (transport <> 'http'
316
+ OR started_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', @http_grace_modifier))
317
+ AND id NOT IN (SELECT value FROM json_each(@exclude_json))
318
+ RETURNING id, correlation_id, started_at, stdout, stderr, exit_code, transport, http_status
319
+ `);
320
+ this.advanceLeaseStmt = this.db.prepare(`
321
+ UPDATE jobs SET lease_deadline = ${SQLITE_NOW_MS} + @lease_ttl_ms, pid = NULL
322
+ WHERE status IN ('queued', 'running')
323
+ AND id IN (SELECT value FROM json_each(@ids_json))
324
+ `);
325
+ this.gcInstancesStmt = this.db.prepare(`DELETE FROM gateway_instances WHERE last_heartbeat < ${SQLITE_NOW_MS} - @gc_ms`);
229
326
  }
230
327
  recordStart(input) {
231
328
  this.insertStmt.run({
@@ -235,7 +332,6 @@ export class SqliteJobStore {
235
332
  cli: input.cli,
236
333
  args_json: JSON.stringify(input.args),
237
334
  output_format: input.outputFormat ?? null,
238
- status: "running",
239
335
  exit_code: null,
240
336
  stdout: "",
241
337
  stderr: "",
@@ -249,7 +345,74 @@ export class SqliteJobStore {
249
345
  transport: input.transport ?? "process",
250
346
  http_status: null,
251
347
  payload_json: input.payloadJson ?? null,
348
+ owner_instance: input.ownerInstance ?? null,
349
+ lease_ttl_ms: this.leaseTtlMs,
350
+ });
351
+ }
352
+ markRunning(id, opts) {
353
+ const result = this.markRunningStmt.run({
354
+ id,
355
+ pid: opts.pid,
356
+ lease_ttl_ms: this.leaseTtlMs,
252
357
  });
358
+ return Number(result.changes) > 0;
359
+ }
360
+ registerInstance(meta) {
361
+ this.registerInstanceStmt.run({
362
+ instance_id: meta.instanceId,
363
+ role: meta.role ?? null,
364
+ hostname: meta.hostname ?? null,
365
+ pid: meta.pid ?? null,
366
+ });
367
+ }
368
+ heartbeat(instanceId) {
369
+ this.heartbeatInstanceStmt.run({ instance_id: instanceId });
370
+ this.heartbeatJobsStmt.run({ instance_id: instanceId, lease_ttl_ms: this.leaseTtlMs });
371
+ }
372
+ deregisterInstance(instanceId) {
373
+ this.deregisterInstanceStmt.run({ instance_id: instanceId });
374
+ }
375
+ selectStaleProcessCandidates(_leaseTtlMs, _httpJobGraceMs) {
376
+ const rows = this.selectStaleCandidatesStmt.all();
377
+ return rows.map(r => ({
378
+ id: r.id,
379
+ pid: r.pid,
380
+ transport: r.transport ?? "process",
381
+ ownerInstance: r.owner_instance ?? null,
382
+ hostname: r.hostname ?? null,
383
+ }));
384
+ }
385
+ recoverStaleJobs(leaseTtlMs, httpJobGraceMs, liveConfirmedIds = []) {
386
+ const excludeJson = JSON.stringify(liveConfirmedIds);
387
+ const httpGraceModifier = `-${httpJobGraceMs / 1000} seconds`;
388
+ const run = this.db.withTransaction(() => {
389
+ if (liveConfirmedIds.length > 0) {
390
+ this.advanceLeaseStmt.run({ ids_json: excludeJson, lease_ttl_ms: leaseTtlMs });
391
+ }
392
+ const nowIso = new Date().toISOString();
393
+ const expiresAt = new Date(Date.now() + this.retentionMs).toISOString();
394
+ const rows = this.orphanExpiredStmt.all({
395
+ now_iso: nowIso,
396
+ expires_iso: expiresAt,
397
+ http_grace_modifier: httpGraceModifier,
398
+ exclude_json: excludeJson,
399
+ });
400
+ return rows.map(r => ({
401
+ id: r.id,
402
+ correlationId: r.correlation_id,
403
+ startedAt: r.started_at,
404
+ stdout: r.stdout ?? "",
405
+ stderr: r.stderr ?? "",
406
+ exitCode: r.exit_code,
407
+ transport: r.transport ?? "process",
408
+ httpStatus: r.http_status ?? null,
409
+ }));
410
+ });
411
+ return run();
412
+ }
413
+ gcInstances(instanceGcMs) {
414
+ const result = this.gcInstancesStmt.run({ gc_ms: instanceGcMs });
415
+ return Number(result.changes);
253
416
  }
254
417
  recordOutput(id, stdout, stderr, outputTruncated) {
255
418
  this.updateOutputStmt.run({
@@ -284,21 +447,8 @@ export class SqliteJobStore {
284
447
  return row ? rowToRecord(row) : null;
285
448
  }
286
449
  markOrphanedOnStartup() {
287
- const now = new Date().toISOString();
288
- const expiresAt = new Date(Date.now() + this.retentionMs).toISOString();
289
- const rows = this.selectRunningOrphansStmt.all();
290
- const orphaned = rows.map(row => ({
291
- id: row.id,
292
- correlationId: row.correlation_id,
293
- startedAt: row.started_at,
294
- stdout: row.stdout ?? "",
295
- stderr: row.stderr ?? "",
296
- exitCode: row.exit_code,
297
- transport: row.transport ?? "process",
298
- httpStatus: row.http_status ?? null,
299
- }));
300
- const result = this.markOrphanedStmt.run(now, expiresAt);
301
- return { count: Number(result.changes), orphaned };
450
+ const orphaned = this.recoverStaleJobs(this.leaseTtlMs, DEFAULT_HTTP_JOB_GRACE_MS);
451
+ return { count: orphaned.length, orphaned };
302
452
  }
303
453
  evictExpired() {
304
454
  const now = new Date().toISOString();
@@ -460,9 +610,11 @@ export class MemoryJobStore {
460
610
  rows = new Map();
461
611
  retentionMs;
462
612
  dedupWindowMs;
613
+ leaseTtlMs;
463
614
  constructor(options = {}) {
464
615
  this.retentionMs = options.retentionMs ?? resolveJobRetentionMs();
465
616
  this.dedupWindowMs = options.dedupWindowMs ?? resolveDedupWindowMs();
617
+ this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_INSTANCE_LEASE_TTL_MS;
466
618
  }
467
619
  recordStart(input) {
468
620
  this.rows.set(input.id, {
@@ -472,7 +624,7 @@ export class MemoryJobStore {
472
624
  cli: input.cli,
473
625
  argsJson: JSON.stringify(input.args),
474
626
  outputFormat: input.outputFormat ?? null,
475
- status: "running",
627
+ status: "queued",
476
628
  exitCode: null,
477
629
  stdout: "",
478
630
  stderr: "",
@@ -486,8 +638,39 @@ export class MemoryJobStore {
486
638
  transport: input.transport ?? "process",
487
639
  httpStatus: null,
488
640
  payloadJson: input.payloadJson ?? null,
641
+ ownerInstance: input.ownerInstance ?? null,
642
+ leaseDeadline: Date.now() + this.leaseTtlMs,
489
643
  });
490
644
  }
645
+ markRunning(id, opts) {
646
+ const row = this.rows.get(id);
647
+ if (!row || row.status !== "queued")
648
+ return false;
649
+ row.status = "running";
650
+ row.pid = opts.pid;
651
+ row.leaseDeadline = Date.now() + this.leaseTtlMs;
652
+ return true;
653
+ }
654
+ registerInstance(_meta) { }
655
+ heartbeat(instanceId) {
656
+ const deadline = Date.now() + this.leaseTtlMs;
657
+ for (const row of this.rows.values()) {
658
+ if (row.ownerInstance === instanceId &&
659
+ (row.status === "queued" || row.status === "running")) {
660
+ row.leaseDeadline = deadline;
661
+ }
662
+ }
663
+ }
664
+ deregisterInstance(_instanceId) { }
665
+ selectStaleProcessCandidates(_leaseTtlMs, _httpJobGraceMs) {
666
+ return [];
667
+ }
668
+ recoverStaleJobs(_leaseTtlMs, _httpJobGraceMs, _liveConfirmedIds) {
669
+ return [];
670
+ }
671
+ gcInstances(_instanceGcMs) {
672
+ return 0;
673
+ }
491
674
  recordOutput(id, stdout, stderr, outputTruncated) {
492
675
  const row = this.rows.get(id);
493
676
  if (!row)
@@ -500,6 +683,8 @@ export class MemoryJobStore {
500
683
  const row = this.rows.get(input.id);
501
684
  if (!row)
502
685
  return;
686
+ if (row.status !== "queued" && row.status !== "running" && row.status !== "orphaned")
687
+ return;
503
688
  row.status = input.status;
504
689
  row.exitCode = input.exitCode;
505
690
  row.stdout = input.stdout;
@@ -508,6 +693,7 @@ export class MemoryJobStore {
508
693
  row.error = input.error;
509
694
  row.finishedAt = input.finishedAt;
510
695
  row.expiresAt = new Date(Date.parse(input.finishedAt) + this.retentionMs).toISOString();
696
+ row.leaseDeadline = null;
511
697
  if (input.httpStatus !== undefined)
512
698
  row.httpStatus = input.httpStatus;
513
699
  }
@@ -517,11 +703,15 @@ export class MemoryJobStore {
517
703
  }
518
704
  findByRequestKey(requestKey) {
519
705
  const cutoffMs = Date.now() - this.dedupWindowMs;
706
+ const nowMs = Date.now();
520
707
  let best = null;
521
708
  for (const row of this.rows.values()) {
522
709
  if (row.requestKey !== requestKey)
523
710
  continue;
524
- if (row.status !== "running" && row.status !== "completed")
711
+ const reusable = row.status === "running" ||
712
+ row.status === "completed" ||
713
+ (row.status === "queued" && row.leaseDeadline !== null && row.leaseDeadline >= nowMs);
714
+ if (!reusable)
525
715
  continue;
526
716
  if (Date.parse(row.startedAt) < cutoffMs)
527
717
  continue;
@@ -550,38 +740,178 @@ export class MemoryJobStore {
550
740
  }
551
741
  }
552
742
  export class PostgresJobStore {
553
- constructor(_dsn, _logger = noopLogger) {
554
- throw new Error("PostgresJobStore is not yet implemented. Use backend = 'sqlite' (single-instance) or " +
555
- "backend = 'memory' (ephemeral) until the Postgres backend ships.");
743
+ logger;
744
+ worker;
745
+ tmpDir;
746
+ nextRequestId = 0;
747
+ closed = false;
748
+ constructor(dsn, logger = noopLogger, options = {}) {
749
+ this.logger = logger;
750
+ if (!dsn) {
751
+ throw new Error("PostgresJobStore requires a non-empty DSN");
752
+ }
753
+ this.tmpDir = mkdtempSync(path.join(os.tmpdir(), "llm-gateway-pg-job-store-"));
754
+ this.worker = new Worker(resolvePostgresWorkerUrl(), {
755
+ execArgv: [],
756
+ workerData: {
757
+ dsn,
758
+ retentionMs: options.retentionMs ?? resolveJobRetentionMs(),
759
+ dedupWindowMs: options.dedupWindowMs ?? resolveDedupWindowMs(),
760
+ leaseTtlMs: options.leaseTtlMs ?? DEFAULT_INSTANCE_LEASE_TTL_MS,
761
+ farFutureIso: FAR_FUTURE_ISO,
762
+ connectionTimeoutMillis: 5000,
763
+ },
764
+ });
765
+ try {
766
+ this.syncCall("init");
767
+ }
768
+ catch (err) {
769
+ this.closed = true;
770
+ void this.worker.terminate();
771
+ rmSync(this.tmpDir, { recursive: true, force: true });
772
+ throw err;
773
+ }
774
+ }
775
+ syncCall(method, ...args) {
776
+ if (this.closed && method !== "close") {
777
+ throw new Error("PostgresJobStore is closed");
778
+ }
779
+ const shared = new SharedArrayBuffer(4);
780
+ const state = new Int32Array(shared);
781
+ const resultPath = path.join(this.tmpDir, `result-${process.pid}-${++this.nextRequestId}.json`);
782
+ this.worker.postMessage({ method, args, resultPath, shared });
783
+ const wait = Atomics.wait(state, 0, 0, method === "init" ? 10_000 : 30_000);
784
+ if (wait !== "ok" && wait !== "not-equal") {
785
+ throw new Error(`PostgresJobStore ${method} wait failed: ${wait}`);
786
+ }
787
+ if (Atomics.load(state, 0) === 2) {
788
+ throw new Error(`PostgresJobStore ${method} worker could not write its result`);
789
+ }
790
+ let payload;
791
+ try {
792
+ payload = JSON.parse(readFileSync(resultPath, "utf8"));
793
+ }
794
+ finally {
795
+ rmSync(resultPath, { force: true });
796
+ }
797
+ if (!payload.ok) {
798
+ const err = new Error(payload.error.message);
799
+ if (payload.error.stack)
800
+ err.stack = payload.error.stack;
801
+ throw err;
802
+ }
803
+ return payload.value;
804
+ }
805
+ recordStart(input) {
806
+ this.syncCall("recordStart", input);
807
+ }
808
+ markRunning(id, opts) {
809
+ return this.syncCall("markRunning", id, opts);
810
+ }
811
+ registerInstance(meta) {
812
+ this.syncCall("registerInstance", meta);
556
813
  }
557
- recordStart() {
558
- throw new Error("not implemented");
814
+ heartbeat(instanceId) {
815
+ this.syncCall("heartbeat", instanceId);
559
816
  }
560
- recordOutput() {
561
- throw new Error("not implemented");
817
+ deregisterInstance(instanceId) {
818
+ this.syncCall("deregisterInstance", instanceId);
819
+ }
820
+ selectStaleProcessCandidates(leaseTtlMs, httpJobGraceMs) {
821
+ return this.syncCall("selectStaleProcessCandidates", leaseTtlMs, httpJobGraceMs);
822
+ }
823
+ recoverStaleJobs(leaseTtlMs, httpJobGraceMs, liveConfirmedIds = []) {
824
+ const result = this.syncCall("recoverStaleJobs", leaseTtlMs, httpJobGraceMs, liveConfirmedIds);
825
+ return result.orphaned.map(row => ({
826
+ id: row.id,
827
+ correlationId: row.correlation_id,
828
+ startedAt: row.started_at,
829
+ stdout: row.stdout ?? "",
830
+ stderr: row.stderr ?? "",
831
+ exitCode: row.exit_code,
832
+ transport: row.transport ?? "process",
833
+ httpStatus: row.http_status ?? null,
834
+ }));
835
+ }
836
+ gcInstances(instanceGcMs) {
837
+ return this.syncCall("gcInstances", instanceGcMs);
838
+ }
839
+ recordOutput(id, stdout, stderr, outputTruncated) {
840
+ this.syncCall("recordOutput", id, stdout, stderr, outputTruncated);
562
841
  }
563
- recordComplete() {
564
- throw new Error("not implemented");
842
+ recordComplete(input) {
843
+ this.syncCall("recordComplete", input);
565
844
  }
566
- getById() {
567
- throw new Error("not implemented");
845
+ getById(id) {
846
+ const row = this.syncCall("getById", id);
847
+ return row ? rowToRecord(row) : null;
568
848
  }
569
- findByRequestKey() {
570
- throw new Error("not implemented");
849
+ findByRequestKey(requestKey) {
850
+ const row = this.syncCall("findByRequestKey", requestKey);
851
+ return row ? rowToRecord(row) : null;
571
852
  }
572
853
  markOrphanedOnStartup() {
573
- throw new Error("not implemented");
854
+ const orphaned = this.recoverStaleJobs(DEFAULT_INSTANCE_LEASE_TTL_MS, DEFAULT_HTTP_JOB_GRACE_MS);
855
+ return { count: orphaned.length, orphaned };
574
856
  }
575
857
  evictExpired() {
576
- throw new Error("not implemented");
858
+ return this.syncCall("evictExpired");
859
+ }
860
+ recordValidationRun(run) {
861
+ this.syncCall("recordValidationRun", run);
862
+ }
863
+ getValidationRun(validationId) {
864
+ const row = this.syncCall("getValidationRun", validationId);
865
+ return row ? rowToValidationRunRecord(row) : null;
866
+ }
867
+ setValidationJudgeLink(validationId, judgeLink) {
868
+ this.syncCall("setValidationJudgeLink", validationId, judgeLink);
869
+ }
870
+ setValidationRunStatus(validationId, status) {
871
+ this.syncCall("setValidationRunStatus", validationId, status);
872
+ }
873
+ getValidationRunIdByJobId(jobId) {
874
+ return this.syncCall("getValidationRunIdByJobId", jobId);
875
+ }
876
+ recordValidationReceipt(receipt) {
877
+ this.syncCall("recordValidationReceipt", receipt);
878
+ }
879
+ getValidationReceipt(validationId) {
880
+ const row = this.syncCall("getValidationReceipt", validationId);
881
+ return row ? rowToValidationReceiptRecord(row) : null;
577
882
  }
578
883
  close() {
884
+ if (this.closed)
885
+ return;
886
+ try {
887
+ this.syncCall("close");
888
+ }
889
+ catch (err) {
890
+ this.logger.error("PostgresJobStore close failed", err);
891
+ }
892
+ finally {
893
+ this.closed = true;
894
+ void this.worker.terminate();
895
+ rmSync(this.tmpDir, { recursive: true, force: true });
896
+ }
579
897
  }
580
898
  }
899
+ function resolvePostgresWorkerUrl() {
900
+ const sibling = new URL("./postgres-job-store-worker.js", import.meta.url);
901
+ if (existsSync(fileURLToPath(sibling)))
902
+ return sibling;
903
+ if (import.meta.url.endsWith("/src/job-store.ts")) {
904
+ const built = new URL("../dist/postgres-job-store-worker.js", import.meta.url);
905
+ if (existsSync(fileURLToPath(built)))
906
+ return built;
907
+ }
908
+ throw new Error("PostgresJobStore worker module is missing. Run `npm run build` before using backend = 'postgres'.");
909
+ }
581
910
  export function createJobStore(config, logger = noopLogger) {
582
911
  const opts = {
583
912
  retentionMs: config.retentionDays * 24 * 60 * 60 * 1000,
584
913
  dedupWindowMs: config.dedupWindowMs,
914
+ leaseTtlMs: config.instanceLeaseTtlMs,
585
915
  };
586
916
  switch (config.backend) {
587
917
  case "none":
@@ -589,7 +919,7 @@ export function createJobStore(config, logger = noopLogger) {
589
919
  case "memory":
590
920
  return new MemoryJobStore(opts);
591
921
  case "postgres":
592
- return new PostgresJobStore(config.dsn ?? "", logger);
922
+ return new PostgresJobStore(config.dsn ?? "", logger, opts);
593
923
  case "sqlite":
594
924
  default:
595
925
  if (!config.path) {
@@ -17,6 +17,7 @@ export interface CliInfo {
17
17
  aliases?: Record<string, string>;
18
18
  modelMetadata?: Record<string, ModelMetadata>;
19
19
  warnings?: string[];
20
+ defaultAgent?: string;
20
21
  }
21
22
  export type CliInfoMap = Record<CliType, CliInfo>;
22
23
  export declare function getCliInfo(forceRefresh?: boolean): CliInfoMap;