@rulvar/cli 1.48.0 → 1.50.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-BK1hP3Od.js";
2
+ import { r as runCli, t as processIo } from "./io-DK9Ji_H2.js";
3
3
  import { sanitizeTerminalText } from "@rulvar/core";
4
4
  import { inspect } from "node:util";
5
5
  //#region src/cli.ts
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as looksLikeFile, a as resumeCommand, c as driveRun, d as attachProgress, f as renderEventLine, g as loadWorkflowModule, h as loadCliConfig, i as inspectCommand, l as reportOutcome, m as assembleEngine, n as HELP, o as runCommand, p as DEFAULT_STORE_DIR, r as runCli, s as runsLsCommand, t as processIo, u as strictExitCode } from "./io-BK1hP3Od.js";
1
+ import { _ as looksLikeFile, a as resumeCommand, c as driveRun, d as attachProgress, f as renderEventLine, g as loadWorkflowModule, h as loadCliConfig, i as inspectCommand, l as reportOutcome, m as assembleEngine, n as HELP, o as runCommand, p as DEFAULT_STORE_DIR, r as runCli, s as runsLsCommand, t as processIo, u as strictExitCode } from "./io-DK9Ji_H2.js";
2
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
  /**
@@ -775,8 +775,13 @@ function createWorker(engine, options) {
775
775
  * tracer)` maps the spanId tree of a run 1:1 onto OTel spans: one span
776
776
  * per rulvar span, parented per the span hierarchy (run > phase >
777
777
  * agent > tool > child), with start/end timestamps from the lifecycle
778
- * events. Events without an own span (log, budget:update) attach as span
779
- * events on their enclosing span.
778
+ * events; each agent:phase pair additionally becomes a child span of
779
+ * its agent span keyed (spanId, invocation), carrying the phase's role,
780
+ * model, usage, and cost. Events without an own span (log,
781
+ * budget:update) attach as span events on their enclosing span. An
782
+ * opener for an already-open span never duplicates it (replayed
783
+ * re-emissions mark the original; a pre-RV-207 stream's extra per-phase
784
+ * agent:start cannot leak the agent span unended).
780
785
  *
781
786
  * `@opentelemetry/api` ^1.9 is an OPTIONAL peer: the CLI has no OTel
782
787
  * dependency, and the exporter is typed against a minimal structural
@@ -799,11 +804,24 @@ function msOf(ts) {
799
804
  /** The OTel status codes (UNSET 0, OK 1, ERROR 2); inlined to avoid the peer. */
800
805
  const STATUS_OK = 1;
801
806
  const STATUS_ERROR = 2;
807
+ /**
808
+ * Usage, cost, and retry attributes on a closing span, defensively: the
809
+ * exporter tolerates foreign or truncated streams, so absent fields
810
+ * simply do not become attributes.
811
+ */
812
+ function setUsageAttributes(open, event, retryKey, retries) {
813
+ if (open === void 0) return;
814
+ if (typeof event.usage?.inputTokens === "number") open.span.setAttribute("gen_ai.usage.input_tokens", event.usage.inputTokens);
815
+ if (typeof event.usage?.outputTokens === "number") open.span.setAttribute("gen_ai.usage.output_tokens", event.usage.outputTokens);
816
+ if (typeof event.costUsd === "number") open.span.setAttribute("rulvar.cost_usd", event.costUsd);
817
+ if (typeof retries === "number") open.span.setAttribute(retryKey, retries);
818
+ }
802
819
  function spanName(event) {
803
820
  switch (event.type) {
804
821
  case "run:start": return `run ${event.workflow}`;
805
822
  case "phase:start": return `phase ${event.phase}`;
806
823
  case "agent:start": return `agent ${event.agentType || "(anon)"} ${event.role}`;
824
+ case "agent:phase:start": return `invocation ${event.role}`;
807
825
  case "tool:start": return `tool ${event.toolName}`;
808
826
  case "child:start": return `workflow ${event.workflow}`;
809
827
  default: return event.type;
@@ -822,6 +840,12 @@ function openAttributes(event, runId) {
822
840
  attrs["gen_ai.request.model"] = event.model;
823
841
  attrs["gen_ai.operation.name"] = event.role;
824
842
  }
843
+ if (event.type === "agent:phase:start") {
844
+ attrs["rulvar.agent_type"] = event.agentType;
845
+ attrs["gen_ai.request.model"] = event.model;
846
+ attrs["gen_ai.operation.name"] = event.role;
847
+ attrs["rulvar.invocation"] = event.invocation;
848
+ }
825
849
  if (event.type === "tool:start") attrs["rulvar.tool_name"] = event.toolName;
826
850
  for (const [key, value] of Object.entries(attrs)) if (typeof value === "string") attrs[key] = maskSecrets(value);
827
851
  return attrs;
@@ -838,12 +862,13 @@ async function toOtel(run, tracer, options = {}) {
838
862
  const stack = [];
839
863
  let created = 0;
840
864
  const { contextApi, setSpan } = options;
841
- const startSpan = (event) => {
842
- if (event.replayed === true && openBySpanId.has(event.spanId)) {
843
- openBySpanId.get(event.spanId)?.span.setAttribute("rulvar.replayed", true);
865
+ const startSpan = (event, key = event.spanId, parentKey) => {
866
+ const parentId = parentKey ?? event.parentSpanId;
867
+ if (openBySpanId.has(key)) {
868
+ if (event.replayed === true) openBySpanId.get(key)?.span.setAttribute("rulvar.replayed", true);
844
869
  return;
845
870
  }
846
- const parent = event.parentSpanId === void 0 ? void 0 : openBySpanId.get(event.parentSpanId);
871
+ const parent = parentId === void 0 ? void 0 : openBySpanId.get(parentId);
847
872
  const parentContext = parent !== void 0 && contextApi !== void 0 && setSpan !== void 0 ? setSpan(contextApi.active(), parent.span) : void 0;
848
873
  const span = tracer.startSpan(spanName(event), {
849
874
  startTime: msOf(event.ts),
@@ -852,10 +877,10 @@ async function toOtel(run, tracer, options = {}) {
852
877
  created += 1;
853
878
  const open = {
854
879
  span,
855
- spanId: event.spanId,
856
- ...event.parentSpanId === void 0 ? {} : { parentSpanId: event.parentSpanId }
880
+ spanId: key,
881
+ ...parentId === void 0 ? {} : { parentSpanId: parentId }
857
882
  };
858
- openBySpanId.set(event.spanId, open);
883
+ openBySpanId.set(key, open);
859
884
  stack.push(open);
860
885
  };
861
886
  const endSpan = (spanId, ts, status, message) => {
@@ -880,7 +905,17 @@ async function toOtel(run, tracer, options = {}) {
880
905
  case "run:end":
881
906
  endSpan(event.spanId, event.ts, event.status);
882
907
  break;
908
+ case "agent:phase:start":
909
+ startSpan(event, `${event.spanId}#${event.invocation}`, event.spanId);
910
+ break;
911
+ case "agent:phase:end": {
912
+ const key = `${event.spanId}#${event.invocation}`;
913
+ setUsageAttributes(openBySpanId.get(key), event, "rulvar.retries", event.retries);
914
+ endSpan(key, event.ts, event.outcome);
915
+ break;
916
+ }
883
917
  case "agent:end":
918
+ setUsageAttributes(openBySpanId.get(event.spanId), event, "rulvar.retry_count", event.retryCount);
884
919
  endSpan(event.spanId, event.ts, event.status);
885
920
  break;
886
921
  case "tool:end":
@@ -889,6 +924,15 @@ async function toOtel(run, tracer, options = {}) {
889
924
  case "child:end":
890
925
  endSpan(event.spanId, event.ts, event.status);
891
926
  break;
927
+ case "determinism:warning":
928
+ (openBySpanId.get(event.spanId) ?? stack[stack.length - 1])?.span.addEvent("determinism:warning", {
929
+ "rulvar.entry_seq": event.seq,
930
+ "rulvar.determinism.category": event.category,
931
+ "rulvar.determinism.provenance": event.provenance,
932
+ ...event.file === void 0 ? {} : { "code.filepath": maskSecrets(event.file) },
933
+ ...event.line === void 0 ? {} : { "code.lineno": event.line }
934
+ });
935
+ break;
892
936
  default: (openBySpanId.get(event.spanId) ?? stack[stack.length - 1])?.span.addEvent(event.type, { "rulvar.entry_seq": event.seq });
893
937
  }
894
938
  }
@@ -1,4 +1,4 @@
1
- import { ConfigError, FileModelKnowledgeStore, INBOX_PROPOSAL_TTL_DAYS, JsonlFileStore, LeaseHeldError, auditRuns, claimExpired, claimExpiry, compilePermissionPreset, costReportFromJournal, createEngine, hashRunArgs, parseModelRef, priceUsdOf, proposalStatement, readRunMeta, reconcileRunMeta, remeasureQueue, resolvePricing, runProfile, sanitizeTerminalText } from "@rulvar/core";
1
+ import { ConfigError, FileModelKnowledgeStore, INBOX_PROPOSAL_TTL_DAYS, JsonlFileStore, LeaseHeldError, auditRuns, claimExpired, claimExpiry, compilePermissionPreset, costReportFromJournal, createEngine, hashRunArgs, hashRunOutput, lastRunSettle, parseModelRef, priceUsdOf, proposalStatement, readRunMeta, reconcileRunMeta, 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";
@@ -197,12 +197,18 @@ function rawEventLine(event) {
197
197
  case "run:start": return `run ${str(event.runId)} ${event.resumed === true ? "resumed" : "started"} (workflow ${str(event.workflow)})`;
198
198
  case "phase:start": return `phase ${str(event.phase)}`;
199
199
  case "agent:start": return `${replayMark}agent ${who}${str(event.label) === "" ? "" : ` [${str(event.label)}]`} ${str(event.role)} on ${str(event.model)}`;
200
- case "agent:end": return `${replayMark}agent ${who} ${str(event.status)} (${money(num(event.costUsd))}, ${String(num(event.usage?.inputTokens) + num(event.usage?.outputTokens))} tok)${event.usageApprox === true ? " approx" : ""}`;
200
+ case "agent:phase:start": return `${replayMark}agent ${who} ${str(event.role)} phase on ${str(event.model)}`;
201
+ case "agent:phase:end": return `${replayMark}agent ${who} ${str(event.role)} phase ${str(event.outcome)} (${money(num(event.costUsd))}, ${String(num(event.usage?.inputTokens) + num(event.usage?.outputTokens))} tok, ${String(num(event.durationMs))}ms${num(event.retries) > 0 ? `, ${String(num(event.retries))} retries` : ""})`;
202
+ case "agent:end": return `${replayMark}agent ${who} ${str(event.status)} (${money(num(event.costUsd))}, ${String(num(event.usage?.inputTokens) + num(event.usage?.outputTokens))} tok${num(event.retryCount) > 0 ? `, ${String(num(event.retryCount))} retries` : ""})${event.usageApprox === true ? " approx" : ""}`;
201
203
  case "agent:error": return `agent ${who} error: ${str(event.error?.message)}${event.willRetry === true ? " (will retry)" : ""}`;
202
204
  case "agent:queued": return `agent ${who} queued`;
203
205
  case "tool:start": return `${replayMark}tool ${str(event.toolName)}`;
204
206
  case "tool:end": return `${replayMark}tool ${str(event.toolName)} ${str(event.outcome)} (${String(num(event.durationMs))}ms)`;
205
207
  case "approval:pending": return `approval pending: tool ${str(event.toolName)} (entry ${String(num(event.entryRef))})`;
208
+ case "determinism:warning": {
209
+ const where = str(event.file) === "" ? str(event.frame) : `at ${str(event.file)}:${String(num(event.line))}:${String(num(event.column))}`;
210
+ return `determinism ${str(event.category)} (${str(event.provenance)}) ${where}`;
211
+ }
206
212
  case "log": return event.level === "debug" ? void 0 : `${str(event.level)}: ${str(event.msg)}`;
207
213
  case "run:end": return `run ${str(event.runId)} ${str(event.status)}${event.usageApprox === true ? " (cost approx)" : ""}`;
208
214
  default: return;
@@ -219,12 +225,15 @@ function attachProgress(handle, io) {
219
225
  "run:start",
220
226
  "phase:start",
221
227
  "agent:start",
228
+ "agent:phase:start",
229
+ "agent:phase:end",
222
230
  "agent:end",
223
231
  "agent:error",
224
232
  "agent:queued",
225
233
  "tool:start",
226
234
  "tool:end",
227
235
  "approval:pending",
236
+ "determinism:warning",
228
237
  "log",
229
238
  "run:end"
230
239
  ]) detachers.push(handle.on(type, forward));
@@ -437,6 +446,16 @@ const GRAMMAR = {
437
446
  { name: "strict" }
438
447
  ]
439
448
  },
449
+ replay: {
450
+ command: "replay",
451
+ positionals: ["<runId>"],
452
+ flags: [
453
+ ARGS,
454
+ STORE,
455
+ { name: "assert-no-live" },
456
+ { name: "compare-output-hash" }
457
+ ]
458
+ },
440
459
  "runs ls": {
441
460
  command: "runs ls",
442
461
  positionals: [],
@@ -560,6 +579,7 @@ function helpCommandLines() {
560
579
  const top = [
561
580
  GRAMMAR.run,
562
581
  GRAMMAR.resume,
582
+ GRAMMAR.replay,
563
583
  GRAMMAR["runs ls"],
564
584
  GRAMMAR["runs audit"],
565
585
  GRAMMAR.inspect,
@@ -863,6 +883,92 @@ async function resumeCommand(argv, context) {
863
883
  return parsed.values.strict === true ? strictExitCode(outcome, base, context.io) : base;
864
884
  }
865
885
  /**
886
+ * `rulvar replay` (RV-209): replay-strict verification of a recorded
887
+ * run. Resumes under the engine's dry-run mode (zero journal or meta
888
+ * writes, zero adapter calls; the first would-be-live call is a typed
889
+ * JournalMissError settle), then reports the replay accounting, every
890
+ * determinism warning the re-executed body raised (with its localized
891
+ * frame), and the output digest comparison against the journaled
892
+ * settle. `--assert-no-live` exits nonzero unless the replay was pure
893
+ * (zero misses, zero reruns); `--compare-output-hash` exits nonzero
894
+ * unless the replayed result's JCS sha256 equals the recorded
895
+ * `outputHash`. Without flags the command reports and exits 0, so it
896
+ * can sit in a pipeline as a diagnostic before it gates anything.
897
+ * Args follow the resume binding exactly, but there is no
898
+ * --allow-args-change here: changed args change the logical run, and a
899
+ * verification against a different logical run proves nothing.
900
+ */
901
+ async function replayCommand(argv, context) {
902
+ const parsed = parseCommand(GRAMMAR.replay, argv);
903
+ const runId = parsed.positionals[0];
904
+ const rawArgs = parsed.values.args;
905
+ const args = parseArgsJson(rawArgs);
906
+ const argsGiven = rawArgs !== void 0;
907
+ const assertNoLive = parsed.values["assert-no-live"] === true;
908
+ const compareOutputHash = parsed.values["compare-output-hash"] === true;
909
+ const store = parsed.values.store;
910
+ const assembled = assembleEngine({
911
+ config: await loadCliConfig(context.cwd),
912
+ ...store === void 0 ? {} : { storePath: store },
913
+ cwd: context.cwd
914
+ });
915
+ const meta = await readRunMeta(assembled.store, runId);
916
+ if (meta === void 0) throw new ConfigError(`run '${runId}' not found in the store`);
917
+ enforceArgsBinding({
918
+ meta,
919
+ argsGiven,
920
+ args,
921
+ allowChange: false,
922
+ io: context.io
923
+ });
924
+ const name = meta.workflowName;
925
+ const workflow = name === void 0 ? void 0 : assembled.workflows[name];
926
+ if (workflow === void 0) throw new ConfigError(`run '${runId}' was started from workflow '${name ?? "(unknown)"}'; register it under that name in rulvar.config.mjs workflows to replay (replay requires the in-process workflow value)`);
927
+ const handle = assembled.engine.resume(runId, workflow, {
928
+ args,
929
+ dryRun: true
930
+ });
931
+ const warnings = [];
932
+ const consumer = (async () => {
933
+ for await (const event of handle.events) if (event.type === "determinism:warning") warnings.push(event);
934
+ })().catch(() => void 0);
935
+ const outcome = await handle.result;
936
+ const preview = await handle.preview;
937
+ await consumer;
938
+ const recorded = lastRunSettle(await assembled.store.load(runId));
939
+ const io = context.io;
940
+ io.err(`replay of '${sanitizeTerminalText(runId)}' (zero journal or meta writes, zero adapter calls):`);
941
+ io.err(` hits: ${preview.hits} misses: ${preview.misses} reruns: ${preview.reruns} skipped: ${preview.skipped}`);
942
+ io.err(recorded === void 0 ? " recorded settle: none (journal predates the settle entry)" : ` recorded settle: ${recorded.runStatus}`);
943
+ io.err(` replayed settle: ${outcome.status}`);
944
+ if (outcome.error !== void 0 && outcome.error.code !== "journal_miss") io.err(` error: ${sanitizeTerminalText(outcome.error.message)}`);
945
+ for (const warning of warnings) {
946
+ const where = warning.file === void 0 ? warning.frame : `at ${warning.file}:${warning.line ?? "?"}:${warning.column ?? "?"}`;
947
+ io.err(` determinism: ${warning.category} (${warning.provenance}) ${sanitizeTerminalText(where)}`);
948
+ }
949
+ let exit = 0;
950
+ if (assertNoLive) if (preview.misses === 0 && preview.reruns === 0 && outcome.error?.code !== "journal_miss") io.err(" assert-no-live: PASS (pure replay, zero would-be-live calls)");
951
+ else {
952
+ io.err(` assert-no-live: FAIL (misses ${preview.misses}, reruns ${preview.reruns}: a real resume would perform new paid work)`);
953
+ exit = 1;
954
+ }
955
+ if (compareOutputHash) {
956
+ const replayedHash = hashRunOutput(outcome.value);
957
+ if (recorded?.outputHash === void 0) {
958
+ io.err(" compare-output-hash: FAIL (the recorded settle carries no output hash: the run predates it, settled without a value, or the value is not JCS-serializable)");
959
+ exit = 1;
960
+ } else if (replayedHash === void 0) {
961
+ io.err(" compare-output-hash: FAIL (the replayed run produced no hashable value)");
962
+ exit = 1;
963
+ } else if (replayedHash === recorded.outputHash) io.err(` compare-output-hash: PASS (${replayedHash.slice(0, 12)})`);
964
+ else {
965
+ io.err(` compare-output-hash: FAIL (recorded ${recorded.outputHash.slice(0, 12)}, replayed ${replayedHash.slice(0, 12)}: the workflow does not reproduce its output)`);
966
+ exit = 1;
967
+ }
968
+ }
969
+ return exit;
970
+ }
971
+ /**
866
972
  * The stranded run probe and reconciler (fenced run state RFC, phase
867
973
  * 3): audits every run the catalog lists against its journal, prints
868
974
  * the divergences worker sweeps can never see, and with --repair
@@ -1430,6 +1536,12 @@ runs audit compares every run's meta row against its journal and names
1430
1536
  the divergences worker sweeps cannot see (a stranded run's terminal
1431
1537
  meta over live journal work); --repair rewrites the sound ones from
1432
1538
  the journal under a brief lease, exit 1 while any divergence remains.
1539
+ replay verifies a recorded run without paying: a dry-run resume (zero
1540
+ journal or meta writes, zero adapter calls) that reports replay
1541
+ accounting, localized determinism warnings, and the output digest;
1542
+ --assert-no-live exits 1 unless the replay is pure, and
1543
+ --compare-output-hash exits 1 unless the replayed result's digest
1544
+ equals the journaled one.
1433
1545
  plan asks the planner model (role plan) to write a workflow script,
1434
1546
  lints and self-repairs it, then runs it in the worker sandbox; --dry-run
1435
1547
  prints the accepted script without running. Both stages are paid runs
@@ -1458,6 +1570,7 @@ async function runCli(argv, options) {
1458
1570
  switch (command) {
1459
1571
  case "run": return await runCommand(rest, context);
1460
1572
  case "resume": return await resumeCommand(rest, context);
1573
+ case "replay": return await replayCommand(rest, context);
1461
1574
  case "runs": {
1462
1575
  const [sub, ...subRest] = rest;
1463
1576
  if (sub === "ls") return await runsLsCommand(subRest, context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/cli",
3
- "version": "1.48.0",
3
+ "version": "1.50.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.48.0"
25
+ "@rulvar/core": "1.50.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/testing": "1.48.0",
32
- "@rulvar/store-sqlite": "1.48.0",
33
- "@rulvar/planner": "1.48.0",
34
- "@rulvar/evals": "1.48.0",
35
- "@rulvar/plan": "1.48.0"
31
+ "@rulvar/testing": "1.50.0",
32
+ "@rulvar/store-sqlite": "1.50.0",
33
+ "@rulvar/planner": "1.50.0",
34
+ "@rulvar/plan": "1.50.0",
35
+ "@rulvar/evals": "1.50.0"
36
36
  },
37
37
  "bin": {
38
38
  "rulvar": "./dist/cli.js"