@rulvar/cli 1.25.0 → 1.27.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/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { r as runCli, t as processIo } from "./io-CAXfPWq7.js";
2
+ import { r as runCli, t as processIo } from "./io-C3T5nbNg.js";
3
3
  import { sanitizeTerminalText } from "@rulvar/core";
4
4
  import { inspect } from "node:util";
5
5
  //#region src/cli.ts
package/dist/index.d.ts CHANGED
@@ -164,13 +164,69 @@ interface CreateServerOptions {
164
164
  */
165
165
  priceUsd?: (servedBy: ModelRef, usage: Usage) => number | undefined;
166
166
  /**
167
- * Opt-in retention (OQ-20 executed at M8-T04): evaluated
167
+ * Opt-in DURABLE retention (OQ-20 executed at M8-T04): evaluated
168
168
  * when a tracked run settles terminally; a true verdict applies
169
169
  * engine.deleteRun (transcript cascade, then the journal) and
170
- * untracks the run. Absent means everything persists indefinitely.
170
+ * untracks the run. This deletes the durable record; to release only
171
+ * process memory, use `memoryRetention` or `maxTrackedRuns`. Absent
172
+ * means nothing is deleted.
171
173
  */
172
174
  retention?: (meta: RunMeta) => boolean;
175
+ /**
176
+ * Opt-in retention of PROCESS MEMORY, decoupled from the durable kind
177
+ * (v1.25.0 scale review P1-2): evaluated when a tracked run settles
178
+ * terminally, after `retention`; a true verdict releases the tracked
179
+ * state (args, outcome, handle, SSE buffer) while the journal and
180
+ * transcripts stay untouched, after which GET status/cost serve from
181
+ * the store exactly as for a run another process owns, and GET events
182
+ * answers with the documented empty stream for a run not live here.
183
+ */
184
+ memoryRetention?: (meta: RunMeta) => boolean;
185
+ /**
186
+ * Cap on SETTLED tracked runs kept in process memory: when a run
187
+ * settles terminally and neither retention released it, the oldest
188
+ * settled tracked runs beyond the cap are released exactly like a
189
+ * `memoryRetention` verdict (durable state untouched). Live runs are
190
+ * never evicted and do not count toward the cap. Absent means no cap.
191
+ * Validated at construction: a non-negative safe integer (zero keeps
192
+ * no settled runs), anything else is a typed ConfigError.
193
+ */
194
+ maxTrackedRuns?: number;
195
+ /**
196
+ * Upper bound on buffered SSE replay events per tracked run: past the
197
+ * bound the OLDEST buffered events are dropped in chunks (so the
198
+ * retained replay window stays at least seven eighths of the bound)
199
+ * and counted. A replay that no longer reaches back to a client's
200
+ * cursor carries `x-rulvar-events-dropped: <count>` and a leading SSE
201
+ * comment naming the first retained seq; the journal remains the
202
+ * durable record of the run itself. Absent means unbounded (the
203
+ * historical behavior). Validated at construction: a positive safe
204
+ * integer, anything else is a typed ConfigError.
205
+ */
206
+ maxBufferedEventsPerRun?: number;
207
+ /**
208
+ * Upper bound on SSE frames PENDING in one client connection's
209
+ * response queue, replay and live feed alike (v1.26.0 deep E2E
210
+ * review P1-2: the replay buffer bound does not bound what a
211
+ * connected consumer that stopped reading accumulates). When a
212
+ * connection's pending queue reaches the bound, the server unhooks
213
+ * the feed, appends an SSE comment naming the bound, and CLOSES that
214
+ * connection; queued frames stay readable, and the standard
215
+ * Last-Event-ID reconnect resumes strictly after the last frame the
216
+ * client consumed. A replay longer than the bound is likewise
217
+ * delivered in bounded chunks across reconnects, so pending memory
218
+ * per connection is O(bound), never O(events). Validated at
219
+ * construction: a positive safe integer. Defaults to 10000.
220
+ */
221
+ maxPendingEventsPerClient?: number;
173
222
  }
223
+ /**
224
+ * The default per-connection pending-frame bound: generous enough that
225
+ * a reading consumer never notices (a normal reader keeps the queue
226
+ * near empty), small enough that a consumer that stopped reading
227
+ * cannot grow process memory past a few megabytes per connection.
228
+ */
229
+ declare const DEFAULT_MAX_PENDING_EVENTS_PER_CLIENT = 1e4;
174
230
  interface RulvarServer {
175
231
  fetch(req: Request): Promise<Response>;
176
232
  }
@@ -284,4 +340,4 @@ declare function toOtel(run: {
284
340
  result: Promise<RunOutcome<unknown>>;
285
341
  }, tracer: TracerLike, options?: ToOtelOptions): Promise<number>;
286
342
  //#endregion
287
- export { type AssembledCli, type CliConfig, type CliIo, type CommandContext, type CreateServerOptions, type CreateWorkerOptions, DEFAULT_STORE_DIR, DEFAULT_WORKER_TTL_MS, HELP, type RulvarServer, type SpanLike, type ToOtelOptions, type TracerLike, type Worker, assembleEngine, attachProgress, createServer, createWorker, driveRun, inspectCommand, loadCliConfig, loadWorkflowModule, looksLikeFile, processIo, renderEventLine, reportOutcome, resumeCommand, runCli, runCommand, runsLsCommand, toOtel };
343
+ export { type AssembledCli, type CliConfig, type CliIo, type CommandContext, type CreateServerOptions, type CreateWorkerOptions, DEFAULT_MAX_PENDING_EVENTS_PER_CLIENT, DEFAULT_STORE_DIR, DEFAULT_WORKER_TTL_MS, HELP, type KbSweepCliConfig, type LoadedWorkflowModule, type OtelContextApi, type RulvarServer, type SpanLike, type ToOtelOptions, type TracerLike, type Worker, assembleEngine, attachProgress, createServer, createWorker, driveRun, inspectCommand, loadCliConfig, loadWorkflowModule, looksLikeFile, processIo, renderEventLine, reportOutcome, resumeCommand, runCli, runCommand, runsLsCommand, toOtel };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
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-CAXfPWq7.js";
2
- import { ConfigError, InvalidResolutionError, JournalCompatibilityError, LeaseHeldError, Replayer, RulvarError, buildDeriverRegistry, costReportFromJournal, maskSecrets, normalizeEntry, scanJournalCompatibility, validateSchemaSpec } from "@rulvar/core";
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-C3T5nbNg.js";
2
+ import { ConfigError, InvalidResolutionError, JournalCompatibilityError, LeaseHeldError, Replayer, RulvarError, buildDeriverRegistry, costReportFromJournal, maskSecrets, normalizeEntry, readRunMeta, scanJournalCompatibility, validateSchemaSpec } from "@rulvar/core";
3
3
  //#region src/server.ts
4
4
  /**
5
5
  * createServer (M8-T01): the HTTP shell over the public engine API
@@ -20,7 +20,12 @@ import { ConfigError, InvalidResolutionError, JournalCompatibilityError, LeaseHe
20
20
  * maps Last-Event-ID to the event seq (the per-run telemetry counter);
21
21
  * replay is at-least-once by design, matching the
22
22
  * journal-backed re-emission contract (consumers
23
- * deduplicate on `replayed`).
23
+ * deduplicate on `replayed`). A terminal settle closes connected
24
+ * streams only AFTER the segment's event pump has delivered the full
25
+ * tail (run:end included), and every connection's pending queue is
26
+ * bounded by maxPendingEventsPerClient: a consumer that stops reading
27
+ * is closed at the bound and resumes through the standard
28
+ * Last-Event-ID replay window (v1.26.0 deep E2E review P1-1/P1-2).
24
29
  *
25
30
  * The server is a single-process shell: it tracks the runs it started
26
31
  * (or resumed) in memory and serves everything else from the engine's
@@ -30,6 +35,13 @@ import { ConfigError, InvalidResolutionError, JournalCompatibilityError, LeaseHe
30
35
  * run resumes on a queue worker (createWorker, M8-T02), not here,
31
36
  * because original run arguments are not journaled in v1 (OQ-21).
32
37
  */
38
+ /**
39
+ * The default per-connection pending-frame bound: generous enough that
40
+ * a reading consumer never notices (a normal reader keeps the queue
41
+ * near empty), small enough that a consumer that stopped reading
42
+ * cannot grow process memory past a few megabytes per connection.
43
+ */
44
+ const DEFAULT_MAX_PENDING_EVENTS_PER_CLIENT = 1e4;
33
45
  const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
34
46
  const wallClock = Date.now.bind(globalThis);
35
47
  function json(status, body) {
@@ -73,34 +85,83 @@ function suspensionKeyOf(entry) {
73
85
  }
74
86
  if (entry.kind === "approval") return `${APPROVAL_KEY_PREFIX}${entry.seq}`;
75
87
  }
88
+ /**
89
+ * Rejects a numeric cap outside its documented domain with a typed
90
+ * ConfigError at construction (v1.26.0 deep E2E review P2-1: NaN
91
+ * silently meant unbounded, Infinity looked like a cap without capping,
92
+ * negatives and fractions produced policies nobody asked for).
93
+ */
94
+ function requireCap(name, value, minimum) {
95
+ if (value === void 0) return;
96
+ if (!Number.isSafeInteger(value) || value < minimum) throw new ConfigError(`createServer ${name} must be a ${minimum === 0 ? "non-negative" : "positive"} safe integer, got ${String(value)}`);
97
+ }
76
98
  function createServer(options) {
77
99
  const { engine, workflows } = options;
100
+ requireCap("maxTrackedRuns", options.maxTrackedRuns, 0);
101
+ requireCap("maxBufferedEventsPerRun", options.maxBufferedEventsPerRun, 1);
102
+ requireCap("maxPendingEventsPerClient", options.maxPendingEventsPerClient, 1);
103
+ const pendingCap = options.maxPendingEventsPerClient ?? 1e4;
78
104
  const journal = engine.stores.journal;
79
105
  const runs = /* @__PURE__ */ new Map();
106
+ /**
107
+ * Buffers one event under the configured bound. Overflow drops the
108
+ * oldest chunk (an eighth of the bound) in one splice, so the
109
+ * amortized cost per event stays O(1) and the retained window never
110
+ * falls below seven eighths of the bound.
111
+ */
112
+ function pushBuffered(run, event) {
113
+ run.buffer.push(event);
114
+ const max = options.maxBufferedEventsPerRun;
115
+ if (max !== void 0 && run.buffer.length > max) {
116
+ const chunk = Math.max(1, Math.floor(max / 8));
117
+ run.buffer.splice(0, chunk);
118
+ run.dropped += chunk;
119
+ }
120
+ }
121
+ /**
122
+ * Releases settled tracked runs beyond maxTrackedRuns, oldest first
123
+ * (Map insertion order), durable state untouched. Live runs never
124
+ * count and are never evicted.
125
+ */
126
+ function enforceTrackedCap() {
127
+ const cap = options.maxTrackedRuns;
128
+ if (cap === void 0) return;
129
+ const settled = [...runs.values()].filter((run) => run.done);
130
+ for (const run of settled.slice(0, Math.max(0, settled.length - cap))) runs.delete(run.runId);
131
+ }
80
132
  /** Pumps one resume segment's events into the buffer and the feeds. */
81
133
  function attach(run, handle) {
82
134
  run.handle = handle;
83
135
  run.outcome = void 0;
84
- (async () => {
136
+ let pumpFailed = false;
137
+ const pump = (async () => {
85
138
  for await (const event of handle.events) {
86
- run.buffer.push(event);
139
+ pushBuffered(run, event);
87
140
  for (const feed of [...run.feeds]) feed(event);
88
141
  }
89
- })().catch(() => void 0);
90
- handle.result.then((outcome) => {
142
+ })().catch(() => {
143
+ pumpFailed = true;
144
+ });
145
+ handle.result.then(async (outcome) => {
91
146
  run.outcome = outcome;
92
- if (outcome.status !== "suspended") {
93
- run.done = true;
94
- for (const feed of [...run.feeds]) feed(null);
95
- run.feeds.clear();
96
- if (options.retention !== void 0) (async () => {
97
- const meta = await metaOf(run.runId);
98
- if (meta !== void 0 && options.retention?.(meta) === true) {
99
- await engine.deleteRun(run.runId);
100
- runs.delete(run.runId);
101
- }
102
- })().catch(() => void 0);
103
- }
147
+ if (outcome.status === "suspended") return;
148
+ await pump;
149
+ run.done = true;
150
+ for (const feed of [...run.feeds]) feed(null, pumpFailed ? "event pump failed; the stream may be incomplete, reconnect with Last-Event-ID to replay" : void 0);
151
+ run.feeds.clear();
152
+ (async () => {
153
+ const meta = options.retention === void 0 && options.memoryRetention === void 0 ? void 0 : await metaOf(run.runId);
154
+ if (meta !== void 0 && options.retention?.(meta) === true) {
155
+ await engine.deleteRun(run.runId);
156
+ runs.delete(run.runId);
157
+ return;
158
+ }
159
+ if (meta !== void 0 && options.memoryRetention?.(meta) === true) {
160
+ runs.delete(run.runId);
161
+ return;
162
+ }
163
+ enforceTrackedCap();
164
+ })().catch(() => void 0);
104
165
  }).catch(() => void 0);
105
166
  }
106
167
  function track(runId, workflowName, args, handle) {
@@ -109,6 +170,7 @@ function createServer(options) {
109
170
  workflowName,
110
171
  args,
111
172
  buffer: [],
173
+ dropped: 0,
112
174
  feeds: /* @__PURE__ */ new Set(),
113
175
  handle,
114
176
  done: false,
@@ -119,7 +181,7 @@ function createServer(options) {
119
181
  return run;
120
182
  }
121
183
  async function metaOf(runId) {
122
- return (await journal.listRuns()).find((meta) => meta.runId === runId);
184
+ return readRunMeta(journal, runId);
123
185
  }
124
186
  async function startRun(req) {
125
187
  let body;
@@ -221,26 +283,68 @@ function createServer(options) {
221
283
  const lastEventId = req.headers.get("last-event-id");
222
284
  const encoder = new TextEncoder();
223
285
  let feed;
286
+ const pendingOf = (controller) => {
287
+ const desired = controller.desiredSize;
288
+ return desired === null ? null : 1 - desired;
289
+ };
290
+ const slowConsumerComment = `: pending frames reached maxPendingEventsPerClient (${pendingCap}); reconnect with Last-Event-ID to continue
291
+
292
+ `;
293
+ let cursor;
294
+ if (lastEventId !== null) {
295
+ const parsed = Number(lastEventId);
296
+ if (Number.isFinite(parsed)) cursor = parsed;
297
+ }
298
+ let startIndex = 0;
299
+ if (cursor !== void 0) {
300
+ let lo = 0;
301
+ let hi = run.buffer.length;
302
+ while (lo < hi) {
303
+ const mid = lo + hi >> 1;
304
+ if (run.buffer[mid].seq <= cursor) lo = mid + 1;
305
+ else hi = mid;
306
+ }
307
+ startIndex = lo;
308
+ }
309
+ const firstRetained = run.buffer.length > 0 ? run.buffer[0].seq : void 0;
310
+ const gap = run.dropped > 0 && (cursor === void 0 || firstRetained === void 0 || cursor < firstRetained);
311
+ const headers = {
312
+ "content-type": "text/event-stream",
313
+ "cache-control": "no-cache",
314
+ ...run.dropped > 0 ? { "x-rulvar-events-dropped": String(run.dropped) } : {}
315
+ };
224
316
  const stream = new ReadableStream({
225
317
  start(controller) {
226
- let startIndex = 0;
227
- if (lastEventId !== null) {
228
- const cursor = Number(lastEventId);
229
- if (Number.isFinite(cursor)) {
230
- for (let i = run.buffer.length - 1; i >= 0; i -= 1) if (run.buffer[i].seq === cursor) {
231
- startIndex = i + 1;
232
- break;
233
- }
318
+ if (gap) controller.enqueue(encoder.encode(`: replay window starts at seq ${String(firstRetained ?? "none")}; ${run.dropped} earlier events were dropped from the in-memory buffer (the journal is the durable record)
319
+
320
+ `));
321
+ for (let i = startIndex; i < run.buffer.length; i += 1) {
322
+ const pending = pendingOf(controller);
323
+ if (pending === null) return;
324
+ if (pending >= pendingCap) {
325
+ controller.enqueue(encoder.encode(slowConsumerComment));
326
+ controller.close();
327
+ return;
234
328
  }
329
+ controller.enqueue(encoder.encode(sseFrame(run.buffer[i])));
235
330
  }
236
- for (const event of run.buffer.slice(startIndex)) controller.enqueue(encoder.encode(sseFrame(event)));
237
331
  if (run.done) {
238
332
  controller.close();
239
333
  return;
240
334
  }
241
- feed = (event) => {
335
+ feed = (event, note) => {
242
336
  if (event === null) {
243
337
  try {
338
+ if (note !== void 0) controller.enqueue(encoder.encode(`: ${note}\n\n`));
339
+ controller.close();
340
+ } catch {}
341
+ return;
342
+ }
343
+ const pending = pendingOf(controller);
344
+ if (pending !== null && pending >= pendingCap) {
345
+ if (feed !== void 0) run.feeds.delete(feed);
346
+ try {
347
+ controller.enqueue(encoder.encode(slowConsumerComment));
244
348
  controller.close();
245
349
  } catch {}
246
350
  return;
@@ -257,16 +361,13 @@ function createServer(options) {
257
361
  });
258
362
  return new Response(stream, {
259
363
  status: 200,
260
- headers: {
261
- "content-type": "text/event-stream",
262
- "cache-control": "no-cache"
263
- }
364
+ headers
264
365
  });
265
366
  }
266
367
  /** The tracked path: live (or settled-suspended) in this process. */
267
368
  async function resolveTracked(run, key, value) {
268
369
  const section = run.queue.then(async () => {
269
- if (run.done) return json(409, { error: {
370
+ if (run.done || run.outcome !== void 0 && run.outcome.status !== "suspended") return json(409, { error: {
270
371
  code: "config",
271
372
  message: `run '${run.runId}' already settled '${run.outcome?.status ?? "unknown"}'`
272
373
  } });
@@ -487,14 +588,28 @@ function createWorker(engine, options) {
487
588
  const pollMs = options.pollMs ?? 1e3;
488
589
  const registry = buildDeriverRegistry(options.extraDerivers);
489
590
  const active = /* @__PURE__ */ new Map();
490
- /** Runs this worker must not retry (DEF-6 violations, binding errors). */
491
- const poisoned = /* @__PURE__ */ new Set();
492
591
  /**
493
- * Journal length at our last release of a still-suspended run: nothing
494
- * new to consume until it grows (an offline resolution appends).
592
+ * Runs this worker must not retry (DEF-6 violations, binding errors),
593
+ * keyed to the run's generation (RunMeta.genesis) at poison time: a
594
+ * deleteRun and recreate of the same runId is a NEW run and must not
595
+ * inherit the poison. Entries for runIds that leave the candidate set
596
+ * are dropped each sweep, so an external delete cannot pin
597
+ * process-local state forever (v1.25.0 scale review).
598
+ */
599
+ const poisoned = /* @__PURE__ */ new Map();
600
+ /**
601
+ * Journal length AND generation at our last release of a
602
+ * still-suspended run: nothing new to consume until the journal grows
603
+ * (an offline resolution appends) or the generation changes (the same
604
+ * runId was deleted and recreated; length alone cannot tell the new
605
+ * run from the old unchanged one, the v1.25.0 scale review). Runs
606
+ * whose meta predates the genesis field compare as equal when both
607
+ * sides are undefined, the historical behavior of length alone.
495
608
  */
496
609
  const suspendedAt = /* @__PURE__ */ new Map();
497
610
  let pollTimer;
611
+ /** An interval tick never overlaps a sweep that is still running. */
612
+ let sweeping = false;
498
613
  let stopping = false;
499
614
  function reportError(runId, error) {
500
615
  try {
@@ -523,10 +638,13 @@ function createWorker(engine, options) {
523
638
  const settled = handle.result.then(async (outcome) => {
524
639
  if (outcome.status === "suspended") {
525
640
  const entries = await store.load(runId);
526
- suspendedAt.set(runId, entries.length);
641
+ suspendedAt.set(runId, {
642
+ length: entries.length,
643
+ genesis: meta.genesis
644
+ });
527
645
  } else suspendedAt.delete(runId);
528
646
  }).catch((thrown) => {
529
- if (thrown instanceof ConfigError || thrown instanceof JournalCompatibilityError) poisoned.add(runId);
647
+ if (thrown instanceof ConfigError || thrown instanceof JournalCompatibilityError) poisoned.set(runId, meta.genesis);
530
648
  reportError(runId, thrown);
531
649
  }).finally(async () => {
532
650
  clearInterval(renewTimer);
@@ -562,45 +680,58 @@ function createWorker(engine, options) {
562
680
  }
563
681
  }
564
682
  async function sweep() {
565
- if (stopping) return 0;
566
- let picked = 0;
567
- const metas = await store.listRuns();
568
- for (const meta of metas) {
569
- if (active.size >= concurrency) break;
570
- if (!CANDIDATE_STATUSES.has(meta.status)) {
571
- if (options.retention !== void 0 && !active.has(meta.runId)) await applyRetention(meta).catch((thrown) => {
572
- reportError(meta.runId, thrown);
573
- });
574
- continue;
575
- }
576
- if (active.has(meta.runId) || poisoned.has(meta.runId)) continue;
577
- let lease;
578
- try {
579
- lease = await store.acquire(meta.runId, owner);
580
- } catch (thrown) {
581
- if (thrown instanceof LeaseHeldError) continue;
582
- throw thrown;
583
- }
584
- try {
585
- const entries = (await store.load(meta.runId)).map((raw) => normalizeEntry(raw));
586
- scanJournalCompatibility(meta.runId, entries, registry);
587
- if (meta.status === "suspended" && suspendedAt.get(meta.runId) === entries.length) {
588
- await releaseQuietly(lease);
683
+ if (stopping || sweeping) return 0;
684
+ sweeping = true;
685
+ try {
686
+ let picked = 0;
687
+ const metas = options.retention === void 0 ? await store.listRuns({ statuses: [...CANDIDATE_STATUSES] }) : await store.listRuns();
688
+ const candidateIds = new Set(metas.filter((meta) => CANDIDATE_STATUSES.has(meta.status)).map((meta) => meta.runId));
689
+ for (const runId of [...suspendedAt.keys()]) if (!candidateIds.has(runId)) suspendedAt.delete(runId);
690
+ for (const runId of [...poisoned.keys()]) if (!candidateIds.has(runId)) poisoned.delete(runId);
691
+ for (const meta of metas) {
692
+ if (active.size >= concurrency) break;
693
+ if (!CANDIDATE_STATUSES.has(meta.status)) {
694
+ if (options.retention !== void 0 && !active.has(meta.runId)) await applyRetention(meta).catch((thrown) => {
695
+ reportError(meta.runId, thrown);
696
+ });
589
697
  continue;
590
698
  }
591
- picked += 1;
592
- drive(meta.runId, meta, lease);
593
- } catch (thrown) {
594
- await releaseQuietly(lease);
595
- if (thrown instanceof JournalCompatibilityError || thrown instanceof ConfigError) {
596
- poisoned.add(meta.runId);
699
+ if (active.has(meta.runId)) continue;
700
+ if (poisoned.has(meta.runId)) {
701
+ if (poisoned.get(meta.runId) === meta.genesis) continue;
702
+ poisoned.delete(meta.runId);
703
+ }
704
+ let lease;
705
+ try {
706
+ lease = await store.acquire(meta.runId, owner);
707
+ } catch (thrown) {
708
+ if (thrown instanceof LeaseHeldError) continue;
709
+ throw thrown;
710
+ }
711
+ try {
712
+ const entries = (await store.load(meta.runId)).map((raw) => normalizeEntry(raw));
713
+ scanJournalCompatibility(meta.runId, entries, registry);
714
+ const cached = suspendedAt.get(meta.runId);
715
+ if (meta.status === "suspended" && cached !== void 0 && cached.length === entries.length && cached.genesis === meta.genesis) {
716
+ await releaseQuietly(lease);
717
+ continue;
718
+ }
719
+ picked += 1;
720
+ drive(meta.runId, meta, lease);
721
+ } catch (thrown) {
722
+ await releaseQuietly(lease);
723
+ if (thrown instanceof JournalCompatibilityError || thrown instanceof ConfigError) {
724
+ poisoned.set(meta.runId, meta.genesis);
725
+ reportError(meta.runId, thrown);
726
+ continue;
727
+ }
597
728
  reportError(meta.runId, thrown);
598
- continue;
599
729
  }
600
- reportError(meta.runId, thrown);
601
730
  }
731
+ return picked;
732
+ } finally {
733
+ sweeping = false;
602
734
  }
603
- return picked;
604
735
  }
605
736
  return {
606
737
  start: () => {
@@ -759,4 +890,4 @@ async function toOtel(run, tracer, options = {}) {
759
890
  return created;
760
891
  }
761
892
  //#endregion
762
- 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 };
893
+ export { DEFAULT_MAX_PENDING_EVENTS_PER_CLIENT, 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 };
@@ -1,4 +1,4 @@
1
- import { ConfigError, FileModelKnowledgeStore, INBOX_PROPOSAL_TTL_DAYS, JsonlFileStore, claimExpired, claimExpiry, compilePermissionPreset, costReportFromJournal, createEngine, hashRunArgs, parseModelRef, priceUsdOf, proposalStatement, remeasureQueue, resolvePricing, runProfile, sanitizeTerminalText } from "@rulvar/core";
1
+ import { ConfigError, FileModelKnowledgeStore, INBOX_PROPOSAL_TTL_DAYS, JsonlFileStore, claimExpired, claimExpiry, compilePermissionPreset, costReportFromJournal, createEngine, hashRunArgs, parseModelRef, priceUsdOf, proposalStatement, readRunMeta, remeasureQueue, resolvePricing, runProfile, sanitizeTerminalText } from "@rulvar/core";
2
2
  import { join, resolve } from "node:path";
3
3
  import { existsSync, statSync } from "node:fs";
4
4
  import { pathToFileURL } from "node:url";
@@ -777,7 +777,7 @@ async function resumeCommand(argv, context) {
777
777
  ...store === void 0 ? {} : { storePath: store },
778
778
  cwd: context.cwd
779
779
  });
780
- const meta = (await assembled.store.listRuns()).find((m) => m.runId === runId);
780
+ const meta = await readRunMeta(assembled.store, runId);
781
781
  if (meta === void 0) throw new ConfigError(`run '${runId}' not found in the store`);
782
782
  enforceArgsBinding({
783
783
  meta,
@@ -829,7 +829,7 @@ async function inspectCommand(argv, context) {
829
829
  ...store === void 0 ? {} : { storePath: store },
830
830
  cwd: context.cwd
831
831
  });
832
- const meta = (await assembled.store.listRuns()).find((m) => m.runId === runId);
832
+ const meta = await readRunMeta(assembled.store, runId);
833
833
  if (meta === void 0) throw new ConfigError(`run '${runId}' not found in the store`);
834
834
  const entries = await assembled.store.load(runId);
835
835
  context.io.out(`run ${meta.runId}: ${meta.status} (updated ${meta.updatedAt})`);
@@ -1087,7 +1087,7 @@ async function kbGateCommand(argv, context) {
1087
1087
  ...values.store === void 0 ? {} : { storePath: values.store },
1088
1088
  cwd: context.cwd
1089
1089
  });
1090
- const meta = (await assembled.store.listRuns()).find((candidate) => candidate.runId === runId);
1090
+ const meta = await readRunMeta(assembled.store, runId);
1091
1091
  if (meta === void 0) throw new ConfigError(`run '${runId}' not found in the store`);
1092
1092
  if (meta.status === "running") throw new ConfigError(`run '${runId}' is still running; proposals gate from finished runs`);
1093
1093
  if (Date.parse(meta.updatedAt) < Date.now() - INBOX_PROPOSAL_TTL_DAYS * 24 * 60 * 60 * 1e3) throw new ConfigError(`the proposal expired: run '${runId}' finished ${meta.updatedAt}, and inbox entries expire after ${String(INBOX_PROPOSAL_TTL_DAYS)} days`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/cli",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "description": "Rulvar shell: run/resume/runs/inspect/plan/kb commands, TUI progress, createServer, createWorker, OTel exporter.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,17 +22,17 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@rulvar/core": "1.25.0"
25
+ "@rulvar/core": "1.27.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.20.0",
29
29
  "tsdown": "^0.22.3",
30
30
  "typescript": "~6.0.3",
31
- "@rulvar/planner": "1.25.0",
32
- "@rulvar/testing": "1.25.0",
33
- "@rulvar/plan": "1.25.0",
34
- "@rulvar/store-sqlite": "1.25.0",
35
- "@rulvar/evals": "1.25.0"
31
+ "@rulvar/store-sqlite": "1.27.0",
32
+ "@rulvar/planner": "1.27.0",
33
+ "@rulvar/testing": "1.27.0",
34
+ "@rulvar/plan": "1.27.0",
35
+ "@rulvar/evals": "1.27.0"
36
36
  },
37
37
  "bin": {
38
38
  "rulvar": "./dist/cli.js"