llm-cli-gateway 1.0.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.
- package/CHANGELOG.md +541 -0
- package/LICENSE +21 -0
- package/README.md +545 -0
- package/dist/approval-manager.d.ts +43 -0
- package/dist/approval-manager.js +156 -0
- package/dist/async-job-manager.d.ts +57 -0
- package/dist/async-job-manager.js +334 -0
- package/dist/claude-mcp-config.d.ts +8 -0
- package/dist/claude-mcp-config.js +161 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.js +56 -0
- package/dist/db.d.ts +48 -0
- package/dist/db.js +170 -0
- package/dist/executor.d.ts +30 -0
- package/dist/executor.js +315 -0
- package/dist/health.d.ts +20 -0
- package/dist/health.js +32 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.js +1503 -0
- package/dist/logger.d.ts +6 -0
- package/dist/logger.js +5 -0
- package/dist/metrics.d.ts +23 -0
- package/dist/metrics.js +57 -0
- package/dist/migrate-sessions.d.ts +12 -0
- package/dist/migrate-sessions.js +145 -0
- package/dist/migrate.d.ts +2 -0
- package/dist/migrate.js +100 -0
- package/dist/model-registry.d.ts +10 -0
- package/dist/model-registry.js +346 -0
- package/dist/optimizer.d.ts +3 -0
- package/dist/optimizer.js +183 -0
- package/dist/process-monitor.d.ts +54 -0
- package/dist/process-monitor.js +146 -0
- package/dist/request-helpers.d.ts +25 -0
- package/dist/request-helpers.js +32 -0
- package/dist/resources.d.ts +26 -0
- package/dist/resources.js +201 -0
- package/dist/retry.d.ts +72 -0
- package/dist/retry.js +146 -0
- package/dist/review-integrity.d.ts +50 -0
- package/dist/review-integrity.js +283 -0
- package/dist/session-manager-pg.d.ts +76 -0
- package/dist/session-manager-pg.js +383 -0
- package/dist/session-manager.d.ts +62 -0
- package/dist/session-manager.js +223 -0
- package/dist/stream-json-parser.d.ts +35 -0
- package/dist/stream-json-parser.js +94 -0
- package/package.json +90 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand process health monitoring via /proc (Linux).
|
|
3
|
+
* Gracefully degrades on non-Linux platforms.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from "fs";
|
|
6
|
+
import { noopLogger } from "./logger.js";
|
|
7
|
+
/**
|
|
8
|
+
* Parse /proc/[pid]/stat safely.
|
|
9
|
+
* The `comm` field (field 2) is in parentheses and may contain spaces,
|
|
10
|
+
* so we find the LAST ')' and parse remaining fields from there.
|
|
11
|
+
*/
|
|
12
|
+
export function parseProcStat(content) {
|
|
13
|
+
const lastParen = content.lastIndexOf(")");
|
|
14
|
+
if (lastParen === -1)
|
|
15
|
+
return null;
|
|
16
|
+
const afterComm = content.slice(lastParen + 2); // skip ") "
|
|
17
|
+
const fields = afterComm.split(" ");
|
|
18
|
+
// fields[0] = state, fields[11] = utime (14-3), fields[12] = stime (15-3)
|
|
19
|
+
if (fields.length < 13)
|
|
20
|
+
return null;
|
|
21
|
+
return {
|
|
22
|
+
state: fields[0],
|
|
23
|
+
utime: parseInt(fields[11], 10),
|
|
24
|
+
stime: parseInt(fields[12], 10),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Parse VmRSS from /proc/[pid]/status.
|
|
29
|
+
* Returns RSS in kilobytes (already in kB in /proc/[pid]/status).
|
|
30
|
+
*/
|
|
31
|
+
export function parseVmRss(content) {
|
|
32
|
+
const match = content.match(/^VmRSS:\s+(\d+)\s+kB$/m);
|
|
33
|
+
return match ? parseInt(match[1], 10) : null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Read total system CPU jiffies from /proc/stat.
|
|
37
|
+
* Used to normalize per-process CPU into a percentage.
|
|
38
|
+
*/
|
|
39
|
+
function getTotalCpuJiffies() {
|
|
40
|
+
try {
|
|
41
|
+
const content = readFileSync("/proc/stat", "utf-8");
|
|
42
|
+
const cpuLine = content.split("\n")[0]; // "cpu user nice system idle ..."
|
|
43
|
+
const fields = cpuLine.split(/\s+/).slice(1).map(Number);
|
|
44
|
+
return fields.reduce((a, b) => a + b, 0);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export class ProcessMonitor {
|
|
51
|
+
logger;
|
|
52
|
+
// Previous samples for CPU delta calculation
|
|
53
|
+
prevSamples = new Map();
|
|
54
|
+
constructor(logger = noopLogger) {
|
|
55
|
+
this.logger = logger;
|
|
56
|
+
}
|
|
57
|
+
/** Clear all cached CPU samples */
|
|
58
|
+
reset() {
|
|
59
|
+
this.prevSamples.clear();
|
|
60
|
+
}
|
|
61
|
+
sampleProcess(pid) {
|
|
62
|
+
const now = new Date().toISOString();
|
|
63
|
+
// 1. Existence check
|
|
64
|
+
let alive = false;
|
|
65
|
+
try {
|
|
66
|
+
process.kill(pid, 0);
|
|
67
|
+
alive = true;
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
if (err.code === "ESRCH") {
|
|
71
|
+
return { pid, alive: false, state: null, cpuPercent: null, memoryRssKb: null, sampledAt: now };
|
|
72
|
+
}
|
|
73
|
+
// EPERM = process exists but we can't signal it
|
|
74
|
+
if (err.code === "EPERM") {
|
|
75
|
+
alive = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// 2. Parse /proc/[pid]/stat for state + CPU ticks
|
|
79
|
+
let state = null;
|
|
80
|
+
let cpuPercent = null;
|
|
81
|
+
try {
|
|
82
|
+
const statContent = readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
83
|
+
const parsed = parseProcStat(statContent);
|
|
84
|
+
if (parsed) {
|
|
85
|
+
state = parsed.state;
|
|
86
|
+
// CPU delta calculation
|
|
87
|
+
const totalJiffies = getTotalCpuJiffies();
|
|
88
|
+
const prev = this.prevSamples.get(pid);
|
|
89
|
+
if (prev && totalJiffies !== null) {
|
|
90
|
+
const processJiffiesDelta = (parsed.utime + parsed.stime) - (prev.utime + prev.stime);
|
|
91
|
+
const totalJiffiesDelta = totalJiffies - prev.totalJiffies;
|
|
92
|
+
if (totalJiffiesDelta > 0) {
|
|
93
|
+
cpuPercent = (processJiffiesDelta / totalJiffiesDelta) * 100;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Store for next delta
|
|
97
|
+
if (totalJiffies !== null) {
|
|
98
|
+
this.prevSamples.set(pid, {
|
|
99
|
+
utime: parsed.utime, stime: parsed.stime,
|
|
100
|
+
totalJiffies, timestamp: Date.now()
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// /proc not available (non-Linux) — degrade gracefully
|
|
107
|
+
}
|
|
108
|
+
// 3. Parse /proc/[pid]/status for VmRSS
|
|
109
|
+
let memoryRssKb = null;
|
|
110
|
+
try {
|
|
111
|
+
const statusContent = readFileSync(`/proc/${pid}/status`, "utf-8");
|
|
112
|
+
memoryRssKb = parseVmRss(statusContent);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Non-Linux or process exited between checks
|
|
116
|
+
}
|
|
117
|
+
return { pid, alive, state, cpuPercent, memoryRssKb, sampledAt: now };
|
|
118
|
+
}
|
|
119
|
+
checkJobHealth(jobs) {
|
|
120
|
+
return jobs.map(job => {
|
|
121
|
+
const runningForMs = Date.now() - new Date(job.startedAt).getTime();
|
|
122
|
+
if (!job.pid) {
|
|
123
|
+
return {
|
|
124
|
+
jobId: job.jobId, cli: job.cli, status: job.status,
|
|
125
|
+
processHealth: null, isDead: false, isZombie: false, runningForMs
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const health = this.sampleProcess(job.pid);
|
|
129
|
+
return {
|
|
130
|
+
jobId: job.jobId, cli: job.cli, status: job.status,
|
|
131
|
+
processHealth: health,
|
|
132
|
+
isDead: job.status === "running" && !health.alive,
|
|
133
|
+
isZombie: job.status === "running" && health.state === "Z",
|
|
134
|
+
runningForMs
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/** Clean up stale samples for PIDs that no longer exist */
|
|
139
|
+
cleanupSamples(activePids) {
|
|
140
|
+
for (const pid of this.prevSamples.keys()) {
|
|
141
|
+
if (!activePids.has(pid)) {
|
|
142
|
+
this.prevSamples.delete(pid);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, side-effect-free helpers for request argument planning.
|
|
3
|
+
* Zero I/O, zero dependencies on index-scoped collaborators.
|
|
4
|
+
*/
|
|
5
|
+
/** Prefix for gateway-generated session IDs. Enforces provenance structurally. */
|
|
6
|
+
export declare const GATEWAY_SESSION_PREFIX = "gw-";
|
|
7
|
+
export interface SessionResumeResult {
|
|
8
|
+
resumeArgs: string[];
|
|
9
|
+
effectiveSessionId: string | undefined;
|
|
10
|
+
userProvidedSession: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Validate that a user-provided sessionId doesn't use the reserved gateway prefix.
|
|
14
|
+
* Throws if the ID starts with "gw-" — this namespace is reserved for gateway-generated IDs.
|
|
15
|
+
*/
|
|
16
|
+
export declare function validateSessionId(sessionId: string): void;
|
|
17
|
+
/**
|
|
18
|
+
* Pure function: determine --resume args and session provenance from request flags.
|
|
19
|
+
* Does NOT perform any session I/O — callers handle create/update separately.
|
|
20
|
+
*/
|
|
21
|
+
export declare function resolveSessionResumeArgs(opts: {
|
|
22
|
+
sessionId?: string;
|
|
23
|
+
resumeLatest?: boolean;
|
|
24
|
+
createNewSession?: boolean;
|
|
25
|
+
}): SessionResumeResult;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, side-effect-free helpers for request argument planning.
|
|
3
|
+
* Zero I/O, zero dependencies on index-scoped collaborators.
|
|
4
|
+
*/
|
|
5
|
+
/** Prefix for gateway-generated session IDs. Enforces provenance structurally. */
|
|
6
|
+
export const GATEWAY_SESSION_PREFIX = "gw-";
|
|
7
|
+
/**
|
|
8
|
+
* Validate that a user-provided sessionId doesn't use the reserved gateway prefix.
|
|
9
|
+
* Throws if the ID starts with "gw-" — this namespace is reserved for gateway-generated IDs.
|
|
10
|
+
*/
|
|
11
|
+
export function validateSessionId(sessionId) {
|
|
12
|
+
if (sessionId.startsWith(GATEWAY_SESSION_PREFIX)) {
|
|
13
|
+
throw new Error(`Session ID "${sessionId}" uses reserved prefix "${GATEWAY_SESSION_PREFIX}". Gateway-generated session IDs cannot be used for --resume.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Pure function: determine --resume args and session provenance from request flags.
|
|
18
|
+
* Does NOT perform any session I/O — callers handle create/update separately.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveSessionResumeArgs(opts) {
|
|
21
|
+
if (opts.createNewSession) {
|
|
22
|
+
return { resumeArgs: [], effectiveSessionId: undefined, userProvidedSession: false };
|
|
23
|
+
}
|
|
24
|
+
if (opts.resumeLatest && !opts.sessionId) {
|
|
25
|
+
return { resumeArgs: ["--resume", "latest"], effectiveSessionId: undefined, userProvidedSession: false };
|
|
26
|
+
}
|
|
27
|
+
if (opts.sessionId) {
|
|
28
|
+
validateSessionId(opts.sessionId);
|
|
29
|
+
return { resumeArgs: ["--resume", opts.sessionId], effectiveSessionId: opts.sessionId, userProvidedSession: true };
|
|
30
|
+
}
|
|
31
|
+
return { resumeArgs: [], effectiveSessionId: undefined, userProvidedSession: false };
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ISessionManager } from "./session-manager.js";
|
|
2
|
+
import { PerformanceMetrics } from "./metrics.js";
|
|
3
|
+
export interface ResourceDefinition {
|
|
4
|
+
uri: string;
|
|
5
|
+
name: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
mimeType?: string;
|
|
9
|
+
annotations?: {
|
|
10
|
+
audience?: ("user" | "assistant")[];
|
|
11
|
+
priority?: number;
|
|
12
|
+
lastModified?: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface ResourceContents {
|
|
16
|
+
uri: string;
|
|
17
|
+
mimeType: string;
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
export declare class ResourceProvider {
|
|
21
|
+
private sessionManager;
|
|
22
|
+
private performanceMetrics;
|
|
23
|
+
constructor(sessionManager: ISessionManager, performanceMetrics: PerformanceMetrics);
|
|
24
|
+
listResources(): ResourceDefinition[];
|
|
25
|
+
readResource(uri: string): Promise<ResourceContents | null>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { getCliInfo } from "./model-registry.js";
|
|
2
|
+
export class ResourceProvider {
|
|
3
|
+
sessionManager;
|
|
4
|
+
performanceMetrics;
|
|
5
|
+
constructor(sessionManager, performanceMetrics) {
|
|
6
|
+
this.sessionManager = sessionManager;
|
|
7
|
+
this.performanceMetrics = performanceMetrics;
|
|
8
|
+
}
|
|
9
|
+
// List all available resources
|
|
10
|
+
listResources() {
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
uri: "sessions://all",
|
|
14
|
+
name: "All Sessions",
|
|
15
|
+
title: "📋 All Sessions",
|
|
16
|
+
description: "List of all conversation sessions across all CLIs",
|
|
17
|
+
mimeType: "application/json",
|
|
18
|
+
annotations: {
|
|
19
|
+
audience: ["user", "assistant"],
|
|
20
|
+
priority: 0.7,
|
|
21
|
+
lastModified: new Date().toISOString()
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
uri: "sessions://claude",
|
|
26
|
+
name: "Claude Sessions",
|
|
27
|
+
title: "🤖 Claude Sessions",
|
|
28
|
+
description: "List of Claude conversation sessions",
|
|
29
|
+
mimeType: "application/json",
|
|
30
|
+
annotations: {
|
|
31
|
+
audience: ["user", "assistant"],
|
|
32
|
+
priority: 0.6
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
uri: "sessions://codex",
|
|
37
|
+
name: "Codex Sessions",
|
|
38
|
+
title: "💻 Codex Sessions",
|
|
39
|
+
description: "List of Codex conversation sessions",
|
|
40
|
+
mimeType: "application/json",
|
|
41
|
+
annotations: {
|
|
42
|
+
audience: ["user", "assistant"],
|
|
43
|
+
priority: 0.6
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
uri: "sessions://gemini",
|
|
48
|
+
name: "Gemini Sessions",
|
|
49
|
+
title: "✨ Gemini Sessions",
|
|
50
|
+
description: "List of Gemini conversation sessions",
|
|
51
|
+
mimeType: "application/json",
|
|
52
|
+
annotations: {
|
|
53
|
+
audience: ["user", "assistant"],
|
|
54
|
+
priority: 0.6
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
uri: "models://claude",
|
|
59
|
+
name: "Claude Models",
|
|
60
|
+
title: "🧠 Claude Models & Capabilities",
|
|
61
|
+
description: "Available Claude models and their capabilities",
|
|
62
|
+
mimeType: "application/json",
|
|
63
|
+
annotations: {
|
|
64
|
+
audience: ["user", "assistant"],
|
|
65
|
+
priority: 0.8
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
uri: "models://codex",
|
|
70
|
+
name: "Codex Models",
|
|
71
|
+
title: "🔧 Codex Models & Capabilities",
|
|
72
|
+
description: "Available Codex models and their capabilities",
|
|
73
|
+
mimeType: "application/json",
|
|
74
|
+
annotations: {
|
|
75
|
+
audience: ["user", "assistant"],
|
|
76
|
+
priority: 0.8
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
uri: "models://gemini",
|
|
81
|
+
name: "Gemini Models",
|
|
82
|
+
title: "🌟 Gemini Models & Capabilities",
|
|
83
|
+
description: "Available Gemini models and their capabilities",
|
|
84
|
+
mimeType: "application/json",
|
|
85
|
+
annotations: {
|
|
86
|
+
audience: ["user", "assistant"],
|
|
87
|
+
priority: 0.8
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
uri: "metrics://performance",
|
|
92
|
+
name: "Performance Metrics",
|
|
93
|
+
title: "📈 Performance Metrics",
|
|
94
|
+
description: "Request counts, response times, and success/failure rates",
|
|
95
|
+
mimeType: "application/json",
|
|
96
|
+
annotations: {
|
|
97
|
+
audience: ["user", "assistant"],
|
|
98
|
+
priority: 0.9
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
// Read a specific resource by URI
|
|
104
|
+
async readResource(uri) {
|
|
105
|
+
// Session resources
|
|
106
|
+
if (uri === "sessions://all") {
|
|
107
|
+
const sessions = await this.sessionManager.listSessions();
|
|
108
|
+
return {
|
|
109
|
+
uri,
|
|
110
|
+
mimeType: "application/json",
|
|
111
|
+
text: JSON.stringify({
|
|
112
|
+
total: sessions.length,
|
|
113
|
+
sessions: sessions.map((s) => ({
|
|
114
|
+
id: s.id,
|
|
115
|
+
cli: s.cli,
|
|
116
|
+
description: s.description,
|
|
117
|
+
createdAt: s.createdAt,
|
|
118
|
+
lastUsedAt: s.lastUsedAt
|
|
119
|
+
})),
|
|
120
|
+
activeSessions: {
|
|
121
|
+
claude: (await this.sessionManager.getActiveSession("claude"))?.id || null,
|
|
122
|
+
codex: (await this.sessionManager.getActiveSession("codex"))?.id || null,
|
|
123
|
+
gemini: (await this.sessionManager.getActiveSession("gemini"))?.id || null
|
|
124
|
+
}
|
|
125
|
+
}, null, 2)
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (uri === "sessions://claude") {
|
|
129
|
+
const sessions = await this.sessionManager.listSessions("claude");
|
|
130
|
+
return {
|
|
131
|
+
uri,
|
|
132
|
+
mimeType: "application/json",
|
|
133
|
+
text: JSON.stringify({
|
|
134
|
+
cli: "claude",
|
|
135
|
+
total: sessions.length,
|
|
136
|
+
sessions,
|
|
137
|
+
activeSession: (await this.sessionManager.getActiveSession("claude"))?.id || null
|
|
138
|
+
}, null, 2)
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (uri === "sessions://codex") {
|
|
142
|
+
const sessions = await this.sessionManager.listSessions("codex");
|
|
143
|
+
return {
|
|
144
|
+
uri,
|
|
145
|
+
mimeType: "application/json",
|
|
146
|
+
text: JSON.stringify({
|
|
147
|
+
cli: "codex",
|
|
148
|
+
total: sessions.length,
|
|
149
|
+
sessions,
|
|
150
|
+
activeSession: (await this.sessionManager.getActiveSession("codex"))?.id || null
|
|
151
|
+
}, null, 2)
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (uri === "sessions://gemini") {
|
|
155
|
+
const sessions = await this.sessionManager.listSessions("gemini");
|
|
156
|
+
return {
|
|
157
|
+
uri,
|
|
158
|
+
mimeType: "application/json",
|
|
159
|
+
text: JSON.stringify({
|
|
160
|
+
cli: "gemini",
|
|
161
|
+
total: sessions.length,
|
|
162
|
+
sessions,
|
|
163
|
+
activeSession: (await this.sessionManager.getActiveSession("gemini"))?.id || null
|
|
164
|
+
}, null, 2)
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// Model capability resources
|
|
168
|
+
if (uri === "models://claude") {
|
|
169
|
+
const cliInfo = getCliInfo();
|
|
170
|
+
return {
|
|
171
|
+
uri,
|
|
172
|
+
mimeType: "application/json",
|
|
173
|
+
text: JSON.stringify(cliInfo.claude, null, 2)
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (uri === "models://codex") {
|
|
177
|
+
const cliInfo = getCliInfo();
|
|
178
|
+
return {
|
|
179
|
+
uri,
|
|
180
|
+
mimeType: "application/json",
|
|
181
|
+
text: JSON.stringify(cliInfo.codex, null, 2)
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (uri === "models://gemini") {
|
|
185
|
+
const cliInfo = getCliInfo();
|
|
186
|
+
return {
|
|
187
|
+
uri,
|
|
188
|
+
mimeType: "application/json",
|
|
189
|
+
text: JSON.stringify(cliInfo.gemini, null, 2)
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (uri === "metrics://performance") {
|
|
193
|
+
return {
|
|
194
|
+
uri,
|
|
195
|
+
mimeType: "application/json",
|
|
196
|
+
text: JSON.stringify(this.performanceMetrics.snapshot(), null, 2)
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
package/dist/retry.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A module for adding retry and circuit breaker logic to asynchronous operations.
|
|
3
|
+
*
|
|
4
|
+
* @module retry
|
|
5
|
+
*/
|
|
6
|
+
import type { Logger } from "./logger.js";
|
|
7
|
+
/**
|
|
8
|
+
* Defines the possible states of the circuit breaker.
|
|
9
|
+
*/
|
|
10
|
+
export declare enum CircuitBreakerState {
|
|
11
|
+
/** The circuit is closed and allows operations to execute. */
|
|
12
|
+
CLOSED = "CLOSED",
|
|
13
|
+
/** The circuit is open and fails operations immediately. */
|
|
14
|
+
OPEN = "OPEN",
|
|
15
|
+
/** The circuit is half-open and allows a single trial operation. */
|
|
16
|
+
HALF_OPEN = "HALF_OPEN"
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Represents the state and configuration of a circuit breaker.
|
|
20
|
+
*/
|
|
21
|
+
export interface CircuitBreaker {
|
|
22
|
+
state: CircuitBreakerState;
|
|
23
|
+
failures: number;
|
|
24
|
+
lastFailureTime: number | null;
|
|
25
|
+
readonly resetTimeout: number;
|
|
26
|
+
readonly failureThreshold: number;
|
|
27
|
+
onStateChange?: (newState: CircuitBreakerState, error?: Error) => void;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Configuration options for the retry logic.
|
|
31
|
+
*/
|
|
32
|
+
export interface RetryOptions {
|
|
33
|
+
/** The initial delay in milliseconds before the first retry. */
|
|
34
|
+
initialDelay: number;
|
|
35
|
+
/** The maximum delay in milliseconds between retries. */
|
|
36
|
+
maxDelay: number;
|
|
37
|
+
/** The exponential backoff factor. */
|
|
38
|
+
factor: number;
|
|
39
|
+
/**
|
|
40
|
+
* A function that determines if an error is transient and should be retried.
|
|
41
|
+
* @param error The error to check.
|
|
42
|
+
* @returns `true` if the error is transient, otherwise `false`.
|
|
43
|
+
*/
|
|
44
|
+
isTransient: (error: any) => boolean;
|
|
45
|
+
/**
|
|
46
|
+
* A callback function executed on each retry attempt.
|
|
47
|
+
* @param error The error that caused the retry.
|
|
48
|
+
* @param attempt The current retry attempt number.
|
|
49
|
+
* @param delay The delay in milliseconds before the next attempt.
|
|
50
|
+
*/
|
|
51
|
+
onRetry: (error: any, attempt: number, delay: number) => void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Creates a new CircuitBreaker instance with default settings.
|
|
55
|
+
* @param options Partial options to override defaults.
|
|
56
|
+
* @returns A new CircuitBreaker instance.
|
|
57
|
+
*/
|
|
58
|
+
export declare function createCircuitBreaker(options?: {
|
|
59
|
+
resetTimeout?: number;
|
|
60
|
+
failureThreshold?: number;
|
|
61
|
+
onStateChange?: (newState: CircuitBreakerState, error?: Error) => void;
|
|
62
|
+
}): CircuitBreaker;
|
|
63
|
+
/**
|
|
64
|
+
* Wraps an asynchronous operation with retry and circuit breaker logic.
|
|
65
|
+
*
|
|
66
|
+
* @template T The return type of the operation.
|
|
67
|
+
* @param {() => Promise<T>} operation The asynchronous operation to execute.
|
|
68
|
+
* @param {CircuitBreaker} circuitBreaker The circuit breaker instance to use.
|
|
69
|
+
* @param {Partial<RetryOptions>} [retryOptions] Options for retry behavior.
|
|
70
|
+
* @returns {Promise<T>} A promise that resolves with the result of the operation.
|
|
71
|
+
*/
|
|
72
|
+
export declare function withRetry<T>(operation: () => Promise<T>, circuitBreaker: CircuitBreaker, retryOptions?: Partial<RetryOptions>, logger?: Logger): Promise<T>;
|
package/dist/retry.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A module for adding retry and circuit breaker logic to asynchronous operations.
|
|
3
|
+
*
|
|
4
|
+
* @module retry
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Defines the possible states of the circuit breaker.
|
|
8
|
+
*/
|
|
9
|
+
export var CircuitBreakerState;
|
|
10
|
+
(function (CircuitBreakerState) {
|
|
11
|
+
/** The circuit is closed and allows operations to execute. */
|
|
12
|
+
CircuitBreakerState["CLOSED"] = "CLOSED";
|
|
13
|
+
/** The circuit is open and fails operations immediately. */
|
|
14
|
+
CircuitBreakerState["OPEN"] = "OPEN";
|
|
15
|
+
/** The circuit is half-open and allows a single trial operation. */
|
|
16
|
+
CircuitBreakerState["HALF_OPEN"] = "HALF_OPEN";
|
|
17
|
+
})(CircuitBreakerState || (CircuitBreakerState = {}));
|
|
18
|
+
/**
|
|
19
|
+
* Default function to determine if an error is transient.
|
|
20
|
+
* Retries on timeout (exit code 124) and common network errors.
|
|
21
|
+
* Does not retry on file-not-found (ENOENT) or other errors.
|
|
22
|
+
* @param error The error object.
|
|
23
|
+
* @returns True if the error is considered transient.
|
|
24
|
+
*/
|
|
25
|
+
const isDefaultTransient = (error) => {
|
|
26
|
+
if (!error) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
// Shell command-related errors
|
|
30
|
+
if (error.code === 124) { // wall-clock timeout (explicit, caller-set) — transient
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
// Note: exit code 125 = idle timeout (stuck process) — intentionally non-transient
|
|
34
|
+
if (error.code === 'ENOENT') { // command not found
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
// Node.js network errors
|
|
38
|
+
const transientErrorCodes = ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EPIPE'];
|
|
39
|
+
if (transientErrorCodes.includes(error.code)) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Creates a new CircuitBreaker instance with default settings.
|
|
46
|
+
* @param options Partial options to override defaults.
|
|
47
|
+
* @returns A new CircuitBreaker instance.
|
|
48
|
+
*/
|
|
49
|
+
export function createCircuitBreaker(options) {
|
|
50
|
+
return {
|
|
51
|
+
state: CircuitBreakerState.CLOSED,
|
|
52
|
+
failures: 0,
|
|
53
|
+
lastFailureTime: null,
|
|
54
|
+
resetTimeout: options?.resetTimeout ?? 60000, // 60 seconds
|
|
55
|
+
failureThreshold: options?.failureThreshold ?? 5,
|
|
56
|
+
onStateChange: options?.onStateChange,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Wraps an asynchronous operation with retry and circuit breaker logic.
|
|
61
|
+
*
|
|
62
|
+
* @template T The return type of the operation.
|
|
63
|
+
* @param {() => Promise<T>} operation The asynchronous operation to execute.
|
|
64
|
+
* @param {CircuitBreaker} circuitBreaker The circuit breaker instance to use.
|
|
65
|
+
* @param {Partial<RetryOptions>} [retryOptions] Options for retry behavior.
|
|
66
|
+
* @returns {Promise<T>} A promise that resolves with the result of the operation.
|
|
67
|
+
*/
|
|
68
|
+
export async function withRetry(operation, circuitBreaker, retryOptions, logger) {
|
|
69
|
+
const wrapError = (message, error) => {
|
|
70
|
+
const wrapped = new Error(message);
|
|
71
|
+
if (error) {
|
|
72
|
+
wrapped.cause = error;
|
|
73
|
+
if ("code" in error) {
|
|
74
|
+
wrapped.code = error.code;
|
|
75
|
+
}
|
|
76
|
+
if ("result" in error) {
|
|
77
|
+
wrapped.result = error.result;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return wrapped;
|
|
81
|
+
};
|
|
82
|
+
const options = {
|
|
83
|
+
initialDelay: 1000, // 1s
|
|
84
|
+
maxDelay: 30000, // 30s
|
|
85
|
+
factor: 2,
|
|
86
|
+
isTransient: isDefaultTransient,
|
|
87
|
+
onRetry: (error, attempt, delay) => {
|
|
88
|
+
logger?.debug(`[Retry] Attempt ${attempt} failed with transient error. Retrying in ${delay}ms... ${error.message}`);
|
|
89
|
+
},
|
|
90
|
+
...retryOptions,
|
|
91
|
+
};
|
|
92
|
+
if (circuitBreaker.state === CircuitBreakerState.OPEN) {
|
|
93
|
+
const timeSinceFailure = Date.now() - (circuitBreaker.lastFailureTime ?? 0);
|
|
94
|
+
if (timeSinceFailure > circuitBreaker.resetTimeout) {
|
|
95
|
+
circuitBreaker.state = CircuitBreakerState.HALF_OPEN;
|
|
96
|
+
circuitBreaker.onStateChange?.(CircuitBreakerState.HALF_OPEN);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const remaining = Math.ceil((circuitBreaker.resetTimeout - timeSinceFailure) / 1000);
|
|
100
|
+
throw wrapError(`[CircuitBreaker] Circuit is open. Failing fast. Will not try for another ${remaining}s.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const maxAttempts = circuitBreaker.failureThreshold;
|
|
104
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
105
|
+
try {
|
|
106
|
+
const result = await operation();
|
|
107
|
+
if (circuitBreaker.failures > 0 || circuitBreaker.state === CircuitBreakerState.HALF_OPEN) {
|
|
108
|
+
const oldState = circuitBreaker.state;
|
|
109
|
+
circuitBreaker.failures = 0;
|
|
110
|
+
circuitBreaker.lastFailureTime = null;
|
|
111
|
+
circuitBreaker.state = CircuitBreakerState.CLOSED;
|
|
112
|
+
if (oldState !== CircuitBreakerState.CLOSED) {
|
|
113
|
+
circuitBreaker.onStateChange?.(CircuitBreakerState.CLOSED);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (!options.isTransient(error)) {
|
|
120
|
+
throw wrapError(`[CircuitBreaker] Operation failed with non-transient error: ${error.message}`, error);
|
|
121
|
+
}
|
|
122
|
+
circuitBreaker.failures++;
|
|
123
|
+
circuitBreaker.lastFailureTime = Date.now();
|
|
124
|
+
if (circuitBreaker.state === CircuitBreakerState.HALF_OPEN) {
|
|
125
|
+
circuitBreaker.state = CircuitBreakerState.OPEN;
|
|
126
|
+
circuitBreaker.onStateChange?.(CircuitBreakerState.OPEN, error);
|
|
127
|
+
throw wrapError(`[CircuitBreaker] Circuit re-opened after failed attempt in HALF_OPEN state. Last error: ${error.message}`, error);
|
|
128
|
+
}
|
|
129
|
+
if (circuitBreaker.failures >= circuitBreaker.failureThreshold) {
|
|
130
|
+
const oldState = circuitBreaker.state;
|
|
131
|
+
circuitBreaker.state = CircuitBreakerState.OPEN;
|
|
132
|
+
if (oldState === CircuitBreakerState.CLOSED) {
|
|
133
|
+
circuitBreaker.onStateChange?.(CircuitBreakerState.OPEN, error);
|
|
134
|
+
}
|
|
135
|
+
throw wrapError(`[CircuitBreaker] Circuit opened after ${circuitBreaker.failures} consecutive failures. Last error: ${error.message}`, error);
|
|
136
|
+
}
|
|
137
|
+
if (attempt === maxAttempts) {
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
const delay = Math.min(options.initialDelay * options.factor ** (attempt - 1), options.maxDelay);
|
|
141
|
+
options.onRetry(error, attempt, delay);
|
|
142
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw new Error('[Retry] Operation failed after all retry attempts.');
|
|
146
|
+
}
|