llm-cli-gateway 1.1.0 → 1.5.4
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/CHANGELOG.md +87 -0
- package/README.md +226 -9
- package/dist/approval-manager.d.ts +1 -1
- package/dist/async-job-manager.d.ts +75 -4
- package/dist/async-job-manager.js +303 -19
- package/dist/auth.d.ts +15 -0
- package/dist/auth.js +46 -0
- package/dist/cli-updater.d.ts +55 -0
- package/dist/cli-updater.js +248 -0
- package/dist/codex-json-parser.d.ts +34 -0
- package/dist/codex-json-parser.js +105 -0
- package/dist/doctor.d.ts +110 -0
- package/dist/doctor.js +280 -0
- package/dist/endpoint-exposure.d.ts +22 -0
- package/dist/endpoint-exposure.js +231 -0
- package/dist/executor.d.ts +2 -0
- package/dist/executor.js +2 -2
- package/dist/flight-recorder.d.ts +3 -1
- package/dist/flight-recorder.js +31 -2
- package/dist/gateway-server.d.ts +2 -0
- package/dist/gateway-server.js +1 -0
- package/dist/gemini-json-parser.d.ts +21 -0
- package/dist/gemini-json-parser.js +47 -0
- package/dist/health.d.ts +7 -0
- package/dist/health.js +22 -0
- package/dist/http-transport.d.ts +22 -0
- package/dist/http-transport.js +164 -0
- package/dist/index.d.ts +210 -2
- package/dist/index.js +2880 -1037
- package/dist/job-store.d.ts +84 -0
- package/dist/job-store.js +251 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.js +14 -0
- package/dist/model-registry.d.ts +14 -0
- package/dist/model-registry.js +478 -134
- package/dist/provider-login-guidance.d.ts +21 -0
- package/dist/provider-login-guidance.js +98 -0
- package/dist/provider-status.d.ts +41 -0
- package/dist/provider-status.js +203 -0
- package/dist/request-helpers.d.ts +525 -4
- package/dist/request-helpers.js +653 -0
- package/dist/resources.js +88 -0
- package/dist/session-manager-pg.js +2 -0
- package/dist/session-manager.d.ts +1 -1
- package/dist/session-manager.js +3 -1
- package/dist/validation-normalizer.d.ts +23 -0
- package/dist/validation-normalizer.js +79 -0
- package/dist/validation-orchestrator.d.ts +47 -0
- package/dist/validation-orchestrator.js +145 -0
- package/dist/validation-prompts.d.ts +15 -0
- package/dist/validation-prompts.js +52 -0
- package/dist/validation-report.d.ts +57 -0
- package/dist/validation-report.js +129 -0
- package/dist/validation-tools.d.ts +7 -0
- package/dist/validation-tools.js +198 -0
- package/package.json +16 -6
- package/setup/status.schema.json +271 -0
|
@@ -3,9 +3,27 @@ import { randomUUID } from "crypto";
|
|
|
3
3
|
import { getExtendedPath, killProcessGroup, registerProcessGroup, unregisterProcessGroup, } from "./executor.js";
|
|
4
4
|
import { noopLogger } from "./logger.js";
|
|
5
5
|
import { ProcessMonitor } from "./process-monitor.js";
|
|
6
|
+
import { computeRequestKey } from "./job-store.js";
|
|
6
7
|
const MAX_OUTPUT_SIZE = 50 * 1024 * 1024;
|
|
7
|
-
const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
8
|
+
const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour in-memory retention; durable store has its own (longer) retention
|
|
8
9
|
const EVICTION_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
|
10
|
+
const OUTPUT_FLUSH_INTERVAL_MS = 1000; // Throttle DB writes for streaming stdout/stderr
|
|
11
|
+
/**
|
|
12
|
+
* U22 fix: deterministic canonicalisation of an env-var map for the dedup key.
|
|
13
|
+
* Returns "" when env is undefined or empty (preserves dedup key continuity for
|
|
14
|
+
* pre-U22 callers that pass no env).
|
|
15
|
+
*/
|
|
16
|
+
function canonicaliseEnvForKey(env) {
|
|
17
|
+
if (!env)
|
|
18
|
+
return "";
|
|
19
|
+
const entries = Object.entries(env)
|
|
20
|
+
.filter(([, v]) => v !== undefined && v !== null)
|
|
21
|
+
.map(([k, v]) => [k, String(v)]);
|
|
22
|
+
if (entries.length === 0)
|
|
23
|
+
return "";
|
|
24
|
+
entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
25
|
+
return JSON.stringify(entries);
|
|
26
|
+
}
|
|
9
27
|
function truncateText(value, maxChars) {
|
|
10
28
|
if (value.length <= maxChars) {
|
|
11
29
|
return { text: value, truncated: false };
|
|
@@ -21,10 +39,23 @@ export class AsyncJobManager {
|
|
|
21
39
|
jobs = new Map();
|
|
22
40
|
evictionTimer = null;
|
|
23
41
|
processMonitor;
|
|
24
|
-
|
|
42
|
+
store;
|
|
43
|
+
constructor(logger = noopLogger, onJobComplete, store = null) {
|
|
25
44
|
this.logger = logger;
|
|
26
45
|
this.onJobComplete = onJobComplete;
|
|
27
46
|
this.processMonitor = new ProcessMonitor(logger);
|
|
47
|
+
this.store = store;
|
|
48
|
+
if (this.store) {
|
|
49
|
+
try {
|
|
50
|
+
const orphaned = this.store.markOrphanedOnStartup();
|
|
51
|
+
if (orphaned > 0) {
|
|
52
|
+
this.logger.info(`Marked ${orphaned} in-flight job(s) as orphaned after gateway restart`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
this.logger.error("markOrphanedOnStartup failed", err);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
28
59
|
this.evictionTimer = setInterval(() => this.evictCompletedJobs(), EVICTION_INTERVAL_MS);
|
|
29
60
|
// Allow the process to exit even if the timer is active
|
|
30
61
|
if (this.evictionTimer.unref) {
|
|
@@ -52,7 +83,7 @@ export class AsyncJobManager {
|
|
|
52
83
|
let evicted = 0;
|
|
53
84
|
// Dead process auto-recovery: check for running jobs whose process no longer exists
|
|
54
85
|
for (const [id, job] of this.jobs) {
|
|
55
|
-
if (job.status === "running" && job.process.pid) {
|
|
86
|
+
if (job.status === "running" && job.process && job.process.pid) {
|
|
56
87
|
try {
|
|
57
88
|
process.kill(job.process.pid, 0);
|
|
58
89
|
}
|
|
@@ -66,6 +97,8 @@ export class AsyncJobManager {
|
|
|
66
97
|
unregisterProcessGroup(job.process.pid);
|
|
67
98
|
this.logger.error(`Job ${id} process ${job.process.pid} no longer exists, marking as failed`);
|
|
68
99
|
this.emitMetrics(job);
|
|
100
|
+
this.persistComplete(job);
|
|
101
|
+
this.fireOnComplete(job);
|
|
69
102
|
}
|
|
70
103
|
// EPERM: process exists but we can't signal it — ignore
|
|
71
104
|
}
|
|
@@ -75,10 +108,12 @@ export class AsyncJobManager {
|
|
|
75
108
|
job.status = "failed";
|
|
76
109
|
job.error = "Process exited without proper status transition";
|
|
77
110
|
job.finishedAt = job.finishedAt || new Date().toISOString();
|
|
78
|
-
if (job.process.pid)
|
|
111
|
+
if (job.process && job.process.pid)
|
|
79
112
|
unregisterProcessGroup(job.process.pid);
|
|
80
113
|
this.logger.error(`Job ${id} has exited flag but was still in running state, marking as failed`);
|
|
81
114
|
this.emitMetrics(job);
|
|
115
|
+
this.persistComplete(job);
|
|
116
|
+
this.fireOnComplete(job);
|
|
82
117
|
}
|
|
83
118
|
}
|
|
84
119
|
for (const [id, job] of this.jobs) {
|
|
@@ -91,17 +126,220 @@ export class AsyncJobManager {
|
|
|
91
126
|
}
|
|
92
127
|
}
|
|
93
128
|
if (evicted > 0) {
|
|
94
|
-
this.logger.debug(`Evicted ${evicted} completed jobs`);
|
|
129
|
+
this.logger.debug(`Evicted ${evicted} completed jobs from memory (durable store retains them)`);
|
|
130
|
+
}
|
|
131
|
+
// Sweep the durable store, too. Errors are non-fatal — the job rows just stay until next sweep.
|
|
132
|
+
if (this.store) {
|
|
133
|
+
try {
|
|
134
|
+
const removed = this.store.evictExpired();
|
|
135
|
+
if (removed > 0) {
|
|
136
|
+
this.logger.debug(`Evicted ${removed} expired jobs from durable store`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
this.logger.error("durable store eviction failed", err);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Compute the dedup key for a job. Stable across re-issues of the same request,
|
|
146
|
+
* which is exactly what allows agents to safely retry without restarting the run.
|
|
147
|
+
*
|
|
148
|
+
* U22 fix: env vars participate in the key via a deterministic canonicalisation
|
|
149
|
+
* (sorted keys → JSON-stringified). This prevents two Mistral requests with the
|
|
150
|
+
* same argv but different `VIBE_ACTIVE_MODEL` from deduping onto each other.
|
|
151
|
+
*/
|
|
152
|
+
buildRequestKey(cli, args, env) {
|
|
153
|
+
return computeRequestKey(cli, args, canonicaliseEnvForKey(env));
|
|
154
|
+
}
|
|
155
|
+
fireOnComplete(job) {
|
|
156
|
+
if (job.onCompleteFired)
|
|
157
|
+
return;
|
|
158
|
+
if (!job.onComplete)
|
|
159
|
+
return;
|
|
160
|
+
job.onCompleteFired = true;
|
|
161
|
+
try {
|
|
162
|
+
job.onComplete();
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
this.logger.error(`Job ${job.id} onComplete hook threw`, err);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
safeStoreCall(label, fn) {
|
|
169
|
+
if (!this.store)
|
|
170
|
+
return;
|
|
171
|
+
try {
|
|
172
|
+
fn();
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
this.logger.error(`JobStore.${label} failed`, err);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Flush in-memory stdout/stderr to the durable store if anything changed
|
|
180
|
+
* since the last flush. Throttled by OUTPUT_FLUSH_INTERVAL_MS to avoid
|
|
181
|
+
* pounding sqlite on every chunk of streaming output.
|
|
182
|
+
*/
|
|
183
|
+
maybeFlushOutput(job, force = false) {
|
|
184
|
+
if (!this.store)
|
|
185
|
+
return;
|
|
186
|
+
if (!job.outputDirty)
|
|
187
|
+
return;
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
if (!force && now - job.lastOutputFlushAt < OUTPUT_FLUSH_INTERVAL_MS)
|
|
190
|
+
return;
|
|
191
|
+
job.outputDirty = false;
|
|
192
|
+
job.lastOutputFlushAt = now;
|
|
193
|
+
this.safeStoreCall("recordOutput", () => this.store.recordOutput(job.id, job.stdout, job.stderr, job.outputTruncated));
|
|
194
|
+
}
|
|
195
|
+
persistComplete(job) {
|
|
196
|
+
if (!this.store)
|
|
197
|
+
return;
|
|
198
|
+
if (job.status === "running")
|
|
199
|
+
return;
|
|
200
|
+
if (!job.finishedAt)
|
|
201
|
+
return;
|
|
202
|
+
// Make sure the latest output is captured in the same row update.
|
|
203
|
+
job.outputDirty = false;
|
|
204
|
+
this.safeStoreCall("recordComplete", () => this.store.recordComplete({
|
|
205
|
+
id: job.id,
|
|
206
|
+
status: job.status === "running" ? "failed" : job.status,
|
|
207
|
+
exitCode: job.exitCode,
|
|
208
|
+
stdout: job.stdout,
|
|
209
|
+
stderr: job.stderr,
|
|
210
|
+
outputTruncated: job.outputTruncated,
|
|
211
|
+
error: job.error,
|
|
212
|
+
finishedAt: job.finishedAt,
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Reconstitute an in-memory AsyncJobRecord from a durable row, so subsequent
|
|
217
|
+
* getJobSnapshot/getJobResult calls hit the in-memory cache.
|
|
218
|
+
* The reconstituted record has process=null — it represents historical data only.
|
|
219
|
+
*/
|
|
220
|
+
hydrateFromStore(jobId) {
|
|
221
|
+
if (!this.store)
|
|
222
|
+
return null;
|
|
223
|
+
let row;
|
|
224
|
+
try {
|
|
225
|
+
row = this.store.getById(jobId);
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
this.logger.error("JobStore.getById failed", err);
|
|
229
|
+
return null;
|
|
95
230
|
}
|
|
231
|
+
if (!row)
|
|
232
|
+
return null;
|
|
233
|
+
const args = (() => {
|
|
234
|
+
try {
|
|
235
|
+
const parsed = JSON.parse(row.argsJson);
|
|
236
|
+
return Array.isArray(parsed) ? parsed.map(String) : [];
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
})();
|
|
242
|
+
const reconstituted = {
|
|
243
|
+
id: row.id,
|
|
244
|
+
cli: row.cli,
|
|
245
|
+
args,
|
|
246
|
+
requestKey: row.requestKey,
|
|
247
|
+
correlationId: row.correlationId,
|
|
248
|
+
status: row.status,
|
|
249
|
+
startedAt: row.startedAt,
|
|
250
|
+
finishedAt: row.finishedAt,
|
|
251
|
+
exitCode: row.exitCode,
|
|
252
|
+
stdout: row.stdout,
|
|
253
|
+
stderr: row.stderr,
|
|
254
|
+
outputTruncated: row.outputTruncated,
|
|
255
|
+
canceled: row.status === "canceled",
|
|
256
|
+
error: row.error,
|
|
257
|
+
process: null,
|
|
258
|
+
exited: row.status !== "running",
|
|
259
|
+
metricsRecorded: true,
|
|
260
|
+
outputFormat: row.outputFormat ?? undefined,
|
|
261
|
+
outputDirty: false,
|
|
262
|
+
lastOutputFlushAt: Date.now(),
|
|
263
|
+
};
|
|
264
|
+
this.jobs.set(jobId, reconstituted);
|
|
265
|
+
return reconstituted;
|
|
96
266
|
}
|
|
97
|
-
|
|
267
|
+
/**
|
|
268
|
+
* Backwards-compatible entry point. Equivalent to startJobWithDedup({...}).snapshot.
|
|
269
|
+
* Existing callers keep working unchanged; forceRefresh is exposed as a trailing
|
|
270
|
+
* optional param for the dedup-aware path.
|
|
271
|
+
*/
|
|
272
|
+
startJob(cli, args, correlationId, cwd, idleTimeoutMs, outputFormat, forceRefresh, env, onComplete) {
|
|
273
|
+
return this.startJobWithDedup(cli, args, correlationId, {
|
|
274
|
+
cwd,
|
|
275
|
+
idleTimeoutMs,
|
|
276
|
+
outputFormat,
|
|
277
|
+
forceRefresh,
|
|
278
|
+
env,
|
|
279
|
+
onComplete,
|
|
280
|
+
}).snapshot;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Start a job, with optional dedup against recent identical requests.
|
|
284
|
+
* Returns `{ snapshot, deduped }` so callers can log/report the short-circuit.
|
|
285
|
+
*
|
|
286
|
+
* Dedup is keyed on (cli, args). If a job with the same key was started within
|
|
287
|
+
* the dedup window (default 1h) and is still running or completed, its snapshot
|
|
288
|
+
* is returned without spawning a new process. forceRefresh skips dedup entirely.
|
|
289
|
+
*/
|
|
290
|
+
startJobWithDedup(cli, args, correlationId, opts = {}) {
|
|
291
|
+
const { cwd, idleTimeoutMs, outputFormat, forceRefresh, env: extraEnv, onComplete } = opts;
|
|
292
|
+
const requestKey = this.buildRequestKey(cli, args, extraEnv);
|
|
293
|
+
if (!forceRefresh && this.store) {
|
|
294
|
+
try {
|
|
295
|
+
const existing = this.store.findByRequestKey(requestKey);
|
|
296
|
+
if (existing) {
|
|
297
|
+
// Prefer the in-memory record if we still have it (live process, idle timers, etc).
|
|
298
|
+
let record = this.jobs.get(existing.id);
|
|
299
|
+
if (!record) {
|
|
300
|
+
record = this.hydrateFromStore(existing.id) ?? undefined;
|
|
301
|
+
}
|
|
302
|
+
if (record) {
|
|
303
|
+
this.logger.info(`Job ${existing.id} reused via dedup for ${cli}`, {
|
|
304
|
+
correlationId,
|
|
305
|
+
originalCorrelationId: record.correlationId,
|
|
306
|
+
status: record.status,
|
|
307
|
+
});
|
|
308
|
+
// U26 fix: the caller's per-request resources (e.g. outputSchema temp
|
|
309
|
+
// file) are NOT consumed by the deduped job, which reuses its own
|
|
310
|
+
// original resources. Release the new request's cleanup immediately
|
|
311
|
+
// to avoid an orphaned temp file. The original job's onComplete (if
|
|
312
|
+
// any) remains attached to that original job record.
|
|
313
|
+
if (onComplete) {
|
|
314
|
+
try {
|
|
315
|
+
onComplete();
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
this.logger.error("dedup onComplete cleanup threw", err);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
snapshot: this.snapshot(record),
|
|
323
|
+
deduped: true,
|
|
324
|
+
originalCorrelationId: record.correlationId,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
this.logger.error("dedup lookup failed; proceeding with fresh run", err);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
98
333
|
const id = randomUUID();
|
|
99
334
|
const startedAt = new Date().toISOString();
|
|
100
|
-
|
|
335
|
+
// Mistral Vibe ships as the `vibe` binary; the gateway uses `mistral` as the
|
|
336
|
+
// provider key but spawns `vibe` on the shell.
|
|
337
|
+
const command = cli === "mistral" ? "vibe" : cli;
|
|
338
|
+
const child = spawn(command, args, {
|
|
101
339
|
cwd,
|
|
102
340
|
detached: true,
|
|
103
341
|
stdio: ["ignore", "pipe", "pipe"],
|
|
104
|
-
env: { ...process.env, PATH: getExtendedPath() },
|
|
342
|
+
env: { ...process.env, PATH: getExtendedPath(), ...(extraEnv ?? {}) },
|
|
105
343
|
});
|
|
106
344
|
if (child.pid)
|
|
107
345
|
registerProcessGroup(child.pid);
|
|
@@ -119,6 +357,7 @@ export class AsyncJobManager {
|
|
|
119
357
|
id,
|
|
120
358
|
cli,
|
|
121
359
|
args: [...args],
|
|
360
|
+
requestKey,
|
|
122
361
|
correlationId,
|
|
123
362
|
status: "running",
|
|
124
363
|
startedAt,
|
|
@@ -134,8 +373,22 @@ export class AsyncJobManager {
|
|
|
134
373
|
metricsRecorded: false,
|
|
135
374
|
outputFormat,
|
|
136
375
|
cleanupGroup,
|
|
376
|
+
onComplete,
|
|
377
|
+
onCompleteFired: false,
|
|
378
|
+
outputDirty: false,
|
|
379
|
+
lastOutputFlushAt: Date.now(),
|
|
137
380
|
};
|
|
138
381
|
this.jobs.set(id, job);
|
|
382
|
+
this.safeStoreCall("recordStart", () => this.store.recordStart({
|
|
383
|
+
id,
|
|
384
|
+
correlationId,
|
|
385
|
+
requestKey,
|
|
386
|
+
cli,
|
|
387
|
+
args: [...args],
|
|
388
|
+
outputFormat,
|
|
389
|
+
startedAt,
|
|
390
|
+
pid: child.pid ?? null,
|
|
391
|
+
}));
|
|
139
392
|
this.logger.info(`Job ${id} started for ${cli}`, { correlationId });
|
|
140
393
|
// Idle timeout: kill process if no output activity for idleTimeoutMs
|
|
141
394
|
let idleTimerId;
|
|
@@ -151,13 +404,16 @@ export class AsyncJobManager {
|
|
|
151
404
|
job.exitCode = 125;
|
|
152
405
|
job.error = `Process killed after ${idleTimeoutMs}ms of inactivity`;
|
|
153
406
|
job.finishedAt = new Date().toISOString();
|
|
154
|
-
|
|
407
|
+
if (job.process)
|
|
408
|
+
killProcessGroup(job.process, "SIGTERM");
|
|
155
409
|
this.logger.info(`Job ${id} killed due to inactivity (${idleTimeoutMs}ms)`, {
|
|
156
410
|
correlationId,
|
|
157
411
|
});
|
|
158
412
|
this.emitMetrics(job);
|
|
413
|
+
this.persistComplete(job);
|
|
414
|
+
this.fireOnComplete(job);
|
|
159
415
|
setTimeout(() => {
|
|
160
|
-
if (!job.exited)
|
|
416
|
+
if (!job.exited && job.process)
|
|
161
417
|
killProcessGroup(job.process, "SIGKILL");
|
|
162
418
|
job.cleanupGroup?.();
|
|
163
419
|
}, 5000);
|
|
@@ -185,6 +441,8 @@ export class AsyncJobManager {
|
|
|
185
441
|
job.finishedAt = new Date().toISOString();
|
|
186
442
|
this.logger.error(`Job ${id} error: ${error.message}`, { correlationId });
|
|
187
443
|
this.emitMetrics(job);
|
|
444
|
+
this.persistComplete(job);
|
|
445
|
+
this.fireOnComplete(job);
|
|
188
446
|
}
|
|
189
447
|
});
|
|
190
448
|
child.on("close", (code) => {
|
|
@@ -199,6 +457,9 @@ export class AsyncJobManager {
|
|
|
199
457
|
if (!job.finishedAt) {
|
|
200
458
|
job.finishedAt = new Date().toISOString();
|
|
201
459
|
}
|
|
460
|
+
// Ensure terminal state reaches the durable store (idle-timeout/output-overflow already persisted).
|
|
461
|
+
this.persistComplete(job);
|
|
462
|
+
this.fireOnComplete(job);
|
|
202
463
|
return;
|
|
203
464
|
}
|
|
204
465
|
job.exitCode = code ?? 0;
|
|
@@ -213,20 +474,29 @@ export class AsyncJobManager {
|
|
|
213
474
|
job.status = "failed";
|
|
214
475
|
}
|
|
215
476
|
this.emitMetrics(job);
|
|
477
|
+
this.persistComplete(job);
|
|
478
|
+
this.fireOnComplete(job);
|
|
216
479
|
});
|
|
217
|
-
return this.snapshot(job);
|
|
480
|
+
return { snapshot: this.snapshot(job), deduped: false };
|
|
218
481
|
}
|
|
219
482
|
getJobSnapshot(jobId) {
|
|
220
|
-
|
|
483
|
+
let job = this.jobs.get(jobId);
|
|
221
484
|
if (!job) {
|
|
222
|
-
|
|
485
|
+
job = this.hydrateFromStore(jobId) ?? undefined;
|
|
486
|
+
if (!job)
|
|
487
|
+
return null;
|
|
223
488
|
}
|
|
224
489
|
return this.snapshot(job);
|
|
225
490
|
}
|
|
491
|
+
getJobSnapshots(jobIds) {
|
|
492
|
+
return Object.fromEntries(jobIds.map(jobId => [jobId, this.getJobSnapshot(jobId)]));
|
|
493
|
+
}
|
|
226
494
|
getJobResult(jobId, maxChars = 200000) {
|
|
227
|
-
|
|
495
|
+
let job = this.jobs.get(jobId);
|
|
228
496
|
if (!job) {
|
|
229
|
-
|
|
497
|
+
job = this.hydrateFromStore(jobId) ?? undefined;
|
|
498
|
+
if (!job)
|
|
499
|
+
return null;
|
|
230
500
|
}
|
|
231
501
|
const stdout = truncateText(job.stdout, maxChars);
|
|
232
502
|
const stderr = truncateText(job.stderr, maxChars);
|
|
@@ -246,14 +516,23 @@ export class AsyncJobManager {
|
|
|
246
516
|
if (job.status !== "running") {
|
|
247
517
|
return { canceled: false, reason: `Job is already ${job.status}` };
|
|
248
518
|
}
|
|
519
|
+
// Reconstituted (orphaned) jobs have no live process to signal — refuse cancel.
|
|
520
|
+
if (!job.process) {
|
|
521
|
+
return {
|
|
522
|
+
canceled: false,
|
|
523
|
+
reason: "Job has no live process (orphaned from prior gateway run)",
|
|
524
|
+
};
|
|
525
|
+
}
|
|
249
526
|
job.canceled = true;
|
|
250
527
|
job.status = "canceled";
|
|
251
528
|
job.finishedAt = new Date().toISOString();
|
|
252
529
|
job.clearIdleTimer?.();
|
|
253
530
|
killProcessGroup(job.process, "SIGTERM");
|
|
254
531
|
this.logger.info(`Job ${jobId} canceled`, { correlationId: job.correlationId });
|
|
532
|
+
this.persistComplete(job);
|
|
533
|
+
this.fireOnComplete(job);
|
|
255
534
|
setTimeout(() => {
|
|
256
|
-
if (!job.exited)
|
|
535
|
+
if (!job.exited && job.process)
|
|
257
536
|
killProcessGroup(job.process, "SIGKILL");
|
|
258
537
|
job.cleanupGroup?.();
|
|
259
538
|
}, 5000);
|
|
@@ -267,7 +546,7 @@ export class AsyncJobManager {
|
|
|
267
546
|
jobId: id,
|
|
268
547
|
cli: job.cli,
|
|
269
548
|
status: job.status,
|
|
270
|
-
pid: job.process
|
|
549
|
+
pid: job.process?.pid ?? null,
|
|
271
550
|
startedAt: job.startedAt,
|
|
272
551
|
});
|
|
273
552
|
}
|
|
@@ -316,13 +595,16 @@ export class AsyncJobManager {
|
|
|
316
595
|
job.error = "Output exceeded maximum size (50MB)";
|
|
317
596
|
job.finishedAt = new Date().toISOString();
|
|
318
597
|
job.clearIdleTimer?.();
|
|
319
|
-
|
|
598
|
+
if (job.process)
|
|
599
|
+
killProcessGroup(job.process, "SIGTERM");
|
|
320
600
|
this.logger.info(`Job ${job.id} killed due to output overflow`, {
|
|
321
601
|
correlationId: job.correlationId,
|
|
322
602
|
});
|
|
323
603
|
this.emitMetrics(job);
|
|
604
|
+
this.persistComplete(job);
|
|
605
|
+
this.fireOnComplete(job);
|
|
324
606
|
setTimeout(() => {
|
|
325
|
-
if (!job.exited)
|
|
607
|
+
if (!job.exited && job.process)
|
|
326
608
|
killProcessGroup(job.process, "SIGKILL");
|
|
327
609
|
job.cleanupGroup?.();
|
|
328
610
|
}, 5000);
|
|
@@ -337,5 +619,7 @@ export class AsyncJobManager {
|
|
|
337
619
|
else {
|
|
338
620
|
job.stderr += text;
|
|
339
621
|
}
|
|
622
|
+
job.outputDirty = true;
|
|
623
|
+
this.maybeFlushOutput(job);
|
|
340
624
|
}
|
|
341
625
|
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export interface AuthConfig {
|
|
3
|
+
required: boolean;
|
|
4
|
+
tokenConfigured: boolean;
|
|
5
|
+
source: "env" | "disabled";
|
|
6
|
+
}
|
|
7
|
+
export interface AuthResult {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
status?: number;
|
|
10
|
+
message?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function loadAuthConfig(env?: NodeJS.ProcessEnv): AuthConfig;
|
|
13
|
+
export declare function getRequiredBearerToken(env?: NodeJS.ProcessEnv): string | null;
|
|
14
|
+
export declare function authorizeBearerRequest(req: IncomingMessage, token?: string | null): AuthResult;
|
|
15
|
+
export declare function writeAuthFailure(res: ServerResponse, result: AuthResult): void;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const AUTH_SCHEME = "Bearer ";
|
|
2
|
+
export function loadAuthConfig(env = process.env) {
|
|
3
|
+
const token = env.LLM_GATEWAY_AUTH_TOKEN;
|
|
4
|
+
const disabled = env.LLM_GATEWAY_AUTH_DISABLED === "1";
|
|
5
|
+
return {
|
|
6
|
+
required: !disabled,
|
|
7
|
+
tokenConfigured: Boolean(token),
|
|
8
|
+
source: disabled ? "disabled" : "env",
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function getRequiredBearerToken(env = process.env) {
|
|
12
|
+
const config = loadAuthConfig(env);
|
|
13
|
+
if (!config.required)
|
|
14
|
+
return null;
|
|
15
|
+
return env.LLM_GATEWAY_AUTH_TOKEN || null;
|
|
16
|
+
}
|
|
17
|
+
export function authorizeBearerRequest(req, token = getRequiredBearerToken()) {
|
|
18
|
+
if (!loadAuthConfig().required) {
|
|
19
|
+
return { ok: true };
|
|
20
|
+
}
|
|
21
|
+
if (!token) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
status: 503,
|
|
25
|
+
message: "HTTP transport requires LLM_GATEWAY_AUTH_TOKEN",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const header = req.headers.authorization;
|
|
29
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
30
|
+
if (!value || !value.startsWith(AUTH_SCHEME)) {
|
|
31
|
+
return { ok: false, status: 401, message: "Unauthorized" };
|
|
32
|
+
}
|
|
33
|
+
const supplied = value.slice(AUTH_SCHEME.length);
|
|
34
|
+
if (supplied !== token) {
|
|
35
|
+
return { ok: false, status: 401, message: "Unauthorized" };
|
|
36
|
+
}
|
|
37
|
+
return { ok: true };
|
|
38
|
+
}
|
|
39
|
+
export function writeAuthFailure(res, result) {
|
|
40
|
+
const status = result.status ?? 401;
|
|
41
|
+
res.writeHead(status, {
|
|
42
|
+
"content-type": "application/json",
|
|
43
|
+
"www-authenticate": 'Bearer realm="llm-cli-gateway"',
|
|
44
|
+
});
|
|
45
|
+
res.end(JSON.stringify({ error: result.message || "Unauthorized" }));
|
|
46
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Logger } from "./logger.js";
|
|
2
|
+
import type { CliType } from "./session-manager.js";
|
|
3
|
+
import { type ProviderLoginStatus } from "./provider-status.js";
|
|
4
|
+
import type { ProviderLoginGuidance } from "./provider-login-guidance.js";
|
|
5
|
+
export interface CliVersionInfo {
|
|
6
|
+
cli: CliType;
|
|
7
|
+
command: string;
|
|
8
|
+
args: string[];
|
|
9
|
+
installed: boolean;
|
|
10
|
+
version?: string;
|
|
11
|
+
loginStatus?: ProviderLoginStatus;
|
|
12
|
+
loginGuidance?: ProviderLoginGuidance["login"];
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface CliUpgradePlan {
|
|
18
|
+
cli: CliType;
|
|
19
|
+
target: string;
|
|
20
|
+
command: string;
|
|
21
|
+
args: string[];
|
|
22
|
+
strategy: "self-update" | "npm-global-install" | "pip-install" | "uv-tool-upgrade" | "brew-upgrade";
|
|
23
|
+
requiresNetwork: boolean;
|
|
24
|
+
note?: string;
|
|
25
|
+
}
|
|
26
|
+
export type MistralInstallMethod = "pip" | "uv" | "brew" | "unknown";
|
|
27
|
+
/**
|
|
28
|
+
* Detect how Vibe was installed on this machine. Vibe does not self-update, so
|
|
29
|
+
* cli_upgrade has to dispatch to the package manager that owns the binary.
|
|
30
|
+
*
|
|
31
|
+
* Probe order: pip → uv → brew. The first one that returns a positive signal
|
|
32
|
+
* wins; if none do, callers should surface an actionable error rather than
|
|
33
|
+
* blindly running `vibe update` (a command that does not exist).
|
|
34
|
+
*/
|
|
35
|
+
export declare function detectMistralInstallMethod(exec?: (cmd: string, args: string[]) => {
|
|
36
|
+
exitCode: number | null;
|
|
37
|
+
stdout: string;
|
|
38
|
+
}): MistralInstallMethod;
|
|
39
|
+
export interface CliUpgradeResult {
|
|
40
|
+
dryRun: boolean;
|
|
41
|
+
plan: CliUpgradePlan;
|
|
42
|
+
stdout?: string;
|
|
43
|
+
stderr?: string;
|
|
44
|
+
exitCode?: number;
|
|
45
|
+
}
|
|
46
|
+
export declare function buildCliUpgradePlan(cli: CliType, target?: string, detectMistral?: () => MistralInstallMethod): CliUpgradePlan;
|
|
47
|
+
export declare function getCliVersion(cli: CliType): Promise<CliVersionInfo>;
|
|
48
|
+
export declare function getCliVersions(cli?: CliType): Promise<CliVersionInfo[]>;
|
|
49
|
+
export declare function runCliUpgrade(params: {
|
|
50
|
+
cli: CliType;
|
|
51
|
+
target?: string;
|
|
52
|
+
dryRun: boolean;
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
logger?: Logger;
|
|
55
|
+
}): Promise<CliUpgradeResult>;
|