@rulvar/cli 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/LICENSE +202 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +17 -0
- package/dist/dist-4eKFSpyW.js +474 -0
- package/dist/dist-DtuSmcav.js +95385 -0
- package/dist/index.d.ts +252 -0
- package/dist/index.js +760 -0
- package/dist/io-DUfUn6KG.js +702 -0
- package/dist/src-DVFj9U50.js +1174 -0
- package/package.json +63 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import { a as resumeCommand, c as driveRun, d as renderEventLine, f as DEFAULT_STORE_DIR, g as looksLikeFile, h as loadWorkflowModule, i as inspectCommand, l as reportOutcome, m as loadCliConfig, n as HELP, o as runCommand, p as assembleEngine, r as runCli, s as runsLsCommand, t as processIo, u as attachProgress } from "./io-DUfUn6KG.js";
|
|
2
|
+
import { ConfigError, InvalidResolutionError, JournalCompatibilityError, LeaseHeldError, Replayer, RulvarError, buildDeriverRegistry, costReportFromJournal, maskSecrets, normalizeEntry, scanJournalCompatibility, validateSchemaSpec } from "@rulvar/core";
|
|
3
|
+
//#region src/server.ts
|
|
4
|
+
/**
|
|
5
|
+
* createServer (M8-T01): the HTTP shell over the public engine API
|
|
6
|
+
* (docs/02, section 8.2; FR-702). Canonical signature
|
|
7
|
+
* `createServer({ engine, workflows })` returning
|
|
8
|
+
* `{ fetch(req: Request): Promise<Response> }`; the journal store comes
|
|
9
|
+
* from the engine (Engine.stores, docs/06 10.2, M8 entry amendment).
|
|
10
|
+
*
|
|
11
|
+
* Routes:
|
|
12
|
+
* POST /runs start a run of a registered workflow
|
|
13
|
+
* GET /runs/:id run status and outcome
|
|
14
|
+
* GET /runs/:id/events SSE event stream (Last-Event-ID resume)
|
|
15
|
+
* POST /runs/:id/external/:key resolve an awaitExternal suspension
|
|
16
|
+
* GET /runs/:id/cost CostReport
|
|
17
|
+
*
|
|
18
|
+
* Authentication is explicitly out of scope: the server is host-embedded
|
|
19
|
+
* and auth belongs to host middleware (docs/14, OQ-16). SSE reconnection
|
|
20
|
+
* maps Last-Event-ID to the event seq (the per-run telemetry counter,
|
|
21
|
+
* docs/09, section 1.1); replay is at-least-once by design, matching the
|
|
22
|
+
* journal-backed re-emission contract (docs/09, section 1.5: consumers
|
|
23
|
+
* deduplicate on `replayed`).
|
|
24
|
+
*
|
|
25
|
+
* The server is a single-process shell: it tracks the runs it started
|
|
26
|
+
* (or resumed) in memory and serves everything else from the engine's
|
|
27
|
+
* stores. A resolution posted for a run that is NOT live in this process
|
|
28
|
+
* is the documented offline append (docs/03, section 8: load, compute
|
|
29
|
+
* next seq, append, under a lease where the store is leasable); such a
|
|
30
|
+
* run resumes on a queue worker (createWorker, M8-T02), not here,
|
|
31
|
+
* because original run arguments are not journaled in v1 (docs/14,
|
|
32
|
+
* OQ-21).
|
|
33
|
+
*/
|
|
34
|
+
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
|
35
|
+
const wallClock = Date.now.bind(globalThis);
|
|
36
|
+
function json(status, body) {
|
|
37
|
+
return new Response(JSON.stringify(body), {
|
|
38
|
+
status,
|
|
39
|
+
headers: JSON_HEADERS
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function errorStatus(error) {
|
|
43
|
+
switch (error.code) {
|
|
44
|
+
case "config":
|
|
45
|
+
case "invalid_resolution":
|
|
46
|
+
case "non_serializable_value": return 400;
|
|
47
|
+
case "lease_held":
|
|
48
|
+
case "journal_compat": return 409;
|
|
49
|
+
default: return 500;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function errorResponse(thrown) {
|
|
53
|
+
if (thrown instanceof RulvarError) return json(errorStatus(thrown), { error: thrown.toWire() });
|
|
54
|
+
return json(500, { error: {
|
|
55
|
+
code: "error",
|
|
56
|
+
message: thrown instanceof Error ? thrown.message : String(thrown),
|
|
57
|
+
retryable: false
|
|
58
|
+
} });
|
|
59
|
+
}
|
|
60
|
+
function sseFrame(event) {
|
|
61
|
+
return `id: ${event.seq}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
62
|
+
}
|
|
63
|
+
function isLeasable(store) {
|
|
64
|
+
const candidate = store;
|
|
65
|
+
return typeof candidate.acquire === "function" && typeof candidate.renew === "function" && typeof candidate.release === "function";
|
|
66
|
+
}
|
|
67
|
+
/** The approval-suspension resolution key (docs/08, section 3.6). */
|
|
68
|
+
const APPROVAL_KEY_PREFIX = "approval:";
|
|
69
|
+
function suspensionKeyOf(entry) {
|
|
70
|
+
if (entry.status !== "suspended") return;
|
|
71
|
+
if (entry.kind === "external") {
|
|
72
|
+
const key = entry.value?.key;
|
|
73
|
+
return typeof key === "string" ? key : void 0;
|
|
74
|
+
}
|
|
75
|
+
if (entry.kind === "approval") return `${APPROVAL_KEY_PREFIX}${entry.seq}`;
|
|
76
|
+
}
|
|
77
|
+
function createServer(options) {
|
|
78
|
+
const { engine, workflows } = options;
|
|
79
|
+
const journal = engine.stores.journal;
|
|
80
|
+
const runs = /* @__PURE__ */ new Map();
|
|
81
|
+
/** Pumps one resume segment's events into the buffer and the feeds. */
|
|
82
|
+
function attach(run, handle) {
|
|
83
|
+
run.handle = handle;
|
|
84
|
+
run.outcome = void 0;
|
|
85
|
+
(async () => {
|
|
86
|
+
for await (const event of handle.events) {
|
|
87
|
+
run.buffer.push(event);
|
|
88
|
+
for (const feed of [...run.feeds]) feed(event);
|
|
89
|
+
}
|
|
90
|
+
})().catch(() => void 0);
|
|
91
|
+
handle.result.then((outcome) => {
|
|
92
|
+
run.outcome = outcome;
|
|
93
|
+
if (outcome.status !== "suspended") {
|
|
94
|
+
run.done = true;
|
|
95
|
+
for (const feed of [...run.feeds]) feed(null);
|
|
96
|
+
run.feeds.clear();
|
|
97
|
+
if (options.retention !== void 0) (async () => {
|
|
98
|
+
const meta = await metaOf(run.runId);
|
|
99
|
+
if (meta !== void 0 && options.retention?.(meta) === true) {
|
|
100
|
+
await engine.deleteRun(run.runId);
|
|
101
|
+
runs.delete(run.runId);
|
|
102
|
+
}
|
|
103
|
+
})().catch(() => void 0);
|
|
104
|
+
}
|
|
105
|
+
}).catch(() => void 0);
|
|
106
|
+
}
|
|
107
|
+
function track(runId, workflowName, args, handle) {
|
|
108
|
+
const run = {
|
|
109
|
+
runId,
|
|
110
|
+
workflowName,
|
|
111
|
+
args,
|
|
112
|
+
buffer: [],
|
|
113
|
+
feeds: /* @__PURE__ */ new Set(),
|
|
114
|
+
handle,
|
|
115
|
+
done: false,
|
|
116
|
+
queue: Promise.resolve()
|
|
117
|
+
};
|
|
118
|
+
runs.set(runId, run);
|
|
119
|
+
attach(run, handle);
|
|
120
|
+
return run;
|
|
121
|
+
}
|
|
122
|
+
async function metaOf(runId) {
|
|
123
|
+
return (await journal.listRuns()).find((meta) => meta.runId === runId);
|
|
124
|
+
}
|
|
125
|
+
async function startRun(req) {
|
|
126
|
+
let body;
|
|
127
|
+
try {
|
|
128
|
+
body = await req.json();
|
|
129
|
+
} catch {
|
|
130
|
+
return json(400, { error: {
|
|
131
|
+
code: "config",
|
|
132
|
+
message: "request body is not valid JSON"
|
|
133
|
+
} });
|
|
134
|
+
}
|
|
135
|
+
const name = body.workflow;
|
|
136
|
+
if (typeof name !== "string" || name.length === 0) return json(400, { error: {
|
|
137
|
+
code: "config",
|
|
138
|
+
message: "body requires { workflow: '<registered name>' }"
|
|
139
|
+
} });
|
|
140
|
+
const workflow = workflows[name];
|
|
141
|
+
if (workflow === void 0) return json(400, { error: {
|
|
142
|
+
code: "config",
|
|
143
|
+
message: `no workflow named '${name}' in the registry (docs/06, section 10.4)`
|
|
144
|
+
} });
|
|
145
|
+
const runOptions = {
|
|
146
|
+
...body.options?.runId === void 0 ? {} : { runId: body.options.runId },
|
|
147
|
+
...body.options?.budgetUsd === void 0 ? {} : { budgetUsd: body.options.budgetUsd },
|
|
148
|
+
...body.options?.name === void 0 ? {} : { name: body.options.name },
|
|
149
|
+
...body.options?.tags === void 0 ? {} : { tags: body.options.tags },
|
|
150
|
+
...body.options?.deadlineAt === void 0 ? {} : { deadlineAt: body.options.deadlineAt }
|
|
151
|
+
};
|
|
152
|
+
const handle = engine.run(workflow, body.args, runOptions);
|
|
153
|
+
track(handle.runId, name, body.args, handle);
|
|
154
|
+
return new Response(JSON.stringify({
|
|
155
|
+
runId: handle.runId,
|
|
156
|
+
status: "running",
|
|
157
|
+
workflow: name
|
|
158
|
+
}), {
|
|
159
|
+
status: 201,
|
|
160
|
+
headers: {
|
|
161
|
+
...JSON_HEADERS,
|
|
162
|
+
location: `/runs/${handle.runId}`
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
async function runStatus(runId) {
|
|
167
|
+
const run = runs.get(runId);
|
|
168
|
+
if (run !== void 0) {
|
|
169
|
+
const outcome = run.outcome;
|
|
170
|
+
if (outcome === void 0) return json(200, {
|
|
171
|
+
runId,
|
|
172
|
+
status: "running",
|
|
173
|
+
workflow: run.workflowName,
|
|
174
|
+
live: true
|
|
175
|
+
});
|
|
176
|
+
return json(200, {
|
|
177
|
+
runId,
|
|
178
|
+
status: outcome.status,
|
|
179
|
+
workflow: run.workflowName,
|
|
180
|
+
live: true,
|
|
181
|
+
...outcome.value === void 0 ? {} : { value: outcome.value },
|
|
182
|
+
...outcome.error === void 0 ? {} : { error: outcome.error },
|
|
183
|
+
pending: outcome.pending,
|
|
184
|
+
dropped: outcome.dropped.length,
|
|
185
|
+
usage: outcome.usage
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
const meta = await metaOf(runId);
|
|
189
|
+
if (meta === void 0) return json(404, { error: {
|
|
190
|
+
code: "config",
|
|
191
|
+
message: `run '${runId}' not found`
|
|
192
|
+
} });
|
|
193
|
+
return json(200, {
|
|
194
|
+
runId,
|
|
195
|
+
status: meta.status,
|
|
196
|
+
live: false,
|
|
197
|
+
...meta.workflowName === void 0 ? {} : { workflow: meta.workflowName },
|
|
198
|
+
...meta.name === void 0 ? {} : { name: meta.name },
|
|
199
|
+
...meta.tags === void 0 ? {} : { tags: meta.tags },
|
|
200
|
+
updatedAt: meta.updatedAt
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
async function runEvents(runId, req) {
|
|
204
|
+
const run = runs.get(runId);
|
|
205
|
+
if (run === void 0) {
|
|
206
|
+
if (await metaOf(runId) === void 0) return json(404, { error: {
|
|
207
|
+
code: "config",
|
|
208
|
+
message: `run '${runId}' not found`
|
|
209
|
+
} });
|
|
210
|
+
const empty = new ReadableStream({ start(controller) {
|
|
211
|
+
controller.enqueue(new TextEncoder().encode(": run is not live in this process\n\n"));
|
|
212
|
+
controller.close();
|
|
213
|
+
} });
|
|
214
|
+
return new Response(empty, {
|
|
215
|
+
status: 200,
|
|
216
|
+
headers: {
|
|
217
|
+
"content-type": "text/event-stream",
|
|
218
|
+
"cache-control": "no-cache"
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const lastEventId = req.headers.get("last-event-id");
|
|
223
|
+
const encoder = new TextEncoder();
|
|
224
|
+
let feed;
|
|
225
|
+
const stream = new ReadableStream({
|
|
226
|
+
start(controller) {
|
|
227
|
+
let startIndex = 0;
|
|
228
|
+
if (lastEventId !== null) {
|
|
229
|
+
const cursor = Number(lastEventId);
|
|
230
|
+
if (Number.isFinite(cursor)) {
|
|
231
|
+
for (let i = run.buffer.length - 1; i >= 0; i -= 1) if (run.buffer[i].seq === cursor) {
|
|
232
|
+
startIndex = i + 1;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const event of run.buffer.slice(startIndex)) controller.enqueue(encoder.encode(sseFrame(event)));
|
|
238
|
+
if (run.done) {
|
|
239
|
+
controller.close();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
feed = (event) => {
|
|
243
|
+
if (event === null) {
|
|
244
|
+
try {
|
|
245
|
+
controller.close();
|
|
246
|
+
} catch {}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
controller.enqueue(encoder.encode(sseFrame(event)));
|
|
251
|
+
} catch {}
|
|
252
|
+
};
|
|
253
|
+
run.feeds.add(feed);
|
|
254
|
+
},
|
|
255
|
+
cancel() {
|
|
256
|
+
if (feed !== void 0) run.feeds.delete(feed);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
return new Response(stream, {
|
|
260
|
+
status: 200,
|
|
261
|
+
headers: {
|
|
262
|
+
"content-type": "text/event-stream",
|
|
263
|
+
"cache-control": "no-cache"
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/** The tracked path: live (or settled-suspended) in this process. */
|
|
268
|
+
async function resolveTracked(run, key, value) {
|
|
269
|
+
const section = run.queue.then(async () => {
|
|
270
|
+
if (run.done) return json(409, { error: {
|
|
271
|
+
code: "config",
|
|
272
|
+
message: `run '${run.runId}' already settled '${run.outcome?.status ?? "unknown"}'`
|
|
273
|
+
} });
|
|
274
|
+
const settledSuspended = run.outcome?.status === "suspended";
|
|
275
|
+
if (settledSuspended) {
|
|
276
|
+
const pendingKeys = run.outcome.pending.map((item) => item.key);
|
|
277
|
+
if (!pendingKeys.includes(key)) return json(404, { error: {
|
|
278
|
+
code: "invalid_resolution",
|
|
279
|
+
message: `no open suspension '${key}' (pending: ${pendingKeys.join(", ") || "none"})`
|
|
280
|
+
} });
|
|
281
|
+
}
|
|
282
|
+
const outcome = await run.handle.resolveExternal(key, value);
|
|
283
|
+
let resumed = false;
|
|
284
|
+
if (outcome.applied && settledSuspended) {
|
|
285
|
+
const workflow = workflows[run.workflowName];
|
|
286
|
+
if (workflow === void 0) return json(409, { error: {
|
|
287
|
+
code: "config",
|
|
288
|
+
message: `resolution applied, but workflow '${run.workflowName}' is no longer registered; resume it from a worker or a process with the registration`
|
|
289
|
+
} });
|
|
290
|
+
attach(run, engine.resume(run.runId, workflow, { args: run.args }));
|
|
291
|
+
resumed = true;
|
|
292
|
+
}
|
|
293
|
+
return json(200, {
|
|
294
|
+
...outcome,
|
|
295
|
+
resumed
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
run.queue = section.catch(() => void 0);
|
|
299
|
+
return section;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The offline path (docs/03, section 8): the run is not live in this
|
|
303
|
+
* process; append the resolution under a lease where the store is
|
|
304
|
+
* leasable and leave the resume to a queue worker.
|
|
305
|
+
*/
|
|
306
|
+
async function resolveOffline(runId, key, value) {
|
|
307
|
+
if (await metaOf(runId) === void 0) return json(404, { error: {
|
|
308
|
+
code: "config",
|
|
309
|
+
message: `run '${runId}' not found`
|
|
310
|
+
} });
|
|
311
|
+
let lease;
|
|
312
|
+
if (isLeasable(journal)) lease = await journal.acquire(runId, `rulvar-server:${process.pid}`);
|
|
313
|
+
try {
|
|
314
|
+
const entries = (await journal.load(runId)).map((raw) => normalizeEntry(raw));
|
|
315
|
+
const replayer = new Replayer({
|
|
316
|
+
runId,
|
|
317
|
+
store: journal,
|
|
318
|
+
now: wallClock,
|
|
319
|
+
priorEntries: entries
|
|
320
|
+
});
|
|
321
|
+
const target = entries.find((entry) => suspensionKeyOf(entry) === key);
|
|
322
|
+
if (target === void 0) return json(404, { error: {
|
|
323
|
+
code: "invalid_resolution",
|
|
324
|
+
message: `no suspension with key '${key}'`
|
|
325
|
+
} });
|
|
326
|
+
const state = replayer.suspensionState(target.seq);
|
|
327
|
+
if (state.state !== "suspended") return json(200, {
|
|
328
|
+
applied: false,
|
|
329
|
+
seq: target.seq,
|
|
330
|
+
supersededBy: state.by,
|
|
331
|
+
reason: state.state === "resolved" ? "already_resolved" : "target_abandoned",
|
|
332
|
+
resumed: false
|
|
333
|
+
});
|
|
334
|
+
if (target.kind === "approval") {
|
|
335
|
+
const decision = value?.decision;
|
|
336
|
+
if (decision !== "allow" && decision !== "deny") throw new InvalidResolutionError(`approval '${key}' resolves with { decision: 'allow' | 'deny', reason? }`);
|
|
337
|
+
}
|
|
338
|
+
const pinnedSchema = target.value?.schema;
|
|
339
|
+
if (pinnedSchema !== void 0) {
|
|
340
|
+
const validation = await validateSchemaSpec(pinnedSchema, value);
|
|
341
|
+
if (!validation.valid) throw new InvalidResolutionError(`resolution for '${key}' does not validate against the pinned schema: ` + validation.issues.map((issue) => issue.message).join("; "));
|
|
342
|
+
}
|
|
343
|
+
return json(200, {
|
|
344
|
+
...await replayer.resolveSuspended(target.seq, {
|
|
345
|
+
by: "external",
|
|
346
|
+
value
|
|
347
|
+
}),
|
|
348
|
+
resumed: false
|
|
349
|
+
});
|
|
350
|
+
} finally {
|
|
351
|
+
if (lease !== void 0 && isLeasable(journal)) await journal.release(lease).catch(() => void 0);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function resolveRun(runId, key, req) {
|
|
355
|
+
let value;
|
|
356
|
+
try {
|
|
357
|
+
value = await req.json();
|
|
358
|
+
} catch {
|
|
359
|
+
return json(400, { error: {
|
|
360
|
+
code: "config",
|
|
361
|
+
message: "request body is not valid JSON"
|
|
362
|
+
} });
|
|
363
|
+
}
|
|
364
|
+
const run = runs.get(runId);
|
|
365
|
+
if (run !== void 0) return resolveTracked(run, key, value);
|
|
366
|
+
return resolveOffline(runId, key, value);
|
|
367
|
+
}
|
|
368
|
+
async function runCost(runId) {
|
|
369
|
+
const run = runs.get(runId);
|
|
370
|
+
if (run?.outcome !== void 0) return json(200, run.outcome.cost);
|
|
371
|
+
const meta = run === void 0 ? await metaOf(runId) : void 0;
|
|
372
|
+
if (run === void 0 && meta === void 0) return json(404, { error: {
|
|
373
|
+
code: "config",
|
|
374
|
+
message: `run '${runId}' not found`
|
|
375
|
+
} });
|
|
376
|
+
return json(200, costReportFromJournal((await journal.load(runId)).map((raw) => normalizeEntry(raw)), options.priceUsd ?? (() => void 0)));
|
|
377
|
+
}
|
|
378
|
+
async function route(req) {
|
|
379
|
+
const path = new URL(req.url).pathname;
|
|
380
|
+
if (path === "/runs") {
|
|
381
|
+
if (req.method !== "POST") return json(405, { error: {
|
|
382
|
+
code: "config",
|
|
383
|
+
message: "POST /runs"
|
|
384
|
+
} });
|
|
385
|
+
return startRun(req);
|
|
386
|
+
}
|
|
387
|
+
const events = /^\/runs\/([^/]+)\/events$/.exec(path);
|
|
388
|
+
if (events !== null) {
|
|
389
|
+
if (req.method !== "GET") return json(405, { error: {
|
|
390
|
+
code: "config",
|
|
391
|
+
message: "GET /runs/:id/events"
|
|
392
|
+
} });
|
|
393
|
+
return runEvents(decodeURIComponent(events[1]), req);
|
|
394
|
+
}
|
|
395
|
+
const external = /^\/runs\/([^/]+)\/external\/(.+)$/.exec(path);
|
|
396
|
+
if (external !== null) {
|
|
397
|
+
if (req.method !== "POST") return json(405, { error: {
|
|
398
|
+
code: "config",
|
|
399
|
+
message: "POST /runs/:id/external/:key"
|
|
400
|
+
} });
|
|
401
|
+
return resolveRun(decodeURIComponent(external[1]), decodeURIComponent(external[2]), req);
|
|
402
|
+
}
|
|
403
|
+
const cost = /^\/runs\/([^/]+)\/cost$/.exec(path);
|
|
404
|
+
if (cost !== null) {
|
|
405
|
+
if (req.method !== "GET") return json(405, { error: {
|
|
406
|
+
code: "config",
|
|
407
|
+
message: "GET /runs/:id/cost"
|
|
408
|
+
} });
|
|
409
|
+
return runCost(decodeURIComponent(cost[1]));
|
|
410
|
+
}
|
|
411
|
+
const status = /^\/runs\/([^/]+)$/.exec(path);
|
|
412
|
+
if (status !== null) {
|
|
413
|
+
if (req.method !== "GET") return json(405, { error: {
|
|
414
|
+
code: "config",
|
|
415
|
+
message: "GET /runs/:id"
|
|
416
|
+
} });
|
|
417
|
+
return runStatus(decodeURIComponent(status[1]));
|
|
418
|
+
}
|
|
419
|
+
return json(404, { error: {
|
|
420
|
+
code: "config",
|
|
421
|
+
message: `no route ${req.method} ${path}`
|
|
422
|
+
} });
|
|
423
|
+
}
|
|
424
|
+
return { fetch: async (req) => {
|
|
425
|
+
try {
|
|
426
|
+
return await route(req);
|
|
427
|
+
} catch (thrown) {
|
|
428
|
+
return errorResponse(thrown);
|
|
429
|
+
}
|
|
430
|
+
} };
|
|
431
|
+
}
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region src/worker.ts
|
|
434
|
+
/**
|
|
435
|
+
* createWorker (M8-T02): the queue shell over the public engine API
|
|
436
|
+
* (docs/02, section 8.3; FR-703). Canonical signature
|
|
437
|
+
* `createWorker(engine, { store: LeasableStore, concurrency? })`.
|
|
438
|
+
*
|
|
439
|
+
* The worker leases resumable ('running' meta: a crashed or currently
|
|
440
|
+
* owned run) and suspended runs via acquire/renew/release with the
|
|
441
|
+
* fencing epoch; acquire on a held lease rejects with LeaseHeldError and
|
|
442
|
+
* the worker simply moves on. Stateless workers call engine.resume,
|
|
443
|
+
* passing the lease via ResumeOptions.lease so EVERY engine append of
|
|
444
|
+
* the resumed run is fenced (docs/03, section 12.3, M8 entry amendment):
|
|
445
|
+
* lease theft is impossible because a stale writer's appends are
|
|
446
|
+
* rejected by the store and never become visible, whether or not the
|
|
447
|
+
* stale worker noticed it lost the lease.
|
|
448
|
+
*
|
|
449
|
+
* DEF-6 at acquire: the journal's hashVersion window is re-checked
|
|
450
|
+
* immediately after every acquire, strictly before any append; a
|
|
451
|
+
* JournalCompatibilityError releases the lease and poisons the run for
|
|
452
|
+
* this worker (an older library never writes into a newer journal).
|
|
453
|
+
*
|
|
454
|
+
* Queue semantics are honestly at-least-once with deduplication by the
|
|
455
|
+
* journal (docs/03, section 13.1): re-leasing a settled or unchanged
|
|
456
|
+
* run replays to the same outcome with zero live calls. Workflows
|
|
457
|
+
* resolve through the engine's defaults.workflows registry plus the
|
|
458
|
+
* persisted CompiledWorkflow sources, never through a worker parameter
|
|
459
|
+
* (docs/06, section 10.4); original in-process run arguments are not
|
|
460
|
+
* journaled in v1, so the host MAY re-supply them per run via `argsFor`
|
|
461
|
+
* (docs/14, OQ-21).
|
|
462
|
+
*
|
|
463
|
+
* Appendix A (committed at M8 entry): concurrency defaults to 1 (one
|
|
464
|
+
* leased run per worker process; hosts scale out by adding workers,
|
|
465
|
+
* which the fencing epoch makes safe by construction); the renew
|
|
466
|
+
* cadence is ttl/3 with the reference ttl of 60000 ms. There is no
|
|
467
|
+
* distributed cross-process rate limiter in v1 (EXC-14; docs/14,
|
|
468
|
+
* OQ-17): divide provider quota per worker or front an external
|
|
469
|
+
* gateway.
|
|
470
|
+
*/
|
|
471
|
+
/** Appendix A: the committed reference lease ttl (docs/06). */
|
|
472
|
+
const DEFAULT_WORKER_TTL_MS = 6e4;
|
|
473
|
+
const CANDIDATE_STATUSES = /* @__PURE__ */ new Set(["running", "suspended"]);
|
|
474
|
+
let workerOrdinal = 0;
|
|
475
|
+
function workerIdentity() {
|
|
476
|
+
workerOrdinal += 1;
|
|
477
|
+
return `rulvar-worker:${process.pid}:${workerOrdinal}`;
|
|
478
|
+
}
|
|
479
|
+
function createWorker(engine, options) {
|
|
480
|
+
const store = options.store;
|
|
481
|
+
if (!(typeof store.acquire === "function" && typeof store.renew === "function" && typeof store.release === "function")) throw new ConfigError("createWorker requires a LeasableStore (acquire/renew/release with fencing epochs); the supplied store has no lease capability (docs/03, section 12.3). Use @rulvar/store-sqlite or another conformant LeasableStore.");
|
|
482
|
+
if (engine.stores.journal !== store) throw new ConfigError("createWorker must lease the SAME journal store the engine writes (engine.stores.journal); leasing a different store would fence nothing (docs/02, section 8.3; docs/06, 10.2)");
|
|
483
|
+
const concurrency = options.concurrency ?? 1;
|
|
484
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) throw new ConfigError(`createWorker concurrency must be a positive integer, got ${concurrency}`);
|
|
485
|
+
const owner = options.owner ?? workerIdentity();
|
|
486
|
+
const ttlMs = options.ttlMs ?? 6e4;
|
|
487
|
+
const renewMs = Math.max(1, Math.floor(ttlMs / 3));
|
|
488
|
+
const pollMs = options.pollMs ?? 1e3;
|
|
489
|
+
const registry = buildDeriverRegistry(options.extraDerivers);
|
|
490
|
+
const active = /* @__PURE__ */ new Map();
|
|
491
|
+
/** Runs this worker must not retry (DEF-6 violations, binding errors). */
|
|
492
|
+
const poisoned = /* @__PURE__ */ new Set();
|
|
493
|
+
/**
|
|
494
|
+
* Journal length at our last release of a still-suspended run: nothing
|
|
495
|
+
* new to consume until it grows (an offline resolution appends).
|
|
496
|
+
*/
|
|
497
|
+
const suspendedAt = /* @__PURE__ */ new Map();
|
|
498
|
+
let pollTimer;
|
|
499
|
+
let stopping = false;
|
|
500
|
+
function reportError(runId, error) {
|
|
501
|
+
try {
|
|
502
|
+
options.onError?.(runId, error);
|
|
503
|
+
} catch {}
|
|
504
|
+
}
|
|
505
|
+
async function releaseQuietly(lease) {
|
|
506
|
+
try {
|
|
507
|
+
await store.release(lease);
|
|
508
|
+
} catch {}
|
|
509
|
+
}
|
|
510
|
+
/** Drives one leased run to its next settle. */
|
|
511
|
+
async function drive(runId, meta, lease) {
|
|
512
|
+
const handle = engine.resume(runId, void 0, {
|
|
513
|
+
lease,
|
|
514
|
+
...options.argsFor === void 0 ? {} : { args: options.argsFor(meta) }
|
|
515
|
+
});
|
|
516
|
+
const renewTimer = setInterval(() => {
|
|
517
|
+
store.renew(lease).catch((thrown) => {
|
|
518
|
+
reportError(runId, thrown);
|
|
519
|
+
handle.cancel("lease lost: fencing epoch superseded");
|
|
520
|
+
clearInterval(renewTimer);
|
|
521
|
+
active.delete(runId);
|
|
522
|
+
});
|
|
523
|
+
}, renewMs);
|
|
524
|
+
const settled = handle.result.then(async (outcome) => {
|
|
525
|
+
if (outcome.status === "suspended") {
|
|
526
|
+
const entries = await store.load(runId);
|
|
527
|
+
suspendedAt.set(runId, entries.length);
|
|
528
|
+
} else suspendedAt.delete(runId);
|
|
529
|
+
}).catch((thrown) => {
|
|
530
|
+
if (thrown instanceof ConfigError || thrown instanceof JournalCompatibilityError) poisoned.add(runId);
|
|
531
|
+
reportError(runId, thrown);
|
|
532
|
+
}).finally(async () => {
|
|
533
|
+
clearInterval(renewTimer);
|
|
534
|
+
await releaseQuietly(lease);
|
|
535
|
+
active.delete(runId);
|
|
536
|
+
});
|
|
537
|
+
active.set(runId, {
|
|
538
|
+
lease,
|
|
539
|
+
renewTimer,
|
|
540
|
+
cancel: async (reason) => {
|
|
541
|
+
await handle.cancel(reason);
|
|
542
|
+
},
|
|
543
|
+
settled: settled.then(() => void 0)
|
|
544
|
+
});
|
|
545
|
+
await settled;
|
|
546
|
+
}
|
|
547
|
+
/** Opt-in retention over settled runs (docs/02, 8.3; M8-T04). */
|
|
548
|
+
async function applyRetention(meta) {
|
|
549
|
+
if (options.retention?.(meta) !== true) return;
|
|
550
|
+
let lease;
|
|
551
|
+
try {
|
|
552
|
+
lease = await store.acquire(meta.runId, owner);
|
|
553
|
+
} catch (thrown) {
|
|
554
|
+
if (thrown instanceof LeaseHeldError) return;
|
|
555
|
+
throw thrown;
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
await engine.deleteRun(meta.runId);
|
|
559
|
+
suspendedAt.delete(meta.runId);
|
|
560
|
+
poisoned.delete(meta.runId);
|
|
561
|
+
} finally {
|
|
562
|
+
await releaseQuietly(lease);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
async function sweep() {
|
|
566
|
+
if (stopping) return 0;
|
|
567
|
+
let picked = 0;
|
|
568
|
+
const metas = await store.listRuns();
|
|
569
|
+
for (const meta of metas) {
|
|
570
|
+
if (active.size >= concurrency) break;
|
|
571
|
+
if (!CANDIDATE_STATUSES.has(meta.status)) {
|
|
572
|
+
if (options.retention !== void 0 && !active.has(meta.runId)) await applyRetention(meta).catch((thrown) => {
|
|
573
|
+
reportError(meta.runId, thrown);
|
|
574
|
+
});
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (active.has(meta.runId) || poisoned.has(meta.runId)) continue;
|
|
578
|
+
let lease;
|
|
579
|
+
try {
|
|
580
|
+
lease = await store.acquire(meta.runId, owner);
|
|
581
|
+
} catch (thrown) {
|
|
582
|
+
if (thrown instanceof LeaseHeldError) continue;
|
|
583
|
+
throw thrown;
|
|
584
|
+
}
|
|
585
|
+
try {
|
|
586
|
+
const entries = (await store.load(meta.runId)).map((raw) => normalizeEntry(raw));
|
|
587
|
+
scanJournalCompatibility(meta.runId, entries, registry);
|
|
588
|
+
if (meta.status === "suspended" && suspendedAt.get(meta.runId) === entries.length) {
|
|
589
|
+
await releaseQuietly(lease);
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
picked += 1;
|
|
593
|
+
drive(meta.runId, meta, lease);
|
|
594
|
+
} catch (thrown) {
|
|
595
|
+
await releaseQuietly(lease);
|
|
596
|
+
if (thrown instanceof JournalCompatibilityError || thrown instanceof ConfigError) {
|
|
597
|
+
poisoned.add(meta.runId);
|
|
598
|
+
reportError(meta.runId, thrown);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
reportError(meta.runId, thrown);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return picked;
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
start: () => {
|
|
608
|
+
if (pollTimer !== void 0 || stopping) return;
|
|
609
|
+
pollTimer = setInterval(() => {
|
|
610
|
+
sweep().catch(() => void 0);
|
|
611
|
+
}, pollMs);
|
|
612
|
+
sweep().catch(() => void 0);
|
|
613
|
+
},
|
|
614
|
+
sweep,
|
|
615
|
+
stop: async () => {
|
|
616
|
+
stopping = true;
|
|
617
|
+
if (pollTimer !== void 0) {
|
|
618
|
+
clearInterval(pollTimer);
|
|
619
|
+
pollTimer = void 0;
|
|
620
|
+
}
|
|
621
|
+
const held = [...active.values()];
|
|
622
|
+
await Promise.all(held.map(async (run) => {
|
|
623
|
+
await run.cancel("worker stopping");
|
|
624
|
+
await run.settled;
|
|
625
|
+
}));
|
|
626
|
+
},
|
|
627
|
+
active: () => [...active.keys()]
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
//#endregion
|
|
631
|
+
//#region src/otel.ts
|
|
632
|
+
/**
|
|
633
|
+
* OpenTelemetry exporter (M5-T08; docs/09, section 3). `toOtel(run,
|
|
634
|
+
* tracer)` maps the spanId tree of a run 1:1 onto OTel spans: one span
|
|
635
|
+
* per rulvar span, parented per the docs/09 1.2 hierarchy (run > phase >
|
|
636
|
+
* agent > tool > child), with start/end timestamps from the lifecycle
|
|
637
|
+
* events. Events without an own span (log, budget:update) attach as span
|
|
638
|
+
* events on their enclosing span.
|
|
639
|
+
*
|
|
640
|
+
* `@opentelemetry/api` ^1.9 is an OPTIONAL peer: the CLI has no OTel
|
|
641
|
+
* dependency, and the exporter is typed against a minimal structural
|
|
642
|
+
* `TracerLike` so an absent peer never breaks the CLI. Attribute content
|
|
643
|
+
* policy: prompts, completions, and tool payloads are NEVER exported;
|
|
644
|
+
* only identifiers, statuses, usage counters, and cost figures ride
|
|
645
|
+
* `rulvar.*` and `gen_ai.*` attributes. Replayed events do not create
|
|
646
|
+
* duplicate spans; the single span is marked `rulvar.replayed = true`.
|
|
647
|
+
*/
|
|
648
|
+
const SPAN_OPENERS = /* @__PURE__ */ new Set([
|
|
649
|
+
"run:start",
|
|
650
|
+
"phase:start",
|
|
651
|
+
"agent:start",
|
|
652
|
+
"tool:start",
|
|
653
|
+
"child:start"
|
|
654
|
+
]);
|
|
655
|
+
function msOf(ts) {
|
|
656
|
+
return Date.parse(ts);
|
|
657
|
+
}
|
|
658
|
+
/** The OTel status codes (UNSET 0, OK 1, ERROR 2); inlined to avoid the peer. */
|
|
659
|
+
const STATUS_OK = 1;
|
|
660
|
+
const STATUS_ERROR = 2;
|
|
661
|
+
function spanName(event) {
|
|
662
|
+
switch (event.type) {
|
|
663
|
+
case "run:start": return `run ${event.workflow}`;
|
|
664
|
+
case "phase:start": return `phase ${event.phase}`;
|
|
665
|
+
case "agent:start": return `agent ${event.agentType || "(anon)"} ${event.role}`;
|
|
666
|
+
case "tool:start": return `tool ${event.toolName}`;
|
|
667
|
+
case "child:start": return `workflow ${event.workflow}`;
|
|
668
|
+
default: return event.type;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function openAttributes(event, runId) {
|
|
672
|
+
const attrs = {
|
|
673
|
+
"rulvar.run_id": runId,
|
|
674
|
+
"rulvar.entry_seq": event.seq
|
|
675
|
+
};
|
|
676
|
+
const scope = event.scope;
|
|
677
|
+
if (typeof scope === "string") attrs["rulvar.scope"] = scope;
|
|
678
|
+
if (event.replayed === true) attrs["rulvar.replayed"] = true;
|
|
679
|
+
if (event.type === "agent:start") {
|
|
680
|
+
attrs["rulvar.agent_type"] = event.agentType;
|
|
681
|
+
attrs["gen_ai.request.model"] = event.model;
|
|
682
|
+
attrs["gen_ai.operation.name"] = event.role;
|
|
683
|
+
}
|
|
684
|
+
if (event.type === "tool:start") attrs["rulvar.tool_name"] = event.toolName;
|
|
685
|
+
for (const [key, value] of Object.entries(attrs)) if (typeof value === "string") attrs[key] = maskSecrets(value);
|
|
686
|
+
return attrs;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Exports one settled run's event stream onto a tracer. The run's
|
|
690
|
+
* events are consumed in seq order; span openers start spans, the
|
|
691
|
+
* matching closers end them, and payload-only events attach as span
|
|
692
|
+
* events on the innermost open span. Returns the number of spans
|
|
693
|
+
* created.
|
|
694
|
+
*/
|
|
695
|
+
async function toOtel(run, tracer, options = {}) {
|
|
696
|
+
const openBySpanId = /* @__PURE__ */ new Map();
|
|
697
|
+
const stack = [];
|
|
698
|
+
let created = 0;
|
|
699
|
+
const startSpan = (event) => {
|
|
700
|
+
if (event.replayed === true && openBySpanId.has(event.spanId)) {
|
|
701
|
+
openBySpanId.get(event.spanId)?.span.setAttribute("rulvar.replayed", true);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const span = tracer.startSpan(spanName(event), {
|
|
705
|
+
startTime: msOf(event.ts),
|
|
706
|
+
attributes: openAttributes(event, run.runId)
|
|
707
|
+
});
|
|
708
|
+
created += 1;
|
|
709
|
+
const open = {
|
|
710
|
+
span,
|
|
711
|
+
spanId: event.spanId,
|
|
712
|
+
...event.parentSpanId === void 0 ? {} : { parentSpanId: event.parentSpanId }
|
|
713
|
+
};
|
|
714
|
+
openBySpanId.set(event.spanId, open);
|
|
715
|
+
stack.push(open);
|
|
716
|
+
};
|
|
717
|
+
const endSpan = (spanId, ts, status, message) => {
|
|
718
|
+
const open = openBySpanId.get(spanId);
|
|
719
|
+
if (open === void 0) return;
|
|
720
|
+
if (status !== void 0) open.span.setAttribute("rulvar.status", status);
|
|
721
|
+
open.span.setStatus(status !== void 0 && status !== "ok" && status !== "skipped" ? {
|
|
722
|
+
code: STATUS_ERROR,
|
|
723
|
+
...message === void 0 ? {} : { message }
|
|
724
|
+
} : { code: STATUS_OK });
|
|
725
|
+
open.span.end(msOf(ts));
|
|
726
|
+
openBySpanId.delete(spanId);
|
|
727
|
+
const idx = stack.lastIndexOf(open);
|
|
728
|
+
if (idx !== -1) stack.splice(idx, 1);
|
|
729
|
+
};
|
|
730
|
+
for await (const event of run.events) {
|
|
731
|
+
if (SPAN_OPENERS.has(event.type)) {
|
|
732
|
+
startSpan(event);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
switch (event.type) {
|
|
736
|
+
case "run:end":
|
|
737
|
+
endSpan(event.spanId, event.ts, event.status);
|
|
738
|
+
break;
|
|
739
|
+
case "agent:end":
|
|
740
|
+
endSpan(event.spanId, event.ts, event.status);
|
|
741
|
+
break;
|
|
742
|
+
case "tool:end":
|
|
743
|
+
endSpan(event.spanId, event.ts, event.outcome);
|
|
744
|
+
break;
|
|
745
|
+
case "child:end":
|
|
746
|
+
endSpan(event.spanId, event.ts, event.status);
|
|
747
|
+
break;
|
|
748
|
+
default: (openBySpanId.get(event.spanId) ?? stack[stack.length - 1])?.span.addEvent(event.type, { "rulvar.entry_seq": event.seq });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
const outcome = await run.result;
|
|
752
|
+
for (const open of [...openBySpanId.values()]) {
|
|
753
|
+
open.span.setStatus({ code: outcome.status === "ok" ? STATUS_OK : STATUS_ERROR });
|
|
754
|
+
open.span.end();
|
|
755
|
+
openBySpanId.delete(open.spanId);
|
|
756
|
+
}
|
|
757
|
+
return created;
|
|
758
|
+
}
|
|
759
|
+
//#endregion
|
|
760
|
+
export { DEFAULT_STORE_DIR, DEFAULT_WORKER_TTL_MS, HELP, assembleEngine, attachProgress, createServer, createWorker, driveRun, inspectCommand, loadCliConfig, loadWorkflowModule, looksLikeFile, processIo, renderEventLine, reportOutcome, resumeCommand, runCli, runCommand, runsLsCommand, toOtel };
|