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.
package/dist/config.js CHANGED
@@ -1,4 +1,9 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { createRequire } from "module";
1
5
  import { z } from "zod";
6
+ import { logWarn, noopLogger } from "./logger.js";
2
7
  // Zod schemas for configuration validation
3
8
  const DatabaseUrlSchema = z
4
9
  .string()
@@ -60,3 +65,165 @@ export function loadConfig() {
60
65
  sessionTtl,
61
66
  };
62
67
  }
68
+ //──────────────────────────────────────────────────────────────────────────────
69
+ // Persistence configuration
70
+ //
71
+ // The async job store is now driven by a typed config (TOML file +
72
+ // validated env-var overrides) instead of a single LLM_GATEWAY_LOGS_DB env
73
+ // var. The structural invariant: `*_request_async` tools are only registered
74
+ // when a real durable store is attached, so silent in-memory loss after the
75
+ // 1h TTL becomes impossible.
76
+ //
77
+ // Backends:
78
+ // - "sqlite": durable on disk (default).
79
+ // - "postgres": durable in Postgres (interface only — impl not yet shipped).
80
+ // - "memory": in-process MemoryJobStore. Process-lifetime durability only.
81
+ // Requires acknowledgeEphemeral=true to register async tools.
82
+ // - "none": no store. Async tools are NOT registered.
83
+ //──────────────────────────────────────────────────────────────────────────────
84
+ export const PERSISTENCE_BACKENDS = ["sqlite", "postgres", "memory", "none"];
85
+ export const DEFAULT_JOB_RETENTION_DAYS = 30;
86
+ export const DEFAULT_DEDUP_WINDOW_MS = 60 * 60 * 1000; // 1 hour
87
+ const PersistenceSchema = z
88
+ .object({
89
+ backend: z.enum(PERSISTENCE_BACKENDS).default("sqlite"),
90
+ path: z.string().optional(),
91
+ dsn: z.string().optional(),
92
+ retentionDays: z.number().positive().default(DEFAULT_JOB_RETENTION_DAYS),
93
+ dedupWindowMs: z.number().int().nonnegative().default(DEFAULT_DEDUP_WINDOW_MS),
94
+ acknowledgeEphemeral: z.boolean().default(false),
95
+ })
96
+ .strict();
97
+ const DEFAULT_SQLITE_PATH = path.join(os.homedir(), ".llm-cli-gateway", "logs.db");
98
+ function defaultPersistenceConfigPath() {
99
+ return (process.env.LLM_GATEWAY_CONFIG ?? path.join(os.homedir(), ".llm-cli-gateway", "config.toml"));
100
+ }
101
+ /**
102
+ * Read and parse the optional TOML config file. Returns the raw `[persistence]`
103
+ * table (if present) and the file path. Missing file is fine — defaults apply.
104
+ */
105
+ function readPersistenceFile(configPath, logger) {
106
+ if (!existsSync(configPath)) {
107
+ return { raw: undefined, sourcePath: null };
108
+ }
109
+ try {
110
+ const require = createRequire(import.meta.url);
111
+ const TOML = require("toml");
112
+ const text = readFileSync(configPath, "utf-8");
113
+ const parsed = TOML.parse(text);
114
+ return { raw: parsed?.persistence, sourcePath: configPath };
115
+ }
116
+ catch (err) {
117
+ logger.error(`Failed to parse gateway config at ${configPath}; using defaults`, err);
118
+ return { raw: undefined, sourcePath: null };
119
+ }
120
+ }
121
+ /**
122
+ * Apply legacy env-var overrides on top of the file/defaults. Each application
123
+ * appends a string to `sources.envOverrides` and emits a one-time deprecation
124
+ * warning so operators can migrate to the config file.
125
+ */
126
+ function applyEnvOverrides(base, logger, sources) {
127
+ const out = { ...base };
128
+ const jobsDbEnv = process.env.LLM_GATEWAY_JOBS_DB;
129
+ const logsDbEnv = process.env.LLM_GATEWAY_LOGS_DB;
130
+ // Empty string is treated as "not set" — only an explicitly non-empty value
131
+ // (or the literal "none") overrides the file/defaults. This avoids the
132
+ // old footgun where `LLM_GATEWAY_LOGS_DB=` silently disabled persistence.
133
+ const dbEnvRaw = jobsDbEnv && jobsDbEnv.length > 0
134
+ ? jobsDbEnv
135
+ : logsDbEnv && logsDbEnv.length > 0
136
+ ? logsDbEnv
137
+ : undefined;
138
+ if (dbEnvRaw !== undefined) {
139
+ const normalized = dbEnvRaw.trim().toLowerCase();
140
+ if (normalized === "none") {
141
+ out.backend = "none";
142
+ out.path = undefined;
143
+ }
144
+ else {
145
+ out.backend = "sqlite";
146
+ out.path = dbEnvRaw.trim();
147
+ }
148
+ const which = jobsDbEnv && jobsDbEnv.length > 0 ? "LLM_GATEWAY_JOBS_DB" : "LLM_GATEWAY_LOGS_DB";
149
+ sources.envOverrides.push(which);
150
+ logWarn(logger, `${which} is deprecated; migrate to [persistence] in ~/.llm-cli-gateway/config.toml`, { backend: out.backend, path: out.path ?? null });
151
+ }
152
+ const retEnv = process.env.LLM_GATEWAY_JOB_RETENTION_DAYS;
153
+ if (retEnv !== undefined) {
154
+ const n = Number(retEnv);
155
+ if (Number.isFinite(n) && n > 0) {
156
+ out.retentionDays = n;
157
+ sources.envOverrides.push("LLM_GATEWAY_JOB_RETENTION_DAYS");
158
+ logWarn(logger, "LLM_GATEWAY_JOB_RETENTION_DAYS is deprecated; set [persistence].retentionDays in config.toml", { retentionDays: n });
159
+ }
160
+ }
161
+ const dedupEnv = process.env.LLM_GATEWAY_DEDUP_WINDOW_MS;
162
+ if (dedupEnv !== undefined) {
163
+ const n = Number(dedupEnv);
164
+ if (Number.isFinite(n) && n >= 0) {
165
+ out.dedupWindowMs = n;
166
+ sources.envOverrides.push("LLM_GATEWAY_DEDUP_WINDOW_MS");
167
+ logWarn(logger, "LLM_GATEWAY_DEDUP_WINDOW_MS is deprecated; set [persistence].dedupWindowMs in config.toml", { dedupWindowMs: n });
168
+ }
169
+ }
170
+ const ackEnv = process.env.LLM_GATEWAY_ACKNOWLEDGE_EPHEMERAL;
171
+ if (ackEnv && ackEnv.length > 0) {
172
+ out.acknowledgeEphemeral = /^(1|true|yes)$/i.test(ackEnv.trim());
173
+ sources.envOverrides.push("LLM_GATEWAY_ACKNOWLEDGE_EPHEMERAL");
174
+ logWarn(logger, "LLM_GATEWAY_ACKNOWLEDGE_EPHEMERAL is deprecated; set [persistence].acknowledgeEphemeral in config.toml", { acknowledgeEphemeral: out.acknowledgeEphemeral });
175
+ }
176
+ return out;
177
+ }
178
+ function expandHome(p) {
179
+ return p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
180
+ }
181
+ /**
182
+ * Load and validate the persistence config from (in order, last-write-wins):
183
+ * 1. Built-in defaults (backend=sqlite, default retention/dedup).
184
+ * 2. ~/.llm-cli-gateway/config.toml (or $LLM_GATEWAY_CONFIG).
185
+ * 3. Legacy env vars (with deprecation warning).
186
+ *
187
+ * Throws on incoherent configs (memory/none + asyncJobsEnabled without ack).
188
+ */
189
+ export function loadPersistenceConfig(logger = noopLogger) {
190
+ const configPath = defaultPersistenceConfigPath();
191
+ const { raw, sourcePath } = readPersistenceFile(configPath, logger);
192
+ const sources = {
193
+ configFile: sourcePath,
194
+ envOverrides: [],
195
+ };
196
+ const merged = applyEnvOverrides(raw ?? {}, logger, sources);
197
+ let parsed;
198
+ try {
199
+ parsed = PersistenceSchema.parse(merged);
200
+ }
201
+ catch (err) {
202
+ throw new Error(`Invalid [persistence] config: ${err instanceof Error ? err.message : String(err)}`);
203
+ }
204
+ const backend = parsed.backend;
205
+ const resolvedPath = backend === "sqlite" ? expandHome(parsed.path ?? DEFAULT_SQLITE_PATH) : null;
206
+ const dsn = backend === "postgres" ? (parsed.dsn ?? null) : null;
207
+ if (backend === "postgres" && !dsn) {
208
+ throw new Error("[persistence].backend = 'postgres' requires a non-empty 'dsn' (e.g. postgresql://user:pw@host/db)");
209
+ }
210
+ if (backend === "memory" && !parsed.acknowledgeEphemeral) {
211
+ throw new Error("[persistence].backend = 'memory' is ephemeral — async job results are lost on gateway exit. " +
212
+ "Set [persistence].acknowledgeEphemeral = true (or LLM_GATEWAY_ACKNOWLEDGE_EPHEMERAL=1) to confirm this is intentional.");
213
+ }
214
+ const asyncJobsEnabled = backend === "sqlite" || backend === "postgres" || backend === "memory";
215
+ if (backend === "none") {
216
+ logWarn(logger, "Async job persistence is DISABLED (backend = 'none'). " +
217
+ "*_request_async tools will NOT be registered on this gateway.");
218
+ }
219
+ return {
220
+ backend,
221
+ path: resolvedPath,
222
+ dsn,
223
+ retentionDays: parsed.retentionDays,
224
+ dedupWindowMs: parsed.dedupWindowMs,
225
+ acknowledgeEphemeral: parsed.acknowledgeEphemeral,
226
+ asyncJobsEnabled,
227
+ sources,
228
+ };
229
+ }
@@ -0,0 +1 @@
1
+ export declare function entrypointFileURL(path: string | undefined): string;
@@ -0,0 +1,5 @@
1
+ import { realpathSync } from "fs";
2
+ import { pathToFileURL } from "url";
3
+ export function entrypointFileURL(path) {
4
+ return path ? pathToFileURL(realpathSync(path)).href : "";
5
+ }
@@ -1,4 +1,4 @@
1
- import { ChildProcess } from "child_process";
1
+ import { ChildProcess, type SpawnOptions } from "child_process";
2
2
  import type { Logger } from "./logger.js";
3
3
  export interface ExecuteOptions {
4
4
  timeout?: number;
@@ -14,6 +14,7 @@ export interface ExecuteResult {
14
14
  code: number;
15
15
  }
16
16
  export declare function getExtendedPath(): string;
17
+ export declare function shouldDetachProviderProcess(platform?: NodeJS.Platform): boolean;
17
18
  export declare function registerProcessGroup(pid: number): void;
18
19
  export declare function unregisterProcessGroup(pid: number): void;
19
20
  /**
@@ -29,4 +30,9 @@ export declare function killAllProcessGroups(): Promise<void>;
29
30
  * if the group kill fails (e.g., pid not yet assigned).
30
31
  */
31
32
  export declare function killProcessGroup(proc: ChildProcess, signal: NodeJS.Signals): boolean;
33
+ export declare function spawnCliProcess(command: string, args: string[], options: {
34
+ cwd?: string;
35
+ env: NodeJS.ProcessEnv;
36
+ stdio: SpawnOptions["stdio"];
37
+ }): ChildProcess;
32
38
  export declare function executeCli(command: string, args: string[], options?: ExecuteOptions): Promise<ExecuteResult>;
package/dist/executor.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawn } from "child_process";
1
+ import { spawn, spawnSync } from "child_process";
2
2
  import { homedir } from "os";
3
3
  import { join, dirname } from "path";
4
4
  import { readdirSync, existsSync } from "fs";
@@ -55,6 +55,12 @@ export function getExtendedPath() {
55
55
  }
56
56
  /** Registry of active detached process groups for shutdown cleanup. */
57
57
  const activeProcessGroups = new Set();
58
+ export function shouldDetachProviderProcess(platform = process.platform) {
59
+ // On Windows, detached console children can flash visible cmd/conhost windows
60
+ // when provider CLIs are native console apps or .cmd shims. Keep them in the
61
+ // gateway process tree and rely on hidden-window spawn plus taskkill cleanup.
62
+ return platform !== "win32";
63
+ }
58
64
  export function registerProcessGroup(pid) {
59
65
  activeProcessGroups.add(pid);
60
66
  }
@@ -72,21 +78,31 @@ export function killAllProcessGroups() {
72
78
  if (activeProcessGroups.size === 0)
73
79
  return Promise.resolve();
74
80
  for (const pid of activeProcessGroups) {
75
- try {
76
- process.kill(-pid, "SIGTERM");
81
+ if (process.platform === "win32") {
82
+ killWindowsProcessTree(pid);
77
83
  }
78
- catch {
79
- /* ESRCH ok */
84
+ else {
85
+ try {
86
+ process.kill(-pid, "SIGTERM");
87
+ }
88
+ catch {
89
+ /* ESRCH ok */
90
+ }
80
91
  }
81
92
  }
82
93
  return new Promise(resolve => {
83
94
  setTimeout(() => {
84
95
  for (const pid of activeProcessGroups) {
85
- try {
86
- process.kill(-pid, "SIGKILL");
96
+ if (process.platform === "win32") {
97
+ killWindowsProcessTree(pid);
87
98
  }
88
- catch {
89
- /* ESRCH ok */
99
+ else {
100
+ try {
101
+ process.kill(-pid, "SIGKILL");
102
+ }
103
+ catch {
104
+ /* ESRCH ok */
105
+ }
90
106
  }
91
107
  }
92
108
  activeProcessGroups.clear();
@@ -100,6 +116,9 @@ export function killAllProcessGroups() {
100
116
  */
101
117
  export function killProcessGroup(proc, signal) {
102
118
  if (proc.pid) {
119
+ if (process.platform === "win32") {
120
+ return killWindowsProcessTree(proc.pid);
121
+ }
103
122
  try {
104
123
  process.kill(-proc.pid, signal);
105
124
  return true;
@@ -124,21 +143,37 @@ export function killProcessGroup(proc, signal) {
124
143
  return false;
125
144
  }
126
145
  }
146
+ function killWindowsProcessTree(pid) {
147
+ const result = spawnSync("taskkill.exe", ["/PID", String(pid), "/T", "/F"], {
148
+ stdio: "ignore",
149
+ windowsHide: true,
150
+ });
151
+ return result.status === 0;
152
+ }
153
+ export function spawnCliProcess(command, args, options) {
154
+ const detached = shouldDetachProviderProcess();
155
+ const proc = spawn(command, args, {
156
+ cwd: options.cwd,
157
+ detached,
158
+ windowsHide: true,
159
+ stdio: options.stdio,
160
+ env: options.env,
161
+ });
162
+ if (proc.pid)
163
+ registerProcessGroup(proc.pid);
164
+ proc.unref();
165
+ return proc;
166
+ }
127
167
  export async function executeCli(command, args, options = {}) {
128
168
  const { timeout, idleTimeout, cwd, env: extraEnv } = options;
129
169
  const extendedPath = getExtendedPath();
130
170
  const circuitBreaker = getCircuitBreaker(command);
131
171
  const runOnce = () => new Promise((resolve, reject) => {
132
- const proc = spawn(command, args, {
172
+ const proc = spawnCliProcess(command, args, {
133
173
  cwd,
134
- detached: true,
135
174
  stdio: ["ignore", "pipe", "pipe"],
136
175
  env: { ...process.env, PATH: extendedPath, ...(extraEnv ?? {}) },
137
176
  });
138
- if (proc.pid)
139
- registerProcessGroup(proc.pid);
140
- // Prevent detached process from keeping parent alive when not needed
141
- proc.unref();
142
177
  let stdout = "";
143
178
  let stderr = "";
144
179
  let timedOut = false;
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import { ISessionManager } from "./session-manager.js";
5
5
  import { ResourceProvider } from "./resources.js";
6
6
  import { PerformanceMetrics } from "./metrics.js";
7
+ import { type PersistenceConfig } from "./config.js";
7
8
  import { DatabaseConnection } from "./db.js";
8
9
  import { AsyncJobManager } from "./async-job-manager.js";
9
10
  import { ApprovalManager, ApprovalRecord } from "./approval-manager.js";
@@ -47,6 +48,7 @@ export interface GatewayServerDeps {
47
48
  approvalManager?: ApprovalManager;
48
49
  flightRecorder?: FlightRecorderLike;
49
50
  logger?: GatewayLogger;
51
+ persistence?: PersistenceConfig;
50
52
  }
51
53
  interface GatewayServerRuntime {
52
54
  sessionManager: ISessionManager;
@@ -57,6 +59,7 @@ interface GatewayServerRuntime {
57
59
  approvalManager: ApprovalManager;
58
60
  flightRecorder: FlightRecorderLike;
59
61
  logger: GatewayLogger;
62
+ persistence: PersistenceConfig;
60
63
  }
61
64
  interface CliRequestPrep {
62
65
  corrId: string;