lazyclaw 3.88.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/LICENSE +21 -0
- package/README.md +186 -0
- package/cli.mjs +2648 -0
- package/config-validate.mjs +61 -0
- package/daemon.mjs +1451 -0
- package/logger.mjs +55 -0
- package/package.json +55 -0
- package/providers/anthropic.mjs +313 -0
- package/providers/cache.mjs +132 -0
- package/providers/fallback.mjs +90 -0
- package/providers/gemini.mjs +187 -0
- package/providers/ollama.mjs +148 -0
- package/providers/openai.mjs +243 -0
- package/providers/rates.mjs +85 -0
- package/providers/registry.mjs +144 -0
- package/providers/retry.mjs +103 -0
- package/ratelimit.mjs +65 -0
- package/rates-validate.mjs +58 -0
- package/sessions.mjs +177 -0
- package/skills.mjs +97 -0
- package/web/server.mjs +33 -0
- package/workflow/executor.mjs +358 -0
- package/workflow/persistent.mjs +369 -0
- package/workflow/summary.mjs +318 -0
package/daemon.mjs
ADDED
|
@@ -0,0 +1,1451 @@
|
|
|
1
|
+
// Local-only HTTP daemon for LazyClaw — the OpenClaw "gateway" shape,
|
|
2
|
+
// scoped down to what this CLI actually offers.
|
|
3
|
+
//
|
|
4
|
+
// Always binds 127.0.0.1 (loopback). The endpoints are read-only inspection
|
|
5
|
+
// (version / providers / sessions) and `agent` for one-shot inference. The
|
|
6
|
+
// daemon never writes to disk under its own authority — only `agent` with
|
|
7
|
+
// an explicit `sessionId` ends up appending a turn to that session, which
|
|
8
|
+
// is the exact same operation the CLI does.
|
|
9
|
+
//
|
|
10
|
+
// Streaming: POST /agent with `{stream: true}` returns SSE
|
|
11
|
+
// (`data: <json>\n\n` per token, `event: done` to terminate). Without it,
|
|
12
|
+
// the response is a single JSON object once the full reply has arrived.
|
|
13
|
+
|
|
14
|
+
import http from 'node:http';
|
|
15
|
+
import nodePath from 'node:path';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
|
|
18
|
+
import { PROVIDERS, PROVIDER_INFO, maskApiKey } from './providers/registry.mjs';
|
|
19
|
+
import { withRateLimitRetry } from './providers/retry.mjs';
|
|
20
|
+
import { withFallback } from './providers/fallback.mjs';
|
|
21
|
+
import { withResponseCache } from './providers/cache.mjs';
|
|
22
|
+
import { costFromUsage, RATE_CARD_SHAPE } from './providers/rates.mjs';
|
|
23
|
+
import { composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill } from './skills.mjs';
|
|
24
|
+
import { TokenBucketLimiter } from './ratelimit.mjs';
|
|
25
|
+
import { createLogger } from './logger.mjs';
|
|
26
|
+
import { summarizeState, listSessions as listWorkflowSessions, loadStateFile as loadWorkflowState, aggregateNodeStats } from './workflow/summary.mjs';
|
|
27
|
+
import { validateConfig } from './config-validate.mjs';
|
|
28
|
+
import { validateRates } from './rates-validate.mjs';
|
|
29
|
+
|
|
30
|
+
// Resolve the provider for a request. Composes opt-in wrappers in this
|
|
31
|
+
// order (innermost first):
|
|
32
|
+
// 1. cache — wraps the base provider so cache hits never trigger
|
|
33
|
+
// fallback or retry (a hit is a successful response).
|
|
34
|
+
// 2. fallback — chain of alternates; pre-yield recoverable errors fall
|
|
35
|
+
// through; mid-stream errors bubble.
|
|
36
|
+
// 3. retry — outermost so each retry covers the full chain (retry
|
|
37
|
+
// exhausts → 429 to client).
|
|
38
|
+
// `cachedByName` is a per-handler Map shared across requests so the
|
|
39
|
+
// cache state actually persists between calls. Without that the cache
|
|
40
|
+
// would be empty on every request.
|
|
41
|
+
//
|
|
42
|
+
// Returns { provider } on success or { error } when the primary or any
|
|
43
|
+
// listed fallback name is unknown.
|
|
44
|
+
function resolveProvider(body, providerName, cachedByName, logger) {
|
|
45
|
+
if (!PROVIDERS[providerName]) return { error: `unknown provider: ${providerName}` };
|
|
46
|
+
// The decorator callbacks emit one debug line each — useful for ops who
|
|
47
|
+
// set --log debug to diagnose why a request is slow or which provider
|
|
48
|
+
// actually served it. With the default level (info) these are silent.
|
|
49
|
+
const dbg = (msg, fields) => { if (logger) logger.debug(msg, fields); };
|
|
50
|
+
const wrapWithCache = (name) => {
|
|
51
|
+
if (!cachedByName) return PROVIDERS[name];
|
|
52
|
+
if (!cachedByName.has(name)) {
|
|
53
|
+
cachedByName.set(name, withResponseCache(PROVIDERS[name], {
|
|
54
|
+
maxEntries: cachedByName._opts?.maxEntries,
|
|
55
|
+
ttlMs: cachedByName._opts?.ttlMs,
|
|
56
|
+
onHit: ({ keyHash, size }) => dbg('cache.hit', { provider: name, keyHash: keyHash.slice(0, 12), size }),
|
|
57
|
+
onMiss: ({ keyHash }) => dbg('cache.miss', { provider: name, keyHash: keyHash.slice(0, 12) }),
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
return cachedByName.get(name);
|
|
61
|
+
};
|
|
62
|
+
// Cache only when the request explicitly opts in. The handler-level
|
|
63
|
+
// Map is shared so two requests with body.cache=true to the same base
|
|
64
|
+
// provider hit the same cache.
|
|
65
|
+
const useCache = !!body?.cache;
|
|
66
|
+
let prov = useCache ? wrapWithCache(providerName) : PROVIDERS[providerName];
|
|
67
|
+
if (Array.isArray(body?.fallback) && body.fallback.length > 0) {
|
|
68
|
+
const chain = [prov];
|
|
69
|
+
for (const name of body.fallback) {
|
|
70
|
+
if (!PROVIDERS[name]) return { error: `unknown fallback provider: ${name}` };
|
|
71
|
+
chain.push(useCache ? wrapWithCache(name) : PROVIDERS[name]);
|
|
72
|
+
}
|
|
73
|
+
prov = withFallback(chain, {
|
|
74
|
+
onFallback: ({ from, to, err }) => dbg('provider.fallback', {
|
|
75
|
+
from, to, errorCode: err?.code || null, errorMsg: String(err?.message || err).slice(0, 120),
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const r = body?.retry;
|
|
80
|
+
if (r && Number.isFinite(r.attempts) && r.attempts > 0) {
|
|
81
|
+
prov = withRateLimitRetry(prov, {
|
|
82
|
+
attempts: r.attempts,
|
|
83
|
+
maxBackoffMs: r.maxBackoffMs,
|
|
84
|
+
onRetry: ({ attempt, retryAfterMs, err }) => dbg('provider.retry', {
|
|
85
|
+
attempt, retryAfterMs, errorCode: err?.code || null,
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return { provider: prov };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function fileExists(p) {
|
|
93
|
+
try { await fs.promises.access(p); return true; }
|
|
94
|
+
catch { return false; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readJson(req) {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
let buf = '';
|
|
100
|
+
req.setEncoding('utf8');
|
|
101
|
+
req.on('data', d => { buf += d; if (buf.length > 5 * 1024 * 1024) { reject(new Error('body too large')); req.destroy(); } });
|
|
102
|
+
req.on('end', () => {
|
|
103
|
+
if (!buf) return resolve({});
|
|
104
|
+
try { resolve(JSON.parse(buf)); }
|
|
105
|
+
catch (e) { reject(new Error(`invalid JSON body: ${e.message}`)); }
|
|
106
|
+
});
|
|
107
|
+
req.on('error', reject);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Raw body reader — used for `PUT /skills/<name>` where the body is
|
|
112
|
+
// markdown rather than JSON. Same 1 MiB cap as the CLI's `--from-url`
|
|
113
|
+
// path so HTTP can't sneak past the safeguard the CLI enforces.
|
|
114
|
+
const SKILL_MAX_BYTES = 1_048_576;
|
|
115
|
+
function readTextBody(req, maxBytes = SKILL_MAX_BYTES) {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
let buf = '';
|
|
118
|
+
req.setEncoding('utf8');
|
|
119
|
+
req.on('data', d => {
|
|
120
|
+
buf += d;
|
|
121
|
+
if (buf.length > maxBytes) {
|
|
122
|
+
reject(new Error(`body exceeds ${maxBytes} bytes`));
|
|
123
|
+
req.destroy();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
req.on('end', () => resolve(buf));
|
|
127
|
+
req.on('error', reject);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function writeJson(res, status, obj, extraHeaders = {}) {
|
|
132
|
+
const body = JSON.stringify(obj);
|
|
133
|
+
res.writeHead(status, {
|
|
134
|
+
'content-type': 'application/json; charset=utf-8',
|
|
135
|
+
'content-length': Buffer.byteLength(body),
|
|
136
|
+
...extraHeaders,
|
|
137
|
+
});
|
|
138
|
+
res.end(body);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Has the cumulative cost in any capped currency reached the cap?
|
|
142
|
+
// Returns the offending currency + amount + cap so the caller can
|
|
143
|
+
// surface it cleanly, or null when no cap is breached.
|
|
144
|
+
function checkCostCap(metrics, costCap) {
|
|
145
|
+
if (!costCap) return null;
|
|
146
|
+
for (const [cur, cap] of Object.entries(costCap)) {
|
|
147
|
+
if (!Number.isFinite(cap) || cap <= 0) continue;
|
|
148
|
+
const spent = metrics.costsByCurrency[cur] || 0;
|
|
149
|
+
if (spent >= cap) return { currency: cur, spent: Math.round(spent * 1_000_000) / 1_000_000, cap };
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Bump per-handler metrics from a single request's cost+usage. Keys
|
|
155
|
+
// cost by currency so heterogeneous fleets (USD-priced anthropic, EUR
|
|
156
|
+
// regional contracts) don't silently sum mismatched numbers. Tokens
|
|
157
|
+
// are unit-free → single counter.
|
|
158
|
+
function accumulateMetricsFromCost(metrics, usage, cost) {
|
|
159
|
+
if (cost && Number.isFinite(cost.cost)) {
|
|
160
|
+
const cur = cost.currency || 'USD';
|
|
161
|
+
metrics.costsByCurrency[cur] = (metrics.costsByCurrency[cur] || 0) + cost.cost;
|
|
162
|
+
}
|
|
163
|
+
if (usage) {
|
|
164
|
+
if (Number.isFinite(usage.inputTokens)) metrics.tokensTotal.inputTokens += usage.inputTokens;
|
|
165
|
+
if (Number.isFinite(usage.outputTokens)) metrics.tokensTotal.outputTokens += usage.outputTokens;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Map provider error codes to HTTP statuses so clients can branch on
|
|
170
|
+
// res.status instead of parsing error messages. Returns
|
|
171
|
+
// { status, headers? } so 429 can attach a Retry-After.
|
|
172
|
+
//
|
|
173
|
+
// Exported for unit testing without spinning up an actual provider that
|
|
174
|
+
// would only fail under live network conditions.
|
|
175
|
+
export function statusForProviderError(err) {
|
|
176
|
+
if (err?.code === 'INVALID_KEY') return { status: 401 };
|
|
177
|
+
if (err?.code === 'RATE_LIMIT') {
|
|
178
|
+
const retrySeconds = Math.max(1, Math.ceil((err.retryAfterMs || 1000) / 1000));
|
|
179
|
+
return { status: 429, headers: { 'retry-after': String(retrySeconds) } };
|
|
180
|
+
}
|
|
181
|
+
if (err?.status && err.status >= 400 && err.status < 600) return { status: err.status };
|
|
182
|
+
return { status: 502 };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function writeSseHead(res) {
|
|
186
|
+
res.writeHead(200, {
|
|
187
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
188
|
+
'cache-control': 'no-cache, no-transform',
|
|
189
|
+
'connection': 'close',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function writeSse(res, event, data) {
|
|
194
|
+
if (event) res.write(`event: ${event}\n`);
|
|
195
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Constant-time string equality. Plain `===` would short-circuit on the
|
|
200
|
+
* first mismatching byte, leaking timing info that lets an attacker on
|
|
201
|
+
* a shared host narrow the secret one byte at a time. We compare every
|
|
202
|
+
* byte with XOR + accumulator.
|
|
203
|
+
*/
|
|
204
|
+
function constantTimeEqual(a, b) {
|
|
205
|
+
const aStr = String(a ?? '');
|
|
206
|
+
const bStr = String(b ?? '');
|
|
207
|
+
if (aStr.length !== bStr.length) return false;
|
|
208
|
+
let diff = 0;
|
|
209
|
+
for (let i = 0; i < aStr.length; i++) {
|
|
210
|
+
diff |= aStr.charCodeAt(i) ^ bStr.charCodeAt(i);
|
|
211
|
+
}
|
|
212
|
+
return diff === 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isAuthorized(req, expectedToken) {
|
|
216
|
+
if (!expectedToken) return true; // auth disabled
|
|
217
|
+
const header = req.headers['authorization'] || '';
|
|
218
|
+
const m = /^Bearer\s+(.+)$/i.exec(header);
|
|
219
|
+
if (!m) return false;
|
|
220
|
+
return constantTimeEqual(m[1].trim(), expectedToken);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Origin gate — protect against DNS-rebinding / CSRF where a page in
|
|
225
|
+
* the user's browser posts to 127.0.0.1:<our port>. Browsers always
|
|
226
|
+
* attach `Origin` for cross-origin POSTs (and increasingly for GETs);
|
|
227
|
+
* CLI tools (curl, fetch from a script) usually don't.
|
|
228
|
+
*
|
|
229
|
+
* Policy:
|
|
230
|
+
* - No `Origin` header → assume non-browser caller, allow.
|
|
231
|
+
* - `Origin` set → must be in `allowedOrigins`. Empty allowlist
|
|
232
|
+
* means "reject all browser-originated requests" — the default,
|
|
233
|
+
* because the daemon is designed for CLI/script callers.
|
|
234
|
+
*
|
|
235
|
+
* Returns true when the request should proceed, false when it should
|
|
236
|
+
* be rejected with 403.
|
|
237
|
+
*/
|
|
238
|
+
function isOriginAllowed(req, allowedOrigins) {
|
|
239
|
+
const origin = req.headers['origin'];
|
|
240
|
+
if (!origin) return true;
|
|
241
|
+
if (!allowedOrigins || allowedOrigins.length === 0) return false;
|
|
242
|
+
return allowedOrigins.includes(origin);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {{
|
|
247
|
+
* readConfig: () => Record<string, unknown>,
|
|
248
|
+
* sessionsDirGetter: () => string,
|
|
249
|
+
* sessionsMod: typeof import('./sessions.mjs'),
|
|
250
|
+
* version: () => string,
|
|
251
|
+
* workflowStateDir?: () => string,
|
|
252
|
+
* authToken?: string,
|
|
253
|
+
* allowedOrigins?: string[],
|
|
254
|
+
* rateLimit?: { capacity?: number, refillPerSec?: number } | null,
|
|
255
|
+
* responseCache?: { maxEntries?: number, ttlMs?: number } | true | null,
|
|
256
|
+
* logger?: ReturnType<typeof createLogger> | null,
|
|
257
|
+
* costCap?: Record<string, number> | null,
|
|
258
|
+
* }} ctx
|
|
259
|
+
*/
|
|
260
|
+
export function makeHandler(ctx) {
|
|
261
|
+
const authToken = ctx.authToken || null;
|
|
262
|
+
const allowedOrigins = Array.isArray(ctx.allowedOrigins) ? ctx.allowedOrigins : [];
|
|
263
|
+
// Default state dir matches the CLI's default. Callers can override
|
|
264
|
+
// via ctx.workflowStateDir or LAZYCLAW_WORKFLOW_STATE_DIR env var.
|
|
265
|
+
const workflowStateDir = ctx.workflowStateDir
|
|
266
|
+
|| (() => process.env.LAZYCLAW_WORKFLOW_STATE_DIR || '.workflow-state');
|
|
267
|
+
ctx = { ...ctx, workflowStateDir };
|
|
268
|
+
// Rate limiter is opt-in; passing nothing → unlimited (the historical
|
|
269
|
+
// single-user-loopback default). When enabled, scope is per remote IP.
|
|
270
|
+
const limiter = ctx.rateLimit
|
|
271
|
+
? new TokenBucketLimiter({
|
|
272
|
+
capacity: ctx.rateLimit.capacity,
|
|
273
|
+
refillPerSec: ctx.rateLimit.refillPerSec,
|
|
274
|
+
})
|
|
275
|
+
: null;
|
|
276
|
+
// Cost cap: ctx.costCap = { USD: 1.50, EUR: 0.80, ... }. When the
|
|
277
|
+
// cumulative cost in any listed currency reaches its cap, /chat and
|
|
278
|
+
// /agent reject with 402 Payment Required. Other routes (/version,
|
|
279
|
+
// /metrics, etc.) stay reachable so monitoring still works after the
|
|
280
|
+
// cap fires. Empty/missing → unlimited (the historical default).
|
|
281
|
+
const costCap = ctx.costCap && typeof ctx.costCap === 'object' ? ctx.costCap : null;
|
|
282
|
+
// Per-handler cache map — populated lazily as requests opt in via
|
|
283
|
+
// body.cache. Shared across requests so the second identical call
|
|
284
|
+
// actually hits. We attach the configured opts so the lazy init
|
|
285
|
+
// gets the right TTL/maxEntries.
|
|
286
|
+
const cachedByName = ctx.responseCache ? Object.assign(new Map(), { _opts: ctx.responseCache === true ? {} : ctx.responseCache }) : null;
|
|
287
|
+
// Logger is opt-in via ctx.logger (the CLI passes one when --log <level>
|
|
288
|
+
// is set). Falsy → silent (the historical default; tests stay quiet).
|
|
289
|
+
const logger = ctx.logger || null;
|
|
290
|
+
// Per-handler metrics. The /metrics endpoint reads these. Bumped on
|
|
291
|
+
// res.close so middleware short-circuits (403/401/429) get counted.
|
|
292
|
+
const metrics = {
|
|
293
|
+
startedAtMs: Date.now(),
|
|
294
|
+
requestsTotal: 0,
|
|
295
|
+
requestsByStatus: /** @type {Record<string, number>} */({}),
|
|
296
|
+
rateLimitDenied: 0,
|
|
297
|
+
// Cumulative cost across all requests that produced a `cost` block.
|
|
298
|
+
// Keyed by currency so a heterogeneous fleet (USD-priced anthropic,
|
|
299
|
+
// EUR-priced regional contract) doesn't silently sum mismatched
|
|
300
|
+
// numbers. Tokens are unit-free so we keep them in a single counter.
|
|
301
|
+
costsByCurrency: /** @type {Record<string, number>} */({}),
|
|
302
|
+
tokensTotal: { inputTokens: 0, outputTokens: 0 },
|
|
303
|
+
};
|
|
304
|
+
return async function handler(req, res) {
|
|
305
|
+
// Capture method+path before any handler logic runs; req.url survives
|
|
306
|
+
// the response but capturing now keeps the log line stable even if a
|
|
307
|
+
// future refactor mutates req.
|
|
308
|
+
const startedAt = Date.now();
|
|
309
|
+
const method = req.method;
|
|
310
|
+
const path = (req.url || '/').split('?')[0];
|
|
311
|
+
const remote = req.socket?.remoteAddress || 'no-socket';
|
|
312
|
+
// Hook res.writeHead to capture the eventual status without
|
|
313
|
+
// intercepting the response body. We log on res 'close'.
|
|
314
|
+
let observedStatus = 0;
|
|
315
|
+
const origWriteHead = res.writeHead.bind(res);
|
|
316
|
+
res.writeHead = (status, ...rest) => {
|
|
317
|
+
observedStatus = status;
|
|
318
|
+
return origWriteHead(status, ...rest);
|
|
319
|
+
};
|
|
320
|
+
// Attach the close-handler only when res supports it. Unit tests
|
|
321
|
+
// sometimes drive the handler with a stub `res` that has writeHead +
|
|
322
|
+
// end but no event-emitter surface; those exercises don't care about
|
|
323
|
+
// metrics or access logs and should not crash.
|
|
324
|
+
if (typeof res.once === 'function') {
|
|
325
|
+
res.once('close', () => {
|
|
326
|
+
// Counters fire even without a logger so /metrics is meaningful
|
|
327
|
+
// by default. Status 0 means writeHead never ran (e.g. body parse
|
|
328
|
+
// crashed) — bucket those as "0" so we don't lose the request.
|
|
329
|
+
metrics.requestsTotal += 1;
|
|
330
|
+
const sk = String(observedStatus || 0);
|
|
331
|
+
metrics.requestsByStatus[sk] = (metrics.requestsByStatus[sk] || 0) + 1;
|
|
332
|
+
if (logger) {
|
|
333
|
+
const durationMs = Date.now() - startedAt;
|
|
334
|
+
logger.info('access', { method, path, status: observedStatus, durationMs, remote });
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
// Origin gate runs *before* auth so a browser-originated request
|
|
340
|
+
// can't even probe whether a token is required.
|
|
341
|
+
if (!isOriginAllowed(req, allowedOrigins)) {
|
|
342
|
+
return writeJson(res, 403, { error: 'forbidden origin' });
|
|
343
|
+
}
|
|
344
|
+
// Authentication gate — when authToken is set, every request must
|
|
345
|
+
// present `Authorization: Bearer <token>`. This is opt-in because
|
|
346
|
+
// the default deployment is loopback-only single-user; the token
|
|
347
|
+
// is for shared-host scenarios or when you want to expose the
|
|
348
|
+
// daemon over an SSH tunnel and lock down the open port.
|
|
349
|
+
if (authToken && !isAuthorized(req, authToken)) {
|
|
350
|
+
return writeJson(res, 401, { error: 'unauthorized' }, {
|
|
351
|
+
'www-authenticate': 'Bearer realm="lazyclaw"',
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
// Rate limit gate — *after* auth so the budget is per authenticated
|
|
355
|
+
// identity rather than per IP-pretending-to-be-someone-else. Authed
|
|
356
|
+
// means the remote actually proved they have the shared secret.
|
|
357
|
+
if (limiter) {
|
|
358
|
+
// The remote-IP key falls back to a fixed string for tests that
|
|
359
|
+
// drive the handler directly without a socket. socket.remoteAddress
|
|
360
|
+
// is "127.0.0.1" for loopback; that's fine for our scope.
|
|
361
|
+
const key = req.socket?.remoteAddress || 'no-socket';
|
|
362
|
+
const verdict = limiter.consume(key);
|
|
363
|
+
if (!verdict.allowed) {
|
|
364
|
+
metrics.rateLimitDenied += 1;
|
|
365
|
+
const retrySeconds = Math.max(1, Math.ceil(verdict.retryAfterMs / 1000));
|
|
366
|
+
return writeJson(res, 429, {
|
|
367
|
+
error: 'rate limit exceeded',
|
|
368
|
+
retryAfterMs: verdict.retryAfterMs,
|
|
369
|
+
}, { 'retry-after': String(retrySeconds) });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
373
|
+
const route = `${req.method} ${url.pathname}`;
|
|
374
|
+
const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
|
|
375
|
+
const providerMatch = url.pathname.match(/^\/providers\/([^/]+)$/);
|
|
376
|
+
const sessionExportMatch = url.pathname.match(/^\/sessions\/([^/]+)\/export$/);
|
|
377
|
+
const skillMatch = url.pathname.match(/^\/skills\/([^/]+)$/);
|
|
378
|
+
const workflowMatch = url.pathname.match(/^\/workflows\/([^/]+)$/);
|
|
379
|
+
const configKeyMatch = url.pathname.match(/^\/config\/([^/]+)$/);
|
|
380
|
+
switch (true) {
|
|
381
|
+
case route === 'GET /version':
|
|
382
|
+
return writeJson(res, 200, { version: ctx.version(), nodeVersion: process.version, platform: `${process.platform}-${process.arch}` });
|
|
383
|
+
case route === 'GET /health':
|
|
384
|
+
// Conventional liveness check — always 200 if the process
|
|
385
|
+
// is alive enough to hit the route. No config inspection
|
|
386
|
+
// (use /doctor for readiness), no provider probing — this
|
|
387
|
+
// is the "is the daemon up?" probe that load balancers
|
|
388
|
+
// and watchdog scripts expect at this path.
|
|
389
|
+
return writeJson(res, 200, {
|
|
390
|
+
ok: true,
|
|
391
|
+
status: 'alive',
|
|
392
|
+
uptimeMs: Date.now() - metrics.startedAtMs,
|
|
393
|
+
timestamp: new Date().toISOString(),
|
|
394
|
+
});
|
|
395
|
+
case route === 'GET /metrics': {
|
|
396
|
+
// Aggregate per-handler counters. cacheStats are pulled per
|
|
397
|
+
// wrapped provider — we report a sum across all populated
|
|
398
|
+
// entries so the figure reflects total cache activity.
|
|
399
|
+
let cacheHits = 0, cacheMisses = 0, cacheSize = 0;
|
|
400
|
+
if (cachedByName) {
|
|
401
|
+
for (const wrapped of cachedByName.values()) {
|
|
402
|
+
const s = typeof wrapped.cacheStats === 'function' ? wrapped.cacheStats() : null;
|
|
403
|
+
if (s) {
|
|
404
|
+
cacheHits += s.hits || 0;
|
|
405
|
+
cacheMisses += s.misses || 0;
|
|
406
|
+
cacheSize += s.size || 0;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// Cumulative tokens / cost — meaningful only when callers used
|
|
411
|
+
// body.usage / body.cost. The fields are always present (zero
|
|
412
|
+
// by default) so monitoring tooling sees a stable schema.
|
|
413
|
+
const tokensTotal = { ...metrics.tokensTotal };
|
|
414
|
+
const costs = {};
|
|
415
|
+
for (const [cur, n] of Object.entries(metrics.costsByCurrency)) {
|
|
416
|
+
// Round to six decimals here too, matching costFromUsage's
|
|
417
|
+
// precision so monitoring deltas line up with per-request
|
|
418
|
+
// breakdowns.
|
|
419
|
+
costs[cur] = Math.round(n * 1_000_000) / 1_000_000;
|
|
420
|
+
}
|
|
421
|
+
// Workflow snapshot — opportunistic. We scan the state dir
|
|
422
|
+
// once per /metrics call and count per bucket. This is
|
|
423
|
+
// cheap unless the user has thousands of state files; for
|
|
424
|
+
// truly large fleets the operator can disable by passing
|
|
425
|
+
// ctx.workflowMetrics === false.
|
|
426
|
+
let workflows = null;
|
|
427
|
+
if (ctx.workflowMetrics !== false) {
|
|
428
|
+
try {
|
|
429
|
+
const stateDir = ctx.workflowStateDir();
|
|
430
|
+
if (fs.existsSync(stateDir)) {
|
|
431
|
+
const sessions = listWorkflowSessions(stateDir);
|
|
432
|
+
workflows = { total: sessions.length, done: 0, resumable: 0, failed: 0, running: 0 };
|
|
433
|
+
for (const s of sessions) {
|
|
434
|
+
if (s.summary.done) workflows.done++;
|
|
435
|
+
if (s.summary.resumable) workflows.resumable++;
|
|
436
|
+
if (s.summary.failed > 0) workflows.failed++;
|
|
437
|
+
if (s.summary.running > 0) workflows.running++;
|
|
438
|
+
}
|
|
439
|
+
} else {
|
|
440
|
+
workflows = { total: 0, done: 0, resumable: 0, failed: 0, running: 0 };
|
|
441
|
+
}
|
|
442
|
+
} catch {
|
|
443
|
+
// Don't fail /metrics because the state dir is unreadable —
|
|
444
|
+
// expose the gap as null and keep monitoring alive.
|
|
445
|
+
workflows = null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return writeJson(res, 200, {
|
|
449
|
+
uptimeMs: Date.now() - metrics.startedAtMs,
|
|
450
|
+
requestsTotal: metrics.requestsTotal,
|
|
451
|
+
requestsByStatus: metrics.requestsByStatus,
|
|
452
|
+
rateLimitDenied: metrics.rateLimitDenied,
|
|
453
|
+
cache: cachedByName ? { hits: cacheHits, misses: cacheMisses, size: cacheSize } : null,
|
|
454
|
+
tokensTotal,
|
|
455
|
+
costsByCurrency: costs,
|
|
456
|
+
workflows,
|
|
457
|
+
timestamp: new Date().toISOString(),
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
case route === 'GET /providers': {
|
|
461
|
+
// ?filter=<substr>&limit=<N> mirror v3.33+ list flags.
|
|
462
|
+
let out = Object.keys(PROVIDERS).map(name => {
|
|
463
|
+
const meta = PROVIDER_INFO[name] || { name };
|
|
464
|
+
return { name, requiresApiKey: !!meta.requiresApiKey, defaultModel: meta.defaultModel || null, suggestedModels: meta.suggestedModels || [] };
|
|
465
|
+
});
|
|
466
|
+
const filter = url.searchParams.get('filter');
|
|
467
|
+
if (filter) {
|
|
468
|
+
const f = filter.toLowerCase();
|
|
469
|
+
out = out.filter(p => p.name.toLowerCase().includes(f));
|
|
470
|
+
}
|
|
471
|
+
const limitStr = url.searchParams.get('limit');
|
|
472
|
+
if (limitStr) {
|
|
473
|
+
const n = parseInt(limitStr, 10);
|
|
474
|
+
if (Number.isFinite(n) && n > 0) out = out.slice(0, n);
|
|
475
|
+
}
|
|
476
|
+
return writeJson(res, 200, out);
|
|
477
|
+
}
|
|
478
|
+
case req.method === 'GET' && !!providerMatch && providerMatch[1] !== 'test': {
|
|
479
|
+
// GET /providers/<name> — full per-provider metadata
|
|
480
|
+
// (mirrors CLI `lazyclaw providers info <name>`).
|
|
481
|
+
// The `name !== 'test'` guard keeps `/providers/test`
|
|
482
|
+
// (parallel batch endpoint) from being intercepted here;
|
|
483
|
+
// switch-case order ensures the literal `GET /providers/test`
|
|
484
|
+
// case runs first anyway, but the guard makes the intent
|
|
485
|
+
// explicit for future readers.
|
|
486
|
+
const name = providerMatch[1];
|
|
487
|
+
const meta = PROVIDER_INFO[name];
|
|
488
|
+
if (!meta) {
|
|
489
|
+
return writeJson(res, 404, {
|
|
490
|
+
error: 'unknown provider',
|
|
491
|
+
name,
|
|
492
|
+
knownProviders: Object.keys(PROVIDERS),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
return writeJson(res, 200, meta);
|
|
496
|
+
}
|
|
497
|
+
case route === 'GET /providers/test': {
|
|
498
|
+
// Mirror of CLI v3.55 `lazyclaw providers test` (no name).
|
|
499
|
+
// A dashboard's "key validity" badge calls this once and
|
|
500
|
+
// gets a per-provider verdict in one round trip. HTTP
|
|
501
|
+
// status mirrors CLI exit code:
|
|
502
|
+
// 200 — every provider returned a non-empty reply
|
|
503
|
+
// 503 — at least one provider failed (Service Unavailable;
|
|
504
|
+
// "the system is partially unhealthy")
|
|
505
|
+
// 503 is the right code because a dashboard observing it
|
|
506
|
+
// can render a yellow status without parsing the body.
|
|
507
|
+
const cfg = ctx.readConfig();
|
|
508
|
+
const apiKey = cfg['api-key'] || '';
|
|
509
|
+
const sharedPrompt = url.searchParams.get('prompt') || 'ping';
|
|
510
|
+
const tAll = Date.now();
|
|
511
|
+
const results = await Promise.all(
|
|
512
|
+
Object.entries(PROVIDERS).map(async ([pid, provider]) => {
|
|
513
|
+
const meta = PROVIDER_INFO[pid] || {};
|
|
514
|
+
const model = url.searchParams.get('model') || cfg.model || meta.defaultModel || 'unknown';
|
|
515
|
+
const t0 = Date.now();
|
|
516
|
+
try {
|
|
517
|
+
let reply = '';
|
|
518
|
+
const stream = provider.sendMessage([{ role: 'user', content: sharedPrompt }], { apiKey, model });
|
|
519
|
+
for await (const chunk of stream) {
|
|
520
|
+
if (typeof chunk === 'string') reply += chunk;
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
name: pid, ok: reply.length > 0, model,
|
|
524
|
+
durationMs: Date.now() - t0,
|
|
525
|
+
replyLength: reply.length,
|
|
526
|
+
};
|
|
527
|
+
} catch (err) {
|
|
528
|
+
return {
|
|
529
|
+
name: pid, ok: false, model,
|
|
530
|
+
durationMs: Date.now() - t0,
|
|
531
|
+
error: err?.message || String(err),
|
|
532
|
+
code: err?.code || null,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
}),
|
|
536
|
+
);
|
|
537
|
+
const allOk = results.every(r => r.ok);
|
|
538
|
+
return writeJson(res, allOk ? 200 : 503, {
|
|
539
|
+
ok: allOk,
|
|
540
|
+
totalDurationMs: Date.now() - tAll,
|
|
541
|
+
results,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
case route === 'GET /rates': {
|
|
545
|
+
// Read-only view of cfg.rates so a dashboard's cost panel
|
|
546
|
+
// can render the configured cards without shelling to the
|
|
547
|
+
// CLI. No write surface exposed — rate-card edits go
|
|
548
|
+
// through the CLI (operator-curated, deliberate).
|
|
549
|
+
// ?filter=<substr>&limit=<N> mirror v3.33+ list flags.
|
|
550
|
+
const cfg = ctx.readConfig();
|
|
551
|
+
const rates = cfg.rates && typeof cfg.rates === 'object' ? cfg.rates : {};
|
|
552
|
+
let entries = Object.entries(rates);
|
|
553
|
+
const filter = url.searchParams.get('filter');
|
|
554
|
+
if (filter) {
|
|
555
|
+
const f = filter.toLowerCase();
|
|
556
|
+
entries = entries.filter(([key]) => key.toLowerCase().includes(f));
|
|
557
|
+
}
|
|
558
|
+
const limitStr = url.searchParams.get('limit');
|
|
559
|
+
if (limitStr) {
|
|
560
|
+
const n = parseInt(limitStr, 10);
|
|
561
|
+
if (Number.isFinite(n) && n > 0) entries = entries.slice(0, n);
|
|
562
|
+
}
|
|
563
|
+
return writeJson(res, 200, Object.fromEntries(entries));
|
|
564
|
+
}
|
|
565
|
+
case route === 'GET /rates/validate': {
|
|
566
|
+
// Mirror of v3.30's `lazyclaw rates validate`. Same shape
|
|
567
|
+
// (single source of truth in rates-validate.mjs). HTTP
|
|
568
|
+
// status reflects ok/issues so a UI's cost-config badge
|
|
569
|
+
// can branch on HTTP code: 200 ok, 422 issues
|
|
570
|
+
// (Unprocessable Entity, same pattern as /config/validate
|
|
571
|
+
// in v3.40).
|
|
572
|
+
const cfg = ctx.readConfig();
|
|
573
|
+
const result = validateRates(cfg.rates, PROVIDERS);
|
|
574
|
+
return writeJson(res, result.ok ? 200 : 422, result);
|
|
575
|
+
}
|
|
576
|
+
case route === 'GET /rates/shape': {
|
|
577
|
+
// Mirror of `lazyclaw rates shape`. Returns the zero-filled
|
|
578
|
+
// reference rate-card template so a dashboard config panel
|
|
579
|
+
// or a script that scaffolds a new card can get the required
|
|
580
|
+
// fields without shelling to the CLI.
|
|
581
|
+
return writeJson(res, 200, RATE_CARD_SHAPE);
|
|
582
|
+
}
|
|
583
|
+
case route === 'GET /status': {
|
|
584
|
+
const cfg = ctx.readConfig();
|
|
585
|
+
return writeJson(res, 200, {
|
|
586
|
+
provider: cfg.provider || null,
|
|
587
|
+
model: cfg.model || null,
|
|
588
|
+
keyMasked: maskApiKey(cfg['api-key']),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
case route === 'GET /config/validate': {
|
|
592
|
+
// Mirror of v3.39's `lazyclaw config validate`. Same shape
|
|
593
|
+
// (single source of truth in config-validate.mjs). HTTP
|
|
594
|
+
// status reflects ok/issues so a UI's "config status"
|
|
595
|
+
// badge can branch on HTTP code: 200 = ok, 422 = issues
|
|
596
|
+
// (Unprocessable Entity — semantically right for "the
|
|
597
|
+
// config you supplied is malformed").
|
|
598
|
+
const cfg = ctx.readConfig();
|
|
599
|
+
const { ok, issues, warnings } = validateConfig(cfg, PROVIDERS);
|
|
600
|
+
return writeJson(res, ok ? 200 : 422, {
|
|
601
|
+
ok,
|
|
602
|
+
keys: Object.keys(cfg),
|
|
603
|
+
issues,
|
|
604
|
+
warnings,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
case route === 'GET /config': {
|
|
608
|
+
// Mirror of `lazyclaw config list`. Returns every stored key
|
|
609
|
+
// with the api-key value masked — lets a dashboard or script
|
|
610
|
+
// inspect the active configuration without shelling to the CLI.
|
|
611
|
+
const cfg = ctx.readConfig();
|
|
612
|
+
const safe = { ...cfg };
|
|
613
|
+
if (safe['api-key']) safe['api-key'] = maskApiKey(safe['api-key']);
|
|
614
|
+
return writeJson(res, 200, safe);
|
|
615
|
+
}
|
|
616
|
+
case req.method === 'GET' && !!configKeyMatch && configKeyMatch[1] !== 'validate': {
|
|
617
|
+
// Mirror of `lazyclaw config get <key>`. The `!== 'validate'`
|
|
618
|
+
// guard ensures the literal GET /config/validate case (above)
|
|
619
|
+
// is never shadowed by this dynamic handler.
|
|
620
|
+
const key = configKeyMatch[1];
|
|
621
|
+
const cfg = ctx.readConfig();
|
|
622
|
+
if (!(key in cfg)) {
|
|
623
|
+
return writeJson(res, 404, { error: 'key not found', key });
|
|
624
|
+
}
|
|
625
|
+
const raw = cfg[key];
|
|
626
|
+
const value = key === 'api-key' ? maskApiKey(raw) : raw;
|
|
627
|
+
return writeJson(res, 200, { key, value });
|
|
628
|
+
}
|
|
629
|
+
case route === 'GET /doctor': {
|
|
630
|
+
// Mirror the CLI doctor output — same field set so any tool that
|
|
631
|
+
// already knows how to read CLI doctor JSON can hit this endpoint.
|
|
632
|
+
const cfg = ctx.readConfig();
|
|
633
|
+
const issues = [];
|
|
634
|
+
if (!cfg.provider) issues.push('config.provider is missing');
|
|
635
|
+
if (cfg.provider && cfg.provider !== 'mock' && !cfg['api-key']) {
|
|
636
|
+
issues.push(`config['api-key'] is missing for provider "${cfg.provider}"`);
|
|
637
|
+
}
|
|
638
|
+
if (cfg.provider && !Object.prototype.hasOwnProperty.call(PROVIDERS, cfg.provider)) {
|
|
639
|
+
issues.push(`unknown provider "${cfg.provider}"`);
|
|
640
|
+
}
|
|
641
|
+
const ok = issues.length === 0;
|
|
642
|
+
return writeJson(res, ok ? 200 : 503, {
|
|
643
|
+
ok,
|
|
644
|
+
provider: cfg.provider || null,
|
|
645
|
+
model: cfg.model || null,
|
|
646
|
+
hasApiKey: !!cfg['api-key'],
|
|
647
|
+
nodeVersion: process.version,
|
|
648
|
+
platform: `${process.platform}-${process.arch}`,
|
|
649
|
+
issues,
|
|
650
|
+
knownProviders: Object.keys(PROVIDERS),
|
|
651
|
+
timestamp: new Date().toISOString(),
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
case route === 'GET /sessions': {
|
|
655
|
+
// ?filter=<substr> case-insensitive id substring;
|
|
656
|
+
// ?limit=<N> caps post-filter count.
|
|
657
|
+
// Same composition (filter then limit) as v3.33's CLI flag.
|
|
658
|
+
// ?withTurnCount=true mirrors CLI v3.59's --with-turn-count;
|
|
659
|
+
// opt-in because it loads each session file.
|
|
660
|
+
// ?sortBy=mtime|turn-count|bytes|id mirrors CLI v3.60's --sort-by;
|
|
661
|
+
// turn-count implicitly enables turn-count loading.
|
|
662
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
663
|
+
let list = ctx.sessionsMod.listSessions(cfgDir);
|
|
664
|
+
const filter = url.searchParams.get('filter');
|
|
665
|
+
if (filter) {
|
|
666
|
+
const f = filter.toLowerCase();
|
|
667
|
+
list = list.filter(s => s.id.toLowerCase().includes(f));
|
|
668
|
+
}
|
|
669
|
+
const limitStr = url.searchParams.get('limit');
|
|
670
|
+
if (limitStr) {
|
|
671
|
+
const n = parseInt(limitStr, 10);
|
|
672
|
+
if (Number.isFinite(n) && n > 0) list = list.slice(0, n);
|
|
673
|
+
}
|
|
674
|
+
const sortBy = url.searchParams.get('sortBy');
|
|
675
|
+
const validSortBy = new Set(['mtime', 'turn-count', 'bytes', 'id']);
|
|
676
|
+
if (sortBy && !validSortBy.has(sortBy)) {
|
|
677
|
+
return writeJson(res, 400, { error: `invalid sortBy: ${sortBy} (expected: mtime, turn-count, bytes, id)` });
|
|
678
|
+
}
|
|
679
|
+
const withCount = url.searchParams.get('withTurnCount') === 'true' || sortBy === 'turn-count';
|
|
680
|
+
let out = list.map(s => {
|
|
681
|
+
const base = { id: s.id, bytes: s.bytes, mtime: new Date(s.mtimeMs).toISOString(), _mtimeMs: s.mtimeMs };
|
|
682
|
+
if (withCount) {
|
|
683
|
+
try { base.turnCount = ctx.sessionsMod.loadTurns(s.id, cfgDir).length; }
|
|
684
|
+
catch { base.turnCount = null; }
|
|
685
|
+
}
|
|
686
|
+
return base;
|
|
687
|
+
});
|
|
688
|
+
if (sortBy) {
|
|
689
|
+
const cmp = {
|
|
690
|
+
mtime: (a, b) => b._mtimeMs - a._mtimeMs,
|
|
691
|
+
'turn-count': (a, b) => (b.turnCount ?? 0) - (a.turnCount ?? 0),
|
|
692
|
+
bytes: (a, b) => b.bytes - a.bytes,
|
|
693
|
+
id: (a, b) => a.id.localeCompare(b.id),
|
|
694
|
+
};
|
|
695
|
+
out.sort(cmp[sortBy]);
|
|
696
|
+
}
|
|
697
|
+
return writeJson(res, 200, out.map(({ _mtimeMs, ...rest }) => rest));
|
|
698
|
+
}
|
|
699
|
+
case route === 'GET /sessions/search': {
|
|
700
|
+
// Mirror of `lazyclaw sessions search <query> [--regex]`.
|
|
701
|
+
// ?q=<query> required; ?regex=true switches to regex mode.
|
|
702
|
+
// Returns { query, regex, matches: [{ id, mtime, matchCount, excerpt }] }
|
|
703
|
+
// — same shape the CLI prints. A dashboard rendering the
|
|
704
|
+
// search box can use the same parser for both surfaces.
|
|
705
|
+
const q = url.searchParams.get('q');
|
|
706
|
+
if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
|
|
707
|
+
const useRegex = url.searchParams.get('regex') === 'true';
|
|
708
|
+
let matcher;
|
|
709
|
+
if (useRegex) {
|
|
710
|
+
try { matcher = new RegExp(q, 'i'); }
|
|
711
|
+
catch (e) { return writeJson(res, 400, { error: `invalid regex: ${e.message}` }); }
|
|
712
|
+
} else {
|
|
713
|
+
const ql = q.toLowerCase();
|
|
714
|
+
matcher = { test: (s) => String(s).toLowerCase().includes(ql) };
|
|
715
|
+
}
|
|
716
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
717
|
+
const list = ctx.sessionsMod.listSessions(cfgDir);
|
|
718
|
+
const matches = [];
|
|
719
|
+
for (const s of list) {
|
|
720
|
+
const turns = ctx.sessionsMod.loadTurns(s.id, cfgDir);
|
|
721
|
+
let matchCount = 0;
|
|
722
|
+
let firstExcerpt = null;
|
|
723
|
+
for (const t of turns) {
|
|
724
|
+
if (typeof t?.content !== 'string') continue;
|
|
725
|
+
if (matcher.test(t.content)) {
|
|
726
|
+
matchCount++;
|
|
727
|
+
if (firstExcerpt === null) {
|
|
728
|
+
const c = t.content;
|
|
729
|
+
let pos = useRegex ? c.search(matcher) : c.toLowerCase().indexOf(q.toLowerCase());
|
|
730
|
+
if (pos < 0) pos = 0;
|
|
731
|
+
const start = Math.max(0, pos - 40);
|
|
732
|
+
const end = Math.min(c.length, pos + q.length + 40);
|
|
733
|
+
firstExcerpt = (start > 0 ? '…' : '') + c.slice(start, end) + (end < c.length ? '…' : '');
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (matchCount > 0) {
|
|
738
|
+
matches.push({
|
|
739
|
+
id: s.id,
|
|
740
|
+
mtime: new Date(s.mtimeMs).toISOString(),
|
|
741
|
+
matchCount,
|
|
742
|
+
excerpt: firstExcerpt,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return writeJson(res, 200, { query: q, regex: useRegex, matches });
|
|
747
|
+
}
|
|
748
|
+
case req.method === 'GET' && !!sessionExportMatch: {
|
|
749
|
+
// GET /sessions/<id>/export?format=md|json|text — same body
|
|
750
|
+
// the CLI's `lazyclaw sessions export <id> --format ...`
|
|
751
|
+
// produces, with the appropriate content-type. The dashboard
|
|
752
|
+
// can offer a "download as ..." button without spawning the
|
|
753
|
+
// CLI.
|
|
754
|
+
const id = sessionExportMatch[1];
|
|
755
|
+
try {
|
|
756
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
757
|
+
const file = ctx.sessionsMod.sessionPath(id, cfgDir);
|
|
758
|
+
if (!(await fileExists(file))) return writeJson(res, 404, { error: 'session not found', id });
|
|
759
|
+
const fmt = (url.searchParams.get('format') || 'md').toLowerCase();
|
|
760
|
+
const FORMATS = {
|
|
761
|
+
md: { fn: ctx.sessionsMod.exportMarkdown, mime: 'text/markdown; charset=utf-8' },
|
|
762
|
+
markdown: { fn: ctx.sessionsMod.exportMarkdown, mime: 'text/markdown; charset=utf-8' },
|
|
763
|
+
json: { fn: ctx.sessionsMod.exportJson, mime: 'application/json; charset=utf-8' },
|
|
764
|
+
text: { fn: ctx.sessionsMod.exportText, mime: 'text/plain; charset=utf-8' },
|
|
765
|
+
txt: { fn: ctx.sessionsMod.exportText, mime: 'text/plain; charset=utf-8' },
|
|
766
|
+
};
|
|
767
|
+
const f = FORMATS[fmt];
|
|
768
|
+
if (!f) {
|
|
769
|
+
return writeJson(res, 400, {
|
|
770
|
+
error: `unknown format: ${fmt}`,
|
|
771
|
+
expected: ['md', 'json', 'text'],
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
const body = f.fn(id, cfgDir);
|
|
775
|
+
res.writeHead(200, {
|
|
776
|
+
'content-type': f.mime,
|
|
777
|
+
'content-length': Buffer.byteLength(body),
|
|
778
|
+
});
|
|
779
|
+
return res.end(body);
|
|
780
|
+
} catch (err) {
|
|
781
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
case req.method === 'GET' && !!sessionMatch: {
|
|
785
|
+
// GET /sessions/<id> — full turn log. Returns 404 when missing
|
|
786
|
+
// rather than an empty array so the caller can distinguish
|
|
787
|
+
// "session does not exist" from "session is empty".
|
|
788
|
+
const id = sessionMatch[1];
|
|
789
|
+
try {
|
|
790
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
791
|
+
const file = ctx.sessionsMod.sessionPath(id, cfgDir);
|
|
792
|
+
if (!(await fileExists(file))) return writeJson(res, 404, { error: 'session not found', id });
|
|
793
|
+
const turns = ctx.sessionsMod.loadTurns(id, cfgDir);
|
|
794
|
+
return writeJson(res, 200, { id, turns });
|
|
795
|
+
} catch (err) {
|
|
796
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
case route === 'GET /workflows/aggregate': {
|
|
800
|
+
// Mirror of CLI v3.48 `lazyclaw inspect --aggregate`. Per-node
|
|
801
|
+
// statistics across every persisted session in the state
|
|
802
|
+
// directory. Answers "which node tends to be slow / fail
|
|
803
|
+
// across all my runs?" — needs no workflow file, just state.
|
|
804
|
+
// ?filter=<substr> applies before aggregation (v3.50).
|
|
805
|
+
const stateDir = ctx.workflowStateDir();
|
|
806
|
+
const qFilter = url.searchParams.get('filter');
|
|
807
|
+
const qNode = url.searchParams.get('node');
|
|
808
|
+
try {
|
|
809
|
+
const stats = aggregateNodeStats(stateDir, { filter: qFilter });
|
|
810
|
+
// ?node=<id>: drill into one node's cross-session stats
|
|
811
|
+
// (mirrors CLI v3.52 --aggregate --node). 404 when the
|
|
812
|
+
// node never appeared in any session.
|
|
813
|
+
if (qNode) {
|
|
814
|
+
const nodeStat = stats.nodeStats[qNode];
|
|
815
|
+
if (!nodeStat) {
|
|
816
|
+
return writeJson(res, 404, {
|
|
817
|
+
error: 'node not found across sessions',
|
|
818
|
+
nodeId: qNode,
|
|
819
|
+
knownNodes: Object.keys(stats.nodeStats),
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
return writeJson(res, 200, {
|
|
823
|
+
dir: stateDir,
|
|
824
|
+
filter: qFilter || null,
|
|
825
|
+
sessionCount: stats.sessionCount,
|
|
826
|
+
nodeId: qNode,
|
|
827
|
+
...nodeStat,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
return writeJson(res, 200, { dir: stateDir, filter: qFilter || null, ...stats });
|
|
831
|
+
} catch (err) {
|
|
832
|
+
if (err?.code === 'ENOENT') {
|
|
833
|
+
return writeJson(res, 200, { dir: stateDir, filter: qFilter || null, sessionCount: 0, nodeStats: {} });
|
|
834
|
+
}
|
|
835
|
+
return writeJson(res, 500, { error: err?.message || String(err) });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
case route === 'GET /workflows': {
|
|
839
|
+
// List every persisted workflow session in the configured
|
|
840
|
+
// state dir, newest activity first. Mirrors `lazyclaw inspect`
|
|
841
|
+
// (no-arg) exactly so a dashboard can use the same renderer
|
|
842
|
+
// for CLI and HTTP outputs. Per-node `nodes` map is omitted —
|
|
843
|
+
// call /workflows/<sessionId> for full detail.
|
|
844
|
+
//
|
|
845
|
+
// ?status=done|resumable|failed|running mirrors the CLI's
|
|
846
|
+
// --status flag — one shared predicate so a UI can paginate
|
|
847
|
+
// by bucket without pulling the full list.
|
|
848
|
+
const stateDir = ctx.workflowStateDir();
|
|
849
|
+
const qStatus = url.searchParams.get('status');
|
|
850
|
+
if (qStatus) {
|
|
851
|
+
const valid = new Set(['done', 'resumable', 'failed', 'running']);
|
|
852
|
+
if (!valid.has(qStatus)) {
|
|
853
|
+
return writeJson(res, 400, {
|
|
854
|
+
error: `invalid status: ${qStatus}`,
|
|
855
|
+
expected: [...valid],
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
try {
|
|
860
|
+
let sessions = listWorkflowSessions(stateDir);
|
|
861
|
+
if (qStatus) {
|
|
862
|
+
sessions = sessions.filter(s => {
|
|
863
|
+
if (qStatus === 'done') return s.summary.done;
|
|
864
|
+
if (qStatus === 'resumable') return s.summary.resumable;
|
|
865
|
+
if (qStatus === 'failed') return s.summary.failed > 0;
|
|
866
|
+
if (qStatus === 'running') return s.summary.running > 0;
|
|
867
|
+
return true;
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
// ?filter=<substr>&limit=<N> mirror v3.33 sessions/skills list flags.
|
|
871
|
+
const qFilter = url.searchParams.get('filter');
|
|
872
|
+
if (qFilter) {
|
|
873
|
+
const f = qFilter.toLowerCase();
|
|
874
|
+
sessions = sessions.filter(s => s.sessionId.toLowerCase().includes(f));
|
|
875
|
+
}
|
|
876
|
+
const qLimit = url.searchParams.get('limit');
|
|
877
|
+
if (qLimit) {
|
|
878
|
+
const n = parseInt(qLimit, 10);
|
|
879
|
+
if (Number.isFinite(n) && n > 0) sessions = sessions.slice(0, n);
|
|
880
|
+
}
|
|
881
|
+
return writeJson(res, 200, { dir: stateDir, status: qStatus || null, sessions });
|
|
882
|
+
} catch (err) {
|
|
883
|
+
if (err?.code === 'ENOENT') {
|
|
884
|
+
// Empty dir is a valid state (no workflows ever ran). The
|
|
885
|
+
// CLI distinguishes "missing dir" from "empty dir" — the
|
|
886
|
+
// daemon collapses both to an empty array so a fresh
|
|
887
|
+
// process doesn't 404 a UI poll loop.
|
|
888
|
+
return writeJson(res, 200, { dir: stateDir, status: qStatus || null, sessions: [] });
|
|
889
|
+
}
|
|
890
|
+
return writeJson(res, 500, { error: err?.message || String(err) });
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
case req.method === 'GET' && !!workflowMatch: {
|
|
894
|
+
// GET /workflows/<sessionId> — full state of a single
|
|
895
|
+
// workflow run. Same shape as `lazyclaw inspect <id>` (the
|
|
896
|
+
// engine's persisted object plus a derived summary block).
|
|
897
|
+
// 404 when the state file is missing.
|
|
898
|
+
const sid = workflowMatch[1];
|
|
899
|
+
const stateDir = ctx.workflowStateDir();
|
|
900
|
+
let state;
|
|
901
|
+
try {
|
|
902
|
+
state = loadWorkflowState(sid, stateDir);
|
|
903
|
+
} catch (err) {
|
|
904
|
+
return writeJson(res, 500, { error: err?.message || String(err) });
|
|
905
|
+
}
|
|
906
|
+
if (!state) return writeJson(res, 404, { error: 'workflow not found', sessionId: sid });
|
|
907
|
+
// ?node=<id> drills into one node's state — same shape as
|
|
908
|
+
// `lazyclaw inspect <session> --node <id>` (v3.41). The
|
|
909
|
+
// HTTP status reflects the node's lifecycle (mirrors the
|
|
910
|
+
// CLI exit codes): 200 success/pending/running, 410 Gone
|
|
911
|
+
// for failed (request was valid, but the resource is in a
|
|
912
|
+
// failed state), 404 for unknown node id.
|
|
913
|
+
// ?slowest=<N>: top N nodes by durationMs. Same shape as
|
|
914
|
+
// CLI v3.44 — pure state-file analysis, no deps needed.
|
|
915
|
+
const qSlowest = url.searchParams.get('slowest');
|
|
916
|
+
if (qSlowest) {
|
|
917
|
+
const n = parseInt(qSlowest, 10);
|
|
918
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
919
|
+
return writeJson(res, 400, {
|
|
920
|
+
error: `slowest must be a positive integer (got ${JSON.stringify(qSlowest)})`,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
const entries = Object.entries(state.nodes || {}).map(([id, ns]) => ({
|
|
924
|
+
id,
|
|
925
|
+
status: ns?.status || 'pending',
|
|
926
|
+
durationMs: Number.isFinite(ns?.durationMs) ? ns.durationMs : 0,
|
|
927
|
+
attempts: ns?.attempts ?? 0,
|
|
928
|
+
}));
|
|
929
|
+
entries.sort((a, b) => (b.durationMs - a.durationMs) || a.id.localeCompare(b.id));
|
|
930
|
+
return writeJson(res, 200, {
|
|
931
|
+
sessionId: state.sessionId,
|
|
932
|
+
top: entries.slice(0, n),
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
const qNode = url.searchParams.get('node');
|
|
936
|
+
if (qNode) {
|
|
937
|
+
const ns = state.nodes?.[qNode];
|
|
938
|
+
if (!ns) {
|
|
939
|
+
return writeJson(res, 404, {
|
|
940
|
+
error: 'node not found',
|
|
941
|
+
sessionId: sid,
|
|
942
|
+
nodeId: qNode,
|
|
943
|
+
knownNodes: Object.keys(state.nodes || {}),
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
return writeJson(res, ns.status === 'failed' ? 410 : 200, {
|
|
947
|
+
sessionId: state.sessionId,
|
|
948
|
+
nodeId: qNode,
|
|
949
|
+
...ns,
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
const { summary, failedNodes } = summarizeState(state);
|
|
953
|
+
// ?summary=true trims the per-node `nodes` map and `order`
|
|
954
|
+
// array, matching v3.17's CLI `inspect --summary` shape and
|
|
955
|
+
// the per-session shape that list-mode produces. A UI fetching
|
|
956
|
+
// this endpoint to render a status badge doesn't want the
|
|
957
|
+
// full per-node payload — `?summary=true` keeps the wire
|
|
958
|
+
// small for high-frequency polls.
|
|
959
|
+
const compact = url.searchParams.get('summary') === 'true';
|
|
960
|
+
const body = compact
|
|
961
|
+
? {
|
|
962
|
+
sessionId: state.sessionId,
|
|
963
|
+
dir: stateDir,
|
|
964
|
+
summary,
|
|
965
|
+
failedNodes,
|
|
966
|
+
startedAt: state.startedAt,
|
|
967
|
+
updatedAt: state.updatedAt,
|
|
968
|
+
}
|
|
969
|
+
: {
|
|
970
|
+
sessionId: state.sessionId,
|
|
971
|
+
dir: stateDir,
|
|
972
|
+
summary,
|
|
973
|
+
failedNodes,
|
|
974
|
+
order: state.order,
|
|
975
|
+
nodes: state.nodes,
|
|
976
|
+
startedAt: state.startedAt,
|
|
977
|
+
updatedAt: state.updatedAt,
|
|
978
|
+
};
|
|
979
|
+
return writeJson(res, 200, body);
|
|
980
|
+
}
|
|
981
|
+
case route === 'GET /skills': {
|
|
982
|
+
// List installed skills with their first-line summary so a UI
|
|
983
|
+
// can render them without a follow-up read for each one.
|
|
984
|
+
// ?filter=<substr>&limit=<N> mirror the v3.33 CLI flags.
|
|
985
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
986
|
+
let items = listSkills(cfgDir);
|
|
987
|
+
const filter = url.searchParams.get('filter');
|
|
988
|
+
if (filter) {
|
|
989
|
+
const f = filter.toLowerCase();
|
|
990
|
+
items = items.filter(s => s.name.toLowerCase().includes(f));
|
|
991
|
+
}
|
|
992
|
+
const limitStr = url.searchParams.get('limit');
|
|
993
|
+
if (limitStr) {
|
|
994
|
+
const n = parseInt(limitStr, 10);
|
|
995
|
+
if (Number.isFinite(n) && n > 0) items = items.slice(0, n);
|
|
996
|
+
}
|
|
997
|
+
return writeJson(res, 200, items.map(s => ({
|
|
998
|
+
name: s.name, bytes: s.bytes, summary: s.summary,
|
|
999
|
+
})));
|
|
1000
|
+
}
|
|
1001
|
+
case route === 'GET /skills/search': {
|
|
1002
|
+
// Mirror of `lazyclaw skills search`. ?q=<query> required;
|
|
1003
|
+
// ?regex=true switches to regex mode. Returns
|
|
1004
|
+
// { query, regex, matches: [{ name, bytes, matchCount, excerpt }] }
|
|
1005
|
+
// — same shape the CLI prints. A dashboard skill picker can
|
|
1006
|
+
// hit this endpoint instead of pulling every skill body and
|
|
1007
|
+
// searching client-side.
|
|
1008
|
+
const q = url.searchParams.get('q');
|
|
1009
|
+
if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
|
|
1010
|
+
const useRegex = url.searchParams.get('regex') === 'true';
|
|
1011
|
+
let matcher;
|
|
1012
|
+
if (useRegex) {
|
|
1013
|
+
try { matcher = new RegExp(q, 'gi'); }
|
|
1014
|
+
catch (e) { return writeJson(res, 400, { error: `invalid regex: ${e.message}` }); }
|
|
1015
|
+
}
|
|
1016
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
1017
|
+
const items = listSkills(cfgDir);
|
|
1018
|
+
const matches = [];
|
|
1019
|
+
for (const s of items) {
|
|
1020
|
+
let body;
|
|
1021
|
+
try { body = loadSkill(s.name, cfgDir); } catch { continue; }
|
|
1022
|
+
let matchCount = 0;
|
|
1023
|
+
let firstExcerpt = null;
|
|
1024
|
+
if (useRegex) {
|
|
1025
|
+
for (const m of body.matchAll(matcher)) {
|
|
1026
|
+
matchCount++;
|
|
1027
|
+
if (firstExcerpt === null) {
|
|
1028
|
+
const pos = m.index ?? 0;
|
|
1029
|
+
const start = Math.max(0, pos - 40);
|
|
1030
|
+
const end = Math.min(body.length, pos + m[0].length + 40);
|
|
1031
|
+
firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
} else {
|
|
1035
|
+
const lower = body.toLowerCase();
|
|
1036
|
+
const ql = q.toLowerCase();
|
|
1037
|
+
let pos = 0;
|
|
1038
|
+
while (true) {
|
|
1039
|
+
const i = lower.indexOf(ql, pos);
|
|
1040
|
+
if (i < 0) break;
|
|
1041
|
+
matchCount++;
|
|
1042
|
+
if (firstExcerpt === null) {
|
|
1043
|
+
const start = Math.max(0, i - 40);
|
|
1044
|
+
const end = Math.min(body.length, i + ql.length + 40);
|
|
1045
|
+
firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
|
|
1046
|
+
}
|
|
1047
|
+
pos = i + ql.length;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
if (matchCount > 0) {
|
|
1051
|
+
matches.push({ name: s.name, bytes: s.bytes, matchCount, excerpt: firstExcerpt });
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return writeJson(res, 200, { query: q, regex: useRegex, matches });
|
|
1055
|
+
}
|
|
1056
|
+
case req.method === 'GET' && !!skillMatch: {
|
|
1057
|
+
// GET /skills/<name> — full markdown body as text/markdown.
|
|
1058
|
+
// 404 when the file is missing so the caller can branch.
|
|
1059
|
+
// 400 when the name fails skillPath validation (path traversal,
|
|
1060
|
+
// dotfile, etc.) — same protections as the CLI.
|
|
1061
|
+
const name = skillMatch[1];
|
|
1062
|
+
try {
|
|
1063
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
1064
|
+
const file = skillPath(name, cfgDir);
|
|
1065
|
+
if (!(await fileExists(file))) return writeJson(res, 404, { error: 'skill not found', name });
|
|
1066
|
+
const body = loadSkill(name, cfgDir);
|
|
1067
|
+
res.writeHead(200, {
|
|
1068
|
+
'content-type': 'text/markdown; charset=utf-8',
|
|
1069
|
+
'content-length': Buffer.byteLength(body),
|
|
1070
|
+
});
|
|
1071
|
+
return res.end(body);
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
case req.method === 'PUT' && !!skillMatch: {
|
|
1077
|
+
// PUT /skills/<name> body = markdown text
|
|
1078
|
+
// 201 on first write, 200 on overwrite (caller can branch on
|
|
1079
|
+
// the status if they care about idempotency vs newness).
|
|
1080
|
+
// 400 on invalid name (skillPath validation) or oversize body.
|
|
1081
|
+
const name = skillMatch[1];
|
|
1082
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
1083
|
+
let priorExists = false;
|
|
1084
|
+
try {
|
|
1085
|
+
// Validate name before reading the body so a bogus name fails
|
|
1086
|
+
// fast and we don't waste bandwidth.
|
|
1087
|
+
const file = skillPath(name, cfgDir);
|
|
1088
|
+
priorExists = await fileExists(file);
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
1091
|
+
}
|
|
1092
|
+
let body;
|
|
1093
|
+
try { body = await readTextBody(req); }
|
|
1094
|
+
catch (err) { return writeJson(res, 400, { error: err?.message || String(err) }); }
|
|
1095
|
+
try {
|
|
1096
|
+
const written = installSkill(name, body, cfgDir);
|
|
1097
|
+
return writeJson(res, priorExists ? 200 : 201, {
|
|
1098
|
+
ok: true, name, path: written, bytes: body.length, replaced: priorExists,
|
|
1099
|
+
});
|
|
1100
|
+
} catch (err) {
|
|
1101
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
case req.method === 'DELETE' && !!skillMatch: {
|
|
1105
|
+
// DELETE /skills/<name> idempotent: 200 whether the file
|
|
1106
|
+
// existed or not, mirroring DELETE /sessions/<id>. The body
|
|
1107
|
+
// reports `removed: true|false` so callers can branch when
|
|
1108
|
+
// they care.
|
|
1109
|
+
const name = skillMatch[1];
|
|
1110
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
1111
|
+
try {
|
|
1112
|
+
const file = skillPath(name, cfgDir);
|
|
1113
|
+
const existed = await fileExists(file);
|
|
1114
|
+
removeSkill(name, cfgDir);
|
|
1115
|
+
return writeJson(res, 200, { ok: true, name, removed: existed });
|
|
1116
|
+
} catch (err) {
|
|
1117
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
case req.method === 'DELETE' && !!sessionMatch: {
|
|
1121
|
+
// DELETE /sessions/<id> — idempotent. 200 on both "deleted" and
|
|
1122
|
+
// "didn't exist" so callers can use it as a reset without checking
|
|
1123
|
+
// first.
|
|
1124
|
+
const id = sessionMatch[1];
|
|
1125
|
+
try {
|
|
1126
|
+
ctx.sessionsMod.clearSession(id, ctx.sessionsDirGetter());
|
|
1127
|
+
return writeJson(res, 200, { ok: true, id });
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
case req.method === 'DELETE' && !!workflowMatch: {
|
|
1133
|
+
// DELETE /workflows/<sessionId> — idempotent: 200 with
|
|
1134
|
+
// `removed: true|false`. Same protection as the rest of the
|
|
1135
|
+
// delete routes — only files inside the configured state dir
|
|
1136
|
+
// are touched. The path matcher already rejects `..` and `/`,
|
|
1137
|
+
// and we re-resolve via path.join so a sessionId that resolves
|
|
1138
|
+
// outside the dir is rejected with 400.
|
|
1139
|
+
const sid = workflowMatch[1];
|
|
1140
|
+
const stateDir = ctx.workflowStateDir();
|
|
1141
|
+
// Note: `path` is shadowed inside this handler by the URL path
|
|
1142
|
+
// variable above — use `nodePath` (aliased import) for fs ops.
|
|
1143
|
+
const file = nodePath.join(stateDir, `${sid}.json`);
|
|
1144
|
+
// Confined-path check: file must resolve under stateDir. fs.realpathSync
|
|
1145
|
+
// would resolve symlinks too, but the dir may not exist yet — use
|
|
1146
|
+
// the resolved string-prefix check, which is enough since stateDir
|
|
1147
|
+
// is operator-controlled.
|
|
1148
|
+
const resolvedDir = nodePath.resolve(stateDir);
|
|
1149
|
+
const resolvedFile = nodePath.resolve(file);
|
|
1150
|
+
if (!resolvedFile.startsWith(resolvedDir + nodePath.sep) && resolvedFile !== resolvedDir) {
|
|
1151
|
+
return writeJson(res, 400, { error: 'invalid sessionId' });
|
|
1152
|
+
}
|
|
1153
|
+
try {
|
|
1154
|
+
const existed = fs.existsSync(resolvedFile);
|
|
1155
|
+
if (existed) fs.unlinkSync(resolvedFile);
|
|
1156
|
+
return writeJson(res, 200, { ok: true, sessionId: sid, removed: existed });
|
|
1157
|
+
} catch (err) {
|
|
1158
|
+
return writeJson(res, 500, { error: err?.message || String(err) });
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
case route === 'POST /chat': {
|
|
1162
|
+
// Cost-cap gate: short-circuit before parsing the body so the
|
|
1163
|
+
// 402 fires fast and we don't pay for body buffering on a
|
|
1164
|
+
// request we're refusing.
|
|
1165
|
+
const breach = checkCostCap(metrics, costCap);
|
|
1166
|
+
if (breach) {
|
|
1167
|
+
return writeJson(res, 402, {
|
|
1168
|
+
error: 'cost cap exceeded',
|
|
1169
|
+
currency: breach.currency,
|
|
1170
|
+
spent: breach.spent,
|
|
1171
|
+
cap: breach.cap,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
// Full message-array input, single response (or stream). Useful when
|
|
1175
|
+
// the caller already has a message history and doesn't want to use
|
|
1176
|
+
// the disk-persisted session model.
|
|
1177
|
+
const body = await readJson(req);
|
|
1178
|
+
const cfg = ctx.readConfig();
|
|
1179
|
+
const provName = body.provider || cfg.provider || 'mock';
|
|
1180
|
+
const resolved = resolveProvider(body, provName, cachedByName, logger);
|
|
1181
|
+
if (resolved.error) return writeJson(res, 400, { error: resolved.error });
|
|
1182
|
+
const prov = resolved.provider;
|
|
1183
|
+
const messages = Array.isArray(body.messages) ? body.messages.filter(m => m && typeof m.role === 'string' && typeof m.content === 'string') : null;
|
|
1184
|
+
if (!messages || messages.length === 0) return writeJson(res, 400, { error: 'messages array required' });
|
|
1185
|
+
const thinkingBudget = Number(body.thinkingBudget) || 0;
|
|
1186
|
+
// Usage capture: opt-in via body.usage. The provider only does
|
|
1187
|
+
// the extra work (and pays the wire cost on OpenAI) when the
|
|
1188
|
+
// caller asks for it.
|
|
1189
|
+
let captured = null;
|
|
1190
|
+
const sendOpts = {
|
|
1191
|
+
apiKey: cfg['api-key'],
|
|
1192
|
+
model: body.model || cfg.model,
|
|
1193
|
+
thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
|
|
1194
|
+
onUsage: body.usage ? (u) => { captured = u; } : undefined,
|
|
1195
|
+
};
|
|
1196
|
+
// Cost lookup: body.cost:true asks the daemon to attach a cost
|
|
1197
|
+
// block when usage was captured AND cfg.rates has a card for
|
|
1198
|
+
// the active provider/model. Pure arithmetic — no extra wire
|
|
1199
|
+
// calls. Inline rather than helper-extract because the two
|
|
1200
|
+
// response paths (stream / non-stream) need to bind it
|
|
1201
|
+
// differently (SSE event vs JSON field).
|
|
1202
|
+
const computeCost = () => {
|
|
1203
|
+
if (!body.cost || !captured || !cfg.rates) return null;
|
|
1204
|
+
try {
|
|
1205
|
+
const c = costFromUsage(
|
|
1206
|
+
{ provider: provName, model: body.model || cfg.model, usage: captured },
|
|
1207
|
+
cfg.rates,
|
|
1208
|
+
);
|
|
1209
|
+
if (c) accumulateMetricsFromCost(metrics, captured, c);
|
|
1210
|
+
return c;
|
|
1211
|
+
} catch { return null; }
|
|
1212
|
+
};
|
|
1213
|
+
if (body.stream === true) {
|
|
1214
|
+
writeSseHead(res);
|
|
1215
|
+
try {
|
|
1216
|
+
for await (const chunk of prov.sendMessage(messages, sendOpts)) {
|
|
1217
|
+
writeSse(res, 'token', { text: chunk });
|
|
1218
|
+
await new Promise(r => setImmediate(r));
|
|
1219
|
+
}
|
|
1220
|
+
if (captured) writeSse(res, 'usage', captured);
|
|
1221
|
+
const cost = computeCost();
|
|
1222
|
+
if (cost) writeSse(res, 'cost', cost);
|
|
1223
|
+
writeSse(res, 'done', { ok: true });
|
|
1224
|
+
return res.end();
|
|
1225
|
+
} catch (err) {
|
|
1226
|
+
writeSse(res, 'error', { message: err?.message || String(err) });
|
|
1227
|
+
return res.end();
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
let acc = '';
|
|
1231
|
+
try {
|
|
1232
|
+
for await (const chunk of prov.sendMessage(messages, sendOpts)) acc += chunk;
|
|
1233
|
+
const cost = computeCost();
|
|
1234
|
+
const out = { reply: acc };
|
|
1235
|
+
if (captured) out.usage = captured;
|
|
1236
|
+
if (cost) out.cost = cost;
|
|
1237
|
+
return writeJson(res, 200, out);
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
const m = statusForProviderError(err);
|
|
1240
|
+
return writeJson(res, m.status, {
|
|
1241
|
+
error: err?.message || String(err),
|
|
1242
|
+
code: err?.code || null,
|
|
1243
|
+
...(err?.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}),
|
|
1244
|
+
}, m.headers || {});
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
case route === 'POST /agent': {
|
|
1248
|
+
const breach = checkCostCap(metrics, costCap);
|
|
1249
|
+
if (breach) {
|
|
1250
|
+
return writeJson(res, 402, {
|
|
1251
|
+
error: 'cost cap exceeded',
|
|
1252
|
+
currency: breach.currency,
|
|
1253
|
+
spent: breach.spent,
|
|
1254
|
+
cap: breach.cap,
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
const body = await readJson(req);
|
|
1258
|
+
const cfg = ctx.readConfig();
|
|
1259
|
+
const provName = body.provider || cfg.provider || 'mock';
|
|
1260
|
+
const resolved = resolveProvider(body, provName, cachedByName, logger);
|
|
1261
|
+
if (resolved.error) return writeJson(res, 400, { error: resolved.error });
|
|
1262
|
+
const prov = resolved.provider;
|
|
1263
|
+
const prompt = String(body.prompt ?? '').trim();
|
|
1264
|
+
if (!prompt) return writeJson(res, 400, { error: 'prompt required' });
|
|
1265
|
+
const model = body.model || cfg.model;
|
|
1266
|
+
const thinkingBudget = Number(body.thinkingBudget) || 0;
|
|
1267
|
+
|
|
1268
|
+
// Session hydration if sessionId provided.
|
|
1269
|
+
const sid = body.sessionId || null;
|
|
1270
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
1271
|
+
let messages = sid ? ctx.sessionsMod.loadTurns(sid, cfgDir).map(t => ({ role: t.role, content: t.content })) : [];
|
|
1272
|
+
// Skill composition: body.skills can be a comma-separated string
|
|
1273
|
+
// ("a,b") or an array (["a","b"]). Compose only when no system
|
|
1274
|
+
// message already exists in the message array (so re-runs of
|
|
1275
|
+
// the same session don't double-prepend).
|
|
1276
|
+
const skillNames = Array.isArray(body.skills)
|
|
1277
|
+
? body.skills
|
|
1278
|
+
: (typeof body.skills === 'string' ? body.skills.split(',').map(s => s.trim()).filter(Boolean) : []);
|
|
1279
|
+
if (skillNames.length > 0 && !messages.some(m => m.role === 'system')) {
|
|
1280
|
+
try {
|
|
1281
|
+
const sys = composeSystemPrompt(skillNames, cfgDir);
|
|
1282
|
+
if (sys) messages.unshift({ role: 'system', content: sys });
|
|
1283
|
+
} catch (err) {
|
|
1284
|
+
return writeJson(res, 400, { error: `skill error: ${err?.message || String(err)}` });
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
messages.push({ role: 'user', content: prompt });
|
|
1288
|
+
if (sid) ctx.sessionsMod.appendTurn(sid, 'user', prompt, cfgDir);
|
|
1289
|
+
|
|
1290
|
+
// body.usage opt-in mirrors POST /chat — provider only does the
|
|
1291
|
+
// extra work when the caller asks for it.
|
|
1292
|
+
let agentCaptured = null;
|
|
1293
|
+
const agentSendOpts = {
|
|
1294
|
+
apiKey: cfg['api-key'],
|
|
1295
|
+
model,
|
|
1296
|
+
thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
|
|
1297
|
+
onUsage: body.usage ? (u) => { agentCaptured = u; } : undefined,
|
|
1298
|
+
};
|
|
1299
|
+
const computeAgentCost = () => {
|
|
1300
|
+
if (!body.cost || !agentCaptured || !cfg.rates) return null;
|
|
1301
|
+
try {
|
|
1302
|
+
const c = costFromUsage(
|
|
1303
|
+
{ provider: provName, model, usage: agentCaptured },
|
|
1304
|
+
cfg.rates,
|
|
1305
|
+
);
|
|
1306
|
+
if (c) accumulateMetricsFromCost(metrics, agentCaptured, c);
|
|
1307
|
+
return c;
|
|
1308
|
+
} catch { return null; }
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
if (body.stream === true) {
|
|
1312
|
+
writeSseHead(res);
|
|
1313
|
+
// Forward client disconnect to the provider so we don't keep
|
|
1314
|
+
// burning tokens after the consumer has gone away.
|
|
1315
|
+
const ac = new AbortController();
|
|
1316
|
+
req.on('aborted', () => ac.abort());
|
|
1317
|
+
res.on('close', () => { if (!res.writableEnded) ac.abort(); });
|
|
1318
|
+
let acc = '';
|
|
1319
|
+
try {
|
|
1320
|
+
for await (const chunk of prov.sendMessage(messages, { ...agentSendOpts, signal: ac.signal })) {
|
|
1321
|
+
if (ac.signal.aborted) break;
|
|
1322
|
+
acc += chunk;
|
|
1323
|
+
writeSse(res, 'token', { text: chunk });
|
|
1324
|
+
// Backpressure: yield so the caller can read each frame.
|
|
1325
|
+
await new Promise(r => setImmediate(r));
|
|
1326
|
+
}
|
|
1327
|
+
if (sid && !ac.signal.aborted) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
|
|
1328
|
+
if (!ac.signal.aborted) {
|
|
1329
|
+
if (agentCaptured) writeSse(res, 'usage', agentCaptured);
|
|
1330
|
+
const cost = computeAgentCost();
|
|
1331
|
+
if (cost) writeSse(res, 'cost', cost);
|
|
1332
|
+
writeSse(res, 'done', { ok: true });
|
|
1333
|
+
}
|
|
1334
|
+
return res.end();
|
|
1335
|
+
} catch (err) {
|
|
1336
|
+
if (err?.code === 'ABORT' || ac.signal.aborted) {
|
|
1337
|
+
// Client gave up — partial assistant turn is discarded.
|
|
1338
|
+
return res.end();
|
|
1339
|
+
}
|
|
1340
|
+
writeSse(res, 'error', { message: err?.message || String(err) });
|
|
1341
|
+
return res.end();
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// Non-streaming: collect then return once. Reuse agentSendOpts
|
|
1346
|
+
// (carrying the optional onUsage capture) so usage lands in the
|
|
1347
|
+
// response when body.usage was set.
|
|
1348
|
+
let acc = '';
|
|
1349
|
+
try {
|
|
1350
|
+
for await (const chunk of prov.sendMessage(messages, agentSendOpts)) acc += chunk;
|
|
1351
|
+
if (sid) ctx.sessionsMod.appendTurn(sid, 'assistant', acc, cfgDir);
|
|
1352
|
+
const cost = computeAgentCost();
|
|
1353
|
+
const out = { reply: acc };
|
|
1354
|
+
if (agentCaptured) out.usage = agentCaptured;
|
|
1355
|
+
if (cost) out.cost = cost;
|
|
1356
|
+
return writeJson(res, 200, out);
|
|
1357
|
+
} catch (err) {
|
|
1358
|
+
const m = statusForProviderError(err);
|
|
1359
|
+
return writeJson(res, m.status, {
|
|
1360
|
+
error: err?.message || String(err),
|
|
1361
|
+
code: err?.code || null,
|
|
1362
|
+
...(err?.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}),
|
|
1363
|
+
}, m.headers || {});
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
default:
|
|
1367
|
+
return writeJson(res, 404, { error: 'not found', route });
|
|
1368
|
+
} /* eslint-disable-line no-fallthrough */
|
|
1369
|
+
} catch (err) {
|
|
1370
|
+
return writeJson(res, 500, { error: err?.message || String(err) });
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Graceful shutdown with a hard timeout. Calls `server.close()` so the
|
|
1377
|
+
* server stops accepting new connections and waits for in-flight to
|
|
1378
|
+
* drain — but races against `timeoutMs` so a hung stream can't keep
|
|
1379
|
+
* the process alive forever. After timeout we force-close every open
|
|
1380
|
+
* connection (Node ≥18.2) and resolve.
|
|
1381
|
+
*
|
|
1382
|
+
* Returns `{ forced: boolean }`:
|
|
1383
|
+
* forced=false → graceful drain completed in time
|
|
1384
|
+
* forced=true → timeout fired; connections were force-closed
|
|
1385
|
+
*
|
|
1386
|
+
* Exported for unit testing without spawning a real daemon.
|
|
1387
|
+
*
|
|
1388
|
+
* @param {{ close: (cb: (err?: Error) => void) => void, closeAllConnections?: () => void }} server
|
|
1389
|
+
* @param {number} timeoutMs
|
|
1390
|
+
*/
|
|
1391
|
+
export function gracefulShutdown(server, timeoutMs) {
|
|
1392
|
+
return new Promise((resolve) => {
|
|
1393
|
+
let resolved = false;
|
|
1394
|
+
const finish = (forced) => {
|
|
1395
|
+
if (resolved) return;
|
|
1396
|
+
resolved = true;
|
|
1397
|
+
resolve({ forced });
|
|
1398
|
+
};
|
|
1399
|
+
const timer = setTimeout(() => {
|
|
1400
|
+
if (typeof server.closeAllConnections === 'function') {
|
|
1401
|
+
try { server.closeAllConnections(); } catch { /* swallow */ }
|
|
1402
|
+
}
|
|
1403
|
+
finish(true);
|
|
1404
|
+
}, timeoutMs);
|
|
1405
|
+
timer.unref?.();
|
|
1406
|
+
server.close((err) => {
|
|
1407
|
+
clearTimeout(timer);
|
|
1408
|
+
finish(false);
|
|
1409
|
+
});
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* Start the daemon. Always binds 127.0.0.1.
|
|
1415
|
+
* @param {{
|
|
1416
|
+
* port?: number,
|
|
1417
|
+
* once?: boolean,
|
|
1418
|
+
* readConfig: () => Record<string, unknown>,
|
|
1419
|
+
* sessionsDirGetter: () => string,
|
|
1420
|
+
* sessionsMod: typeof import('./sessions.mjs'),
|
|
1421
|
+
* version: () => string,
|
|
1422
|
+
* authToken?: string,
|
|
1423
|
+
* allowedOrigins?: string[],
|
|
1424
|
+
* rateLimit?: { capacity?: number, refillPerSec?: number } | null,
|
|
1425
|
+
* responseCache?: { maxEntries?: number, ttlMs?: number } | true | null,
|
|
1426
|
+
* logger?: ReturnType<typeof createLogger> | null,
|
|
1427
|
+
* costCap?: Record<string, number> | null,
|
|
1428
|
+
* }} opts
|
|
1429
|
+
* @returns {Promise<{ port: number, server: http.Server, close: () => Promise<void> }>}
|
|
1430
|
+
*/
|
|
1431
|
+
export async function startDaemon(opts) {
|
|
1432
|
+
const handler = makeHandler(opts);
|
|
1433
|
+
const server = http.createServer(async (req, res) => {
|
|
1434
|
+
await handler(req, res);
|
|
1435
|
+
if (opts.once) {
|
|
1436
|
+
// Allow the response to flush before closing.
|
|
1437
|
+
setImmediate(() => server.close());
|
|
1438
|
+
}
|
|
1439
|
+
});
|
|
1440
|
+
return new Promise((resolve) => {
|
|
1441
|
+
server.listen(opts.port ?? 0, '127.0.0.1', () => {
|
|
1442
|
+
const addr = server.address();
|
|
1443
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
1444
|
+
resolve({
|
|
1445
|
+
port,
|
|
1446
|
+
server,
|
|
1447
|
+
close: () => new Promise(r => server.close(() => r())),
|
|
1448
|
+
});
|
|
1449
|
+
});
|
|
1450
|
+
});
|
|
1451
|
+
}
|