@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/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 };