llm-cli-gateway 1.5.4 → 1.5.14

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.
@@ -1,4 +1,5 @@
1
1
  import type { Logger } from "./logger.js";
2
+ import type { PersistenceConfig } from "./config.js";
2
3
  export type JobStoreStatus = "running" | "completed" | "failed" | "canceled" | "orphaned";
3
4
  export interface JobRecord {
4
5
  id: string;
@@ -22,7 +23,43 @@ export declare function resolveJobStoreDbPath(): string | null;
22
23
  export declare function resolveJobRetentionMs(): number;
23
24
  export declare function resolveDedupWindowMs(): number;
24
25
  export declare function computeRequestKey(cli: string, args: string[], extra?: string): string;
25
- export declare class JobStore {
26
+ /**
27
+ * Public surface every backend (sqlite/postgres/memory) must implement. The
28
+ * AsyncJobManager talks to this interface only.
29
+ */
30
+ export interface JobStore {
31
+ recordStart(input: {
32
+ id: string;
33
+ correlationId: string;
34
+ requestKey: string;
35
+ cli: string;
36
+ args: string[];
37
+ outputFormat?: string;
38
+ startedAt: string;
39
+ pid: number | null;
40
+ }): void;
41
+ recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
42
+ recordComplete(input: {
43
+ id: string;
44
+ status: Exclude<JobStoreStatus, "running">;
45
+ exitCode: number | null;
46
+ stdout: string;
47
+ stderr: string;
48
+ outputTruncated: boolean;
49
+ error: string | null;
50
+ finishedAt: string;
51
+ }): void;
52
+ getById(id: string): JobRecord | null;
53
+ findByRequestKey(requestKey: string): JobRecord | null;
54
+ markOrphanedOnStartup(): number;
55
+ evictExpired(): number;
56
+ close(): void;
57
+ }
58
+ /**
59
+ * SQLite-backed job store. Default backend for production. Durable across
60
+ * gateway restarts; safe for single-instance deployments.
61
+ */
62
+ export declare class SqliteJobStore implements JobStore {
26
63
  private logger;
27
64
  private db;
28
65
  private retentionMs;
@@ -34,7 +71,10 @@ export declare class JobStore {
34
71
  private findByRequestKeyStmt;
35
72
  private markOrphanedStmt;
36
73
  private deleteExpiredStmt;
37
- constructor(dbPath: string, logger?: Logger);
74
+ constructor(dbPath: string, logger?: Logger, options?: {
75
+ retentionMs?: number;
76
+ dedupWindowMs?: number;
77
+ });
38
78
  /**
39
79
  * Insert a new running job row. Caller has already computed requestKey.
40
80
  */
@@ -82,3 +122,79 @@ export declare class JobStore {
82
122
  evictExpired(): number;
83
123
  close(): void;
84
124
  }
125
+ /**
126
+ * Backwards-compatibility alias. Older code and tests construct `new JobStore(path)`
127
+ * directly; that surface now resolves to the SQLite implementation. Prefer
128
+ * `createJobStore(config)` in new code.
129
+ *
130
+ * @deprecated Use `SqliteJobStore` directly, or `createJobStore(persistenceConfig)`.
131
+ */
132
+ export declare const JobStoreClass: typeof SqliteJobStore;
133
+ /**
134
+ * In-process job store. Same semantics as SqliteJobStore but state lives in a
135
+ * Map and is lost on process exit. Use for tests and ephemeral/CI gateways
136
+ * that have explicitly acknowledged the trade-off via
137
+ * `[persistence].acknowledgeEphemeral = true`.
138
+ */
139
+ export declare class MemoryJobStore implements JobStore {
140
+ private rows;
141
+ private retentionMs;
142
+ private dedupWindowMs;
143
+ constructor(options?: {
144
+ retentionMs?: number;
145
+ dedupWindowMs?: number;
146
+ });
147
+ recordStart(input: {
148
+ id: string;
149
+ correlationId: string;
150
+ requestKey: string;
151
+ cli: string;
152
+ args: string[];
153
+ outputFormat?: string;
154
+ startedAt: string;
155
+ pid: number | null;
156
+ }): void;
157
+ recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
158
+ recordComplete(input: {
159
+ id: string;
160
+ status: Exclude<JobStoreStatus, "running">;
161
+ exitCode: number | null;
162
+ stdout: string;
163
+ stderr: string;
164
+ outputTruncated: boolean;
165
+ error: string | null;
166
+ finishedAt: string;
167
+ }): void;
168
+ getById(id: string): JobRecord | null;
169
+ findByRequestKey(requestKey: string): JobRecord | null;
170
+ /**
171
+ * In-memory stores have no cross-process state, so any "running" rows here
172
+ * came from this very process and aren't actually orphaned. No-op.
173
+ */
174
+ markOrphanedOnStartup(): number;
175
+ evictExpired(): number;
176
+ close(): void;
177
+ }
178
+ /**
179
+ * Stub for the planned Postgres backend. The interface and config surface ship
180
+ * now so multi-instance deployments can plan around them, but the
181
+ * implementation is intentionally not yet provided — calling code must select
182
+ * `sqlite` or `memory` until a real impl lands.
183
+ */
184
+ export declare class PostgresJobStore implements JobStore {
185
+ constructor(_dsn: string, _logger?: Logger);
186
+ recordStart(): void;
187
+ recordOutput(): void;
188
+ recordComplete(): void;
189
+ getById(): JobRecord | null;
190
+ findByRequestKey(): JobRecord | null;
191
+ markOrphanedOnStartup(): number;
192
+ evictExpired(): number;
193
+ close(): void;
194
+ }
195
+ /**
196
+ * Construct the JobStore appropriate to the resolved PersistenceConfig.
197
+ * Returns `null` when `backend = "none"` — callers must not register
198
+ * `*_request_async` tools in that case (use `config.asyncJobsEnabled`).
199
+ */
200
+ export declare function createJobStore(config: PersistenceConfig, logger?: Logger): JobStore | null;
package/dist/job-store.js CHANGED
@@ -59,7 +59,11 @@ function rowToRecord(row) {
59
59
  expiresAt: row.expires_at,
60
60
  };
61
61
  }
62
- export class JobStore {
62
+ /**
63
+ * SQLite-backed job store. Default backend for production. Durable across
64
+ * gateway restarts; safe for single-instance deployments.
65
+ */
66
+ export class SqliteJobStore {
63
67
  logger;
64
68
  db;
65
69
  retentionMs;
@@ -71,7 +75,7 @@ export class JobStore {
71
75
  findByRequestKeyStmt;
72
76
  markOrphanedStmt;
73
77
  deleteExpiredStmt;
74
- constructor(dbPath, logger = noopLogger) {
78
+ constructor(dbPath, logger = noopLogger, options = {}) {
75
79
  this.logger = logger;
76
80
  const require = createRequire(import.meta.url);
77
81
  const BetterSqlite3 = require("better-sqlite3");
@@ -114,8 +118,8 @@ export class JobStore {
114
118
  // Best effort permissions hardening.
115
119
  }
116
120
  }
117
- this.retentionMs = resolveJobRetentionMs();
118
- this.dedupWindowMs = resolveDedupWindowMs();
121
+ this.retentionMs = options.retentionMs ?? resolveJobRetentionMs();
122
+ this.dedupWindowMs = options.dedupWindowMs ?? resolveDedupWindowMs();
119
123
  this.insertStmt = this.db.prepare(`
120
124
  INSERT INTO jobs (id, correlation_id, request_key, cli, args_json, output_format,
121
125
  status, exit_code, stdout, stderr, output_truncated, error,
@@ -245,7 +249,174 @@ export class JobStore {
245
249
  this.db.close();
246
250
  }
247
251
  catch (err) {
248
- this.logger.error("JobStore close failed", err);
252
+ this.logger.error("SqliteJobStore close failed", err);
249
253
  }
250
254
  }
251
255
  }
256
+ /**
257
+ * Backwards-compatibility alias. Older code and tests construct `new JobStore(path)`
258
+ * directly; that surface now resolves to the SQLite implementation. Prefer
259
+ * `createJobStore(config)` in new code.
260
+ *
261
+ * @deprecated Use `SqliteJobStore` directly, or `createJobStore(persistenceConfig)`.
262
+ */
263
+ export const JobStoreClass = SqliteJobStore;
264
+ /**
265
+ * In-process job store. Same semantics as SqliteJobStore but state lives in a
266
+ * Map and is lost on process exit. Use for tests and ephemeral/CI gateways
267
+ * that have explicitly acknowledged the trade-off via
268
+ * `[persistence].acknowledgeEphemeral = true`.
269
+ */
270
+ export class MemoryJobStore {
271
+ rows = new Map();
272
+ retentionMs;
273
+ dedupWindowMs;
274
+ constructor(options = {}) {
275
+ this.retentionMs = options.retentionMs ?? resolveJobRetentionMs();
276
+ this.dedupWindowMs = options.dedupWindowMs ?? resolveDedupWindowMs();
277
+ }
278
+ recordStart(input) {
279
+ this.rows.set(input.id, {
280
+ id: input.id,
281
+ correlationId: input.correlationId,
282
+ requestKey: input.requestKey,
283
+ cli: input.cli,
284
+ argsJson: JSON.stringify(input.args),
285
+ outputFormat: input.outputFormat ?? null,
286
+ status: "running",
287
+ exitCode: null,
288
+ stdout: "",
289
+ stderr: "",
290
+ outputTruncated: false,
291
+ error: null,
292
+ startedAt: input.startedAt,
293
+ finishedAt: null,
294
+ pid: input.pid,
295
+ expiresAt: FAR_FUTURE_ISO,
296
+ });
297
+ }
298
+ recordOutput(id, stdout, stderr, outputTruncated) {
299
+ const row = this.rows.get(id);
300
+ if (!row)
301
+ return;
302
+ row.stdout = stdout;
303
+ row.stderr = stderr;
304
+ row.outputTruncated = outputTruncated;
305
+ }
306
+ recordComplete(input) {
307
+ const row = this.rows.get(input.id);
308
+ if (!row)
309
+ return;
310
+ row.status = input.status;
311
+ row.exitCode = input.exitCode;
312
+ row.stdout = input.stdout;
313
+ row.stderr = input.stderr;
314
+ row.outputTruncated = input.outputTruncated;
315
+ row.error = input.error;
316
+ row.finishedAt = input.finishedAt;
317
+ row.expiresAt = new Date(Date.parse(input.finishedAt) + this.retentionMs).toISOString();
318
+ }
319
+ getById(id) {
320
+ const row = this.rows.get(id);
321
+ return row ? { ...row } : null;
322
+ }
323
+ findByRequestKey(requestKey) {
324
+ const cutoffMs = Date.now() - this.dedupWindowMs;
325
+ let best = null;
326
+ for (const row of this.rows.values()) {
327
+ if (row.requestKey !== requestKey)
328
+ continue;
329
+ if (row.status !== "running" && row.status !== "completed")
330
+ continue;
331
+ if (Date.parse(row.startedAt) < cutoffMs)
332
+ continue;
333
+ if (!best || Date.parse(row.startedAt) > Date.parse(best.startedAt)) {
334
+ best = row;
335
+ }
336
+ }
337
+ return best ? { ...best } : null;
338
+ }
339
+ /**
340
+ * In-memory stores have no cross-process state, so any "running" rows here
341
+ * came from this very process and aren't actually orphaned. No-op.
342
+ */
343
+ markOrphanedOnStartup() {
344
+ return 0;
345
+ }
346
+ evictExpired() {
347
+ const nowIso = new Date().toISOString();
348
+ let removed = 0;
349
+ for (const [id, row] of this.rows) {
350
+ if (row.expiresAt < nowIso) {
351
+ this.rows.delete(id);
352
+ removed++;
353
+ }
354
+ }
355
+ return removed;
356
+ }
357
+ close() {
358
+ this.rows.clear();
359
+ }
360
+ }
361
+ /**
362
+ * Stub for the planned Postgres backend. The interface and config surface ship
363
+ * now so multi-instance deployments can plan around them, but the
364
+ * implementation is intentionally not yet provided — calling code must select
365
+ * `sqlite` or `memory` until a real impl lands.
366
+ */
367
+ export class PostgresJobStore {
368
+ constructor(_dsn, _logger = noopLogger) {
369
+ throw new Error("PostgresJobStore is not yet implemented. Use backend = 'sqlite' (single-instance) or " +
370
+ "backend = 'memory' (ephemeral) until the Postgres backend ships.");
371
+ }
372
+ recordStart() {
373
+ throw new Error("not implemented");
374
+ }
375
+ recordOutput() {
376
+ throw new Error("not implemented");
377
+ }
378
+ recordComplete() {
379
+ throw new Error("not implemented");
380
+ }
381
+ getById() {
382
+ throw new Error("not implemented");
383
+ }
384
+ findByRequestKey() {
385
+ throw new Error("not implemented");
386
+ }
387
+ markOrphanedOnStartup() {
388
+ throw new Error("not implemented");
389
+ }
390
+ evictExpired() {
391
+ throw new Error("not implemented");
392
+ }
393
+ close() {
394
+ /* no-op */
395
+ }
396
+ }
397
+ /**
398
+ * Construct the JobStore appropriate to the resolved PersistenceConfig.
399
+ * Returns `null` when `backend = "none"` — callers must not register
400
+ * `*_request_async` tools in that case (use `config.asyncJobsEnabled`).
401
+ */
402
+ export function createJobStore(config, logger = noopLogger) {
403
+ const opts = {
404
+ retentionMs: config.retentionDays * 24 * 60 * 60 * 1000,
405
+ dedupWindowMs: config.dedupWindowMs,
406
+ };
407
+ switch (config.backend) {
408
+ case "none":
409
+ return null;
410
+ case "memory":
411
+ return new MemoryJobStore(opts);
412
+ case "postgres":
413
+ // Throws today; design surface is honest so callers can react.
414
+ return new PostgresJobStore(config.dsn ?? "", logger);
415
+ case "sqlite":
416
+ default:
417
+ if (!config.path) {
418
+ throw new Error("SqliteJobStore requires a non-empty path");
419
+ }
420
+ return new SqliteJobStore(config.path, logger, opts);
421
+ }
422
+ }
@@ -24,8 +24,9 @@ export declare class PostgreSQLSessionManager {
24
24
  */
25
25
  private acquireLockWithRetry;
26
26
  /**
27
- * Release distributed lock using Lua script for atomic compare-and-delete
28
- * Only releases if lockValue matches (prevents releasing another process's lock)
27
+ * Release distributed lock with optimistic Redis transaction semantics.
28
+ * Only releases if lockValue matches, which prevents releasing another
29
+ * process's lock after expiry/reacquire.
29
30
  */
30
31
  private releaseLock;
31
32
  /**
@@ -52,20 +52,25 @@ export class PostgreSQLSessionManager {
52
52
  }
53
53
  }
54
54
  /**
55
- * Release distributed lock using Lua script for atomic compare-and-delete
56
- * Only releases if lockValue matches (prevents releasing another process's lock)
55
+ * Release distributed lock with optimistic Redis transaction semantics.
56
+ * Only releases if lockValue matches, which prevents releasing another
57
+ * process's lock after expiry/reacquire.
57
58
  */
58
59
  async releaseLock(key, lockValue) {
59
60
  const lockKey = `lock:${key}`;
60
- // Lua script for atomic compare-and-delete
61
- const script = `
62
- if redis.call("get", KEYS[1]) == ARGV[1] then
63
- return redis.call("del", KEYS[1])
64
- else
65
- return 0
66
- end
67
- `;
68
- await this.redis.eval(script, 1, lockKey, lockValue);
61
+ await this.redis.watch(lockKey);
62
+ try {
63
+ const currentValue = await this.redis.get(lockKey);
64
+ if (currentValue !== lockValue) {
65
+ await this.redis.unwatch();
66
+ return;
67
+ }
68
+ await this.redis.multi().del(lockKey).exec();
69
+ }
70
+ catch (error) {
71
+ await this.redis.unwatch().catch(() => undefined);
72
+ throw error;
73
+ }
69
74
  }
70
75
  /**
71
76
  * Invalidate session cache
@@ -0,0 +1,62 @@
1
+ import type { CliType } from "./session-manager.js";
2
+ export type CliFlagArity = "none" | "one" | "variadic";
3
+ export interface CliFlagContract {
4
+ arity: CliFlagArity;
5
+ values?: readonly string[];
6
+ pattern?: RegExp;
7
+ description: string;
8
+ }
9
+ export interface CliContract {
10
+ cli: CliType;
11
+ executable: string;
12
+ upstream: string;
13
+ helpArgs: string[][];
14
+ flags: Record<string, CliFlagContract>;
15
+ env?: Record<string, CliFlagContract>;
16
+ mcpTools: readonly string[];
17
+ mcpParameters: readonly string[];
18
+ conformanceFixtures: readonly CliContractFixture[];
19
+ command?: {
20
+ requiredFirstArg: string;
21
+ optionalSecondArg?: string;
22
+ };
23
+ maxPositionals: number;
24
+ resumeMaxPositionals?: number;
25
+ resumeOnlyFlags?: readonly string[];
26
+ resumeForbiddenFlags?: readonly string[];
27
+ }
28
+ export interface CliContractFixture {
29
+ id: string;
30
+ description: string;
31
+ args: readonly string[];
32
+ env?: Record<string, string>;
33
+ expect: "pass" | "fail";
34
+ }
35
+ export interface ContractViolation {
36
+ cli: CliType;
37
+ arg?: string;
38
+ index?: number;
39
+ message: string;
40
+ }
41
+ export interface ContractValidationResult {
42
+ ok: boolean;
43
+ violations: ContractViolation[];
44
+ }
45
+ export declare const UPSTREAM_CLI_CONTRACTS: Record<CliType, CliContract>;
46
+ export declare function validateUpstreamCliArgs(cli: CliType, args: readonly string[]): ContractValidationResult;
47
+ export declare function assertUpstreamCliArgs(cli: CliType, args: readonly string[]): void;
48
+ export declare function validateUpstreamCliEnv(cli: CliType, env: Record<string, string> | undefined): ContractValidationResult;
49
+ export declare function assertUpstreamCliEnv(cli: CliType, env: Record<string, string> | undefined): void;
50
+ export interface InstalledCliContractProbe {
51
+ cli: CliType;
52
+ executable: string;
53
+ available: boolean;
54
+ checkedHelpCommands: string[][];
55
+ missingFlags: string[];
56
+ warnings: string[];
57
+ }
58
+ export declare function probeInstalledCliContract(cli: CliType, timeoutMs?: number): InstalledCliContractProbe;
59
+ export declare function buildUpstreamContractReport(options?: {
60
+ cli?: CliType;
61
+ probeInstalled?: boolean;
62
+ }): Record<string, unknown>;