@rulvar/cli 1.25.0 → 1.26.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,12 +164,43 @@ 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
+ */
192
+ maxTrackedRuns?: number;
193
+ /**
194
+ * Upper bound on buffered SSE replay events per tracked run: past the
195
+ * bound the OLDEST buffered events are dropped in chunks (so the
196
+ * retained replay window stays at least seven eighths of the bound)
197
+ * and counted. A replay that no longer reaches back to a client's
198
+ * cursor carries `x-rulvar-events-dropped: <count>` and a leading SSE
199
+ * comment naming the first retained seq; the journal remains the
200
+ * durable record of the run itself. Absent means unbounded (the
201
+ * historical behavior).
202
+ */
203
+ maxBufferedEventsPerRun?: number;
173
204
  }
174
205
  interface RulvarServer {
175
206
  fetch(req: Request): Promise<Response>;
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
@@ -77,13 +77,39 @@ function createServer(options) {
77
77
  const { engine, workflows } = options;
78
78
  const journal = engine.stores.journal;
79
79
  const runs = /* @__PURE__ */ new Map();
80
+ /**
81
+ * Buffers one event under the configured bound. Overflow drops the
82
+ * oldest chunk (an eighth of the bound) in one splice, so the
83
+ * amortized cost per event stays O(1) and the retained window never
84
+ * falls below seven eighths of the bound.
85
+ */
86
+ function pushBuffered(run, event) {
87
+ run.buffer.push(event);
88
+ const max = options.maxBufferedEventsPerRun;
89
+ if (max !== void 0 && run.buffer.length > max) {
90
+ const chunk = Math.max(1, Math.floor(max / 8));
91
+ run.buffer.splice(0, chunk);
92
+ run.dropped += chunk;
93
+ }
94
+ }
95
+ /**
96
+ * Releases settled tracked runs beyond maxTrackedRuns, oldest first
97
+ * (Map insertion order), durable state untouched. Live runs never
98
+ * count and are never evicted.
99
+ */
100
+ function enforceTrackedCap() {
101
+ const cap = options.maxTrackedRuns;
102
+ if (cap === void 0) return;
103
+ const settled = [...runs.values()].filter((run) => run.done);
104
+ for (const run of settled.slice(0, Math.max(0, settled.length - cap))) runs.delete(run.runId);
105
+ }
80
106
  /** Pumps one resume segment's events into the buffer and the feeds. */
81
107
  function attach(run, handle) {
82
108
  run.handle = handle;
83
109
  run.outcome = void 0;
84
110
  (async () => {
85
111
  for await (const event of handle.events) {
86
- run.buffer.push(event);
112
+ pushBuffered(run, event);
87
113
  for (const feed of [...run.feeds]) feed(event);
88
114
  }
89
115
  })().catch(() => void 0);
@@ -93,12 +119,18 @@ function createServer(options) {
93
119
  run.done = true;
94
120
  for (const feed of [...run.feeds]) feed(null);
95
121
  run.feeds.clear();
96
- if (options.retention !== void 0) (async () => {
97
- const meta = await metaOf(run.runId);
122
+ (async () => {
123
+ const meta = options.retention === void 0 && options.memoryRetention === void 0 ? void 0 : await metaOf(run.runId);
98
124
  if (meta !== void 0 && options.retention?.(meta) === true) {
99
125
  await engine.deleteRun(run.runId);
100
126
  runs.delete(run.runId);
127
+ return;
128
+ }
129
+ if (meta !== void 0 && options.memoryRetention?.(meta) === true) {
130
+ runs.delete(run.runId);
131
+ return;
101
132
  }
133
+ enforceTrackedCap();
102
134
  })().catch(() => void 0);
103
135
  }
104
136
  }).catch(() => void 0);
@@ -109,6 +141,7 @@ function createServer(options) {
109
141
  workflowName,
110
142
  args,
111
143
  buffer: [],
144
+ dropped: 0,
112
145
  feeds: /* @__PURE__ */ new Set(),
113
146
  handle,
114
147
  done: false,
@@ -119,7 +152,7 @@ function createServer(options) {
119
152
  return run;
120
153
  }
121
154
  async function metaOf(runId) {
122
- return (await journal.listRuns()).find((meta) => meta.runId === runId);
155
+ return readRunMeta(journal, runId);
123
156
  }
124
157
  async function startRun(req) {
125
158
  let body;
@@ -221,19 +254,35 @@ function createServer(options) {
221
254
  const lastEventId = req.headers.get("last-event-id");
222
255
  const encoder = new TextEncoder();
223
256
  let feed;
257
+ let cursor;
258
+ if (lastEventId !== null) {
259
+ const parsed = Number(lastEventId);
260
+ if (Number.isFinite(parsed)) cursor = parsed;
261
+ }
262
+ let startIndex = 0;
263
+ if (cursor !== void 0) {
264
+ let lo = 0;
265
+ let hi = run.buffer.length;
266
+ while (lo < hi) {
267
+ const mid = lo + hi >> 1;
268
+ if (run.buffer[mid].seq <= cursor) lo = mid + 1;
269
+ else hi = mid;
270
+ }
271
+ startIndex = lo;
272
+ }
273
+ const firstRetained = run.buffer.length > 0 ? run.buffer[0].seq : void 0;
274
+ const gap = run.dropped > 0 && (cursor === void 0 || firstRetained === void 0 || cursor < firstRetained);
275
+ const headers = {
276
+ "content-type": "text/event-stream",
277
+ "cache-control": "no-cache",
278
+ ...run.dropped > 0 ? { "x-rulvar-events-dropped": String(run.dropped) } : {}
279
+ };
224
280
  const stream = new ReadableStream({
225
281
  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
- }
234
- }
235
- }
236
- for (const event of run.buffer.slice(startIndex)) controller.enqueue(encoder.encode(sseFrame(event)));
282
+ 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)
283
+
284
+ `));
285
+ for (let i = startIndex; i < run.buffer.length; i += 1) controller.enqueue(encoder.encode(sseFrame(run.buffer[i])));
237
286
  if (run.done) {
238
287
  controller.close();
239
288
  return;
@@ -257,10 +306,7 @@ function createServer(options) {
257
306
  });
258
307
  return new Response(stream, {
259
308
  status: 200,
260
- headers: {
261
- "content-type": "text/event-stream",
262
- "cache-control": "no-cache"
263
- }
309
+ headers
264
310
  });
265
311
  }
266
312
  /** The tracked path: live (or settled-suspended) in this process. */
@@ -487,14 +533,28 @@ function createWorker(engine, options) {
487
533
  const pollMs = options.pollMs ?? 1e3;
488
534
  const registry = buildDeriverRegistry(options.extraDerivers);
489
535
  const active = /* @__PURE__ */ new Map();
490
- /** Runs this worker must not retry (DEF-6 violations, binding errors). */
491
- const poisoned = /* @__PURE__ */ new Set();
492
536
  /**
493
- * Journal length at our last release of a still-suspended run: nothing
494
- * new to consume until it grows (an offline resolution appends).
537
+ * Runs this worker must not retry (DEF-6 violations, binding errors),
538
+ * keyed to the run's generation (RunMeta.genesis) at poison time: a
539
+ * deleteRun and recreate of the same runId is a NEW run and must not
540
+ * inherit the poison. Entries for runIds that leave the candidate set
541
+ * are dropped each sweep, so an external delete cannot pin
542
+ * process-local state forever (v1.25.0 scale review).
543
+ */
544
+ const poisoned = /* @__PURE__ */ new Map();
545
+ /**
546
+ * Journal length AND generation at our last release of a
547
+ * still-suspended run: nothing new to consume until the journal grows
548
+ * (an offline resolution appends) or the generation changes (the same
549
+ * runId was deleted and recreated; length alone cannot tell the new
550
+ * run from the old unchanged one, the v1.25.0 scale review). Runs
551
+ * whose meta predates the genesis field compare as equal when both
552
+ * sides are undefined, the historical behavior of length alone.
495
553
  */
496
554
  const suspendedAt = /* @__PURE__ */ new Map();
497
555
  let pollTimer;
556
+ /** An interval tick never overlaps a sweep that is still running. */
557
+ let sweeping = false;
498
558
  let stopping = false;
499
559
  function reportError(runId, error) {
500
560
  try {
@@ -523,10 +583,13 @@ function createWorker(engine, options) {
523
583
  const settled = handle.result.then(async (outcome) => {
524
584
  if (outcome.status === "suspended") {
525
585
  const entries = await store.load(runId);
526
- suspendedAt.set(runId, entries.length);
586
+ suspendedAt.set(runId, {
587
+ length: entries.length,
588
+ genesis: meta.genesis
589
+ });
527
590
  } else suspendedAt.delete(runId);
528
591
  }).catch((thrown) => {
529
- if (thrown instanceof ConfigError || thrown instanceof JournalCompatibilityError) poisoned.add(runId);
592
+ if (thrown instanceof ConfigError || thrown instanceof JournalCompatibilityError) poisoned.set(runId, meta.genesis);
530
593
  reportError(runId, thrown);
531
594
  }).finally(async () => {
532
595
  clearInterval(renewTimer);
@@ -562,45 +625,58 @@ function createWorker(engine, options) {
562
625
  }
563
626
  }
564
627
  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);
628
+ if (stopping || sweeping) return 0;
629
+ sweeping = true;
630
+ try {
631
+ let picked = 0;
632
+ const metas = options.retention === void 0 ? await store.listRuns({ statuses: [...CANDIDATE_STATUSES] }) : await store.listRuns();
633
+ const candidateIds = new Set(metas.filter((meta) => CANDIDATE_STATUSES.has(meta.status)).map((meta) => meta.runId));
634
+ for (const runId of [...suspendedAt.keys()]) if (!candidateIds.has(runId)) suspendedAt.delete(runId);
635
+ for (const runId of [...poisoned.keys()]) if (!candidateIds.has(runId)) poisoned.delete(runId);
636
+ for (const meta of metas) {
637
+ if (active.size >= concurrency) break;
638
+ if (!CANDIDATE_STATUSES.has(meta.status)) {
639
+ if (options.retention !== void 0 && !active.has(meta.runId)) await applyRetention(meta).catch((thrown) => {
640
+ reportError(meta.runId, thrown);
641
+ });
589
642
  continue;
590
643
  }
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);
644
+ if (active.has(meta.runId)) continue;
645
+ if (poisoned.has(meta.runId)) {
646
+ if (poisoned.get(meta.runId) === meta.genesis) continue;
647
+ poisoned.delete(meta.runId);
648
+ }
649
+ let lease;
650
+ try {
651
+ lease = await store.acquire(meta.runId, owner);
652
+ } catch (thrown) {
653
+ if (thrown instanceof LeaseHeldError) continue;
654
+ throw thrown;
655
+ }
656
+ try {
657
+ const entries = (await store.load(meta.runId)).map((raw) => normalizeEntry(raw));
658
+ scanJournalCompatibility(meta.runId, entries, registry);
659
+ const cached = suspendedAt.get(meta.runId);
660
+ if (meta.status === "suspended" && cached !== void 0 && cached.length === entries.length && cached.genesis === meta.genesis) {
661
+ await releaseQuietly(lease);
662
+ continue;
663
+ }
664
+ picked += 1;
665
+ drive(meta.runId, meta, lease);
666
+ } catch (thrown) {
667
+ await releaseQuietly(lease);
668
+ if (thrown instanceof JournalCompatibilityError || thrown instanceof ConfigError) {
669
+ poisoned.set(meta.runId, meta.genesis);
670
+ reportError(meta.runId, thrown);
671
+ continue;
672
+ }
597
673
  reportError(meta.runId, thrown);
598
- continue;
599
674
  }
600
- reportError(meta.runId, thrown);
601
675
  }
676
+ return picked;
677
+ } finally {
678
+ sweeping = false;
602
679
  }
603
- return picked;
604
680
  }
605
681
  return {
606
682
  start: () => {
@@ -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.26.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.26.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/plan": "1.26.0",
32
+ "@rulvar/testing": "1.26.0",
33
+ "@rulvar/planner": "1.26.0",
34
+ "@rulvar/evals": "1.26.0",
35
+ "@rulvar/store-sqlite": "1.26.0"
36
36
  },
37
37
  "bin": {
38
38
  "rulvar": "./dist/cli.js"