@rulvar/cli 1.24.1 → 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 +4 -2
- package/dist/index.d.ts +40 -3
- package/dist/index.js +136 -60
- package/dist/{io-CV_G44A2.js → io-C3T5nbNg.js} +48 -29
- package/package.json +7 -7
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { r as runCli, t as processIo } from "./io-
|
|
2
|
+
import { r as runCli, t as processIo } from "./io-C3T5nbNg.js";
|
|
3
|
+
import { sanitizeTerminalText } from "@rulvar/core";
|
|
3
4
|
import { inspect } from "node:util";
|
|
4
5
|
//#region src/cli.ts
|
|
5
6
|
/**
|
|
@@ -11,7 +12,8 @@ runCli(process.argv.slice(2), {
|
|
|
11
12
|
}).then((code) => {
|
|
12
13
|
process.exitCode = code;
|
|
13
14
|
}, (thrown) => {
|
|
14
|
-
|
|
15
|
+
const rendered = inspect(thrown).split("\n").map(sanitizeTerminalText).join("\n");
|
|
16
|
+
process.stderr.write(`${rendered}\n`);
|
|
15
17
|
process.exitCode = 1;
|
|
16
18
|
});
|
|
17
19
|
//#endregion
|
package/dist/index.d.ts
CHANGED
|
@@ -142,7 +142,13 @@ declare function driveRun(options: {
|
|
|
142
142
|
io: CliIo; /** Original run arguments: not journaled in v1, the host re-supplies them. */
|
|
143
143
|
args?: unknown;
|
|
144
144
|
}): Promise<RunOutcome<unknown>>;
|
|
145
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* Renders the settled outcome; returns the process exit code. Error
|
|
147
|
+
* messages, suspension keys, model refs, and phase names originate from
|
|
148
|
+
* providers, tools, and workflow authors, so each is sanitized before
|
|
149
|
+
* it reaches a terminal line, matching the TUI renderer (v1.24.1 review
|
|
150
|
+
* P2-1). Values print as JSON, which escapes control bytes on its own.
|
|
151
|
+
*/
|
|
146
152
|
declare function reportOutcome(outcome: RunOutcome<unknown>, io: CliIo): number;
|
|
147
153
|
//#endregion
|
|
148
154
|
//#region src/server.d.ts
|
|
@@ -158,12 +164,43 @@ interface CreateServerOptions {
|
|
|
158
164
|
*/
|
|
159
165
|
priceUsd?: (servedBy: ModelRef, usage: Usage) => number | undefined;
|
|
160
166
|
/**
|
|
161
|
-
* Opt-in retention (OQ-20 executed at M8-T04): evaluated
|
|
167
|
+
* Opt-in DURABLE retention (OQ-20 executed at M8-T04): evaluated
|
|
162
168
|
* when a tracked run settles terminally; a true verdict applies
|
|
163
169
|
* engine.deleteRun (transcript cascade, then the journal) and
|
|
164
|
-
* untracks the run.
|
|
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.
|
|
165
173
|
*/
|
|
166
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;
|
|
167
204
|
}
|
|
168
205
|
interface RulvarServer {
|
|
169
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-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
*
|
|
494
|
-
*
|
|
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,
|
|
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.
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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";
|
|
@@ -234,6 +234,13 @@ function attachProgress(handle, io) {
|
|
|
234
234
|
}
|
|
235
235
|
//#endregion
|
|
236
236
|
//#region src/drive.ts
|
|
237
|
+
/**
|
|
238
|
+
* The run/suspend/resolve/resume loop shared by `rulvar run` and
|
|
239
|
+
* `rulvar resume` (the CLI performs interactive resolution of suspended
|
|
240
|
+
* approvals and external inputs). Prompts read
|
|
241
|
+
* one line per pending suspension; EOF leaves the run suspended with a
|
|
242
|
+
* notice, never an error.
|
|
243
|
+
*/
|
|
237
244
|
const APPROVAL_PREFIX = "approval:";
|
|
238
245
|
/** Parses an approval answer; undefined = unusable input. */
|
|
239
246
|
function approvalDecision(answer) {
|
|
@@ -259,31 +266,32 @@ function approvalDecision(answer) {
|
|
|
259
266
|
async function resolvePending(handle, pending, io) {
|
|
260
267
|
let applied = 0;
|
|
261
268
|
for (const item of pending) {
|
|
269
|
+
const keyRef = sanitizeTerminalText(item.key);
|
|
262
270
|
if (item.key.startsWith(APPROVAL_PREFIX)) {
|
|
263
|
-
const answer = await io.prompt(`approve '${item.prompt ?? item.key}'? [allow/deny]`);
|
|
271
|
+
const answer = await io.prompt(`approve '${sanitizeTerminalText(item.prompt ?? item.key)}'? [allow/deny]`);
|
|
264
272
|
if (answer === void 0) return applied;
|
|
265
273
|
const decision = approvalDecision(answer);
|
|
266
274
|
if (decision === void 0) {
|
|
267
|
-
io.err(`unrecognized answer '${answer.trim()}'; leaving ${
|
|
275
|
+
io.err(`unrecognized answer '${sanitizeTerminalText(answer.trim())}'; leaving ${keyRef} suspended`);
|
|
268
276
|
continue;
|
|
269
277
|
}
|
|
270
278
|
const outcome = await handle.resolveExternal(item.key, decision);
|
|
271
|
-
io.err(`approval ${
|
|
279
|
+
io.err(`approval ${keyRef}: ${decision.decision} (${outcome.applied ? "applied" : sanitizeTerminalText(outcome.reason)})`);
|
|
272
280
|
if (outcome.applied) applied += 1;
|
|
273
281
|
continue;
|
|
274
282
|
}
|
|
275
|
-
const label = item.prompt === void 0 ?
|
|
283
|
+
const label = item.prompt === void 0 ? keyRef : `${keyRef} (${sanitizeTerminalText(item.prompt)})`;
|
|
276
284
|
const answer = await io.prompt(`value for external '${label}' as JSON:`);
|
|
277
285
|
if (answer === void 0) return applied;
|
|
278
286
|
let value;
|
|
279
287
|
try {
|
|
280
288
|
value = JSON.parse(answer);
|
|
281
289
|
} catch {
|
|
282
|
-
io.err(`not valid JSON; leaving '${
|
|
290
|
+
io.err(`not valid JSON; leaving '${keyRef}' suspended`);
|
|
283
291
|
continue;
|
|
284
292
|
}
|
|
285
293
|
const outcome = await handle.resolveExternal(item.key, value);
|
|
286
|
-
io.err(`external '${
|
|
294
|
+
io.err(`external '${keyRef}': ${outcome.applied ? "applied" : sanitizeTerminalText(outcome.reason)}`);
|
|
287
295
|
if (outcome.applied) applied += 1;
|
|
288
296
|
}
|
|
289
297
|
return applied;
|
|
@@ -320,29 +328,35 @@ async function reportDryRun(handle, io) {
|
|
|
320
328
|
io.err(` hits: ${preview.hits} misses: ${preview.misses} reruns: ${preview.reruns} skipped: ${preview.skipped}`);
|
|
321
329
|
io.err(preview.orphaned.length === 0 ? " orphaned effect roots: none" : ` orphaned effect roots (entryRefs): ${preview.orphaned.join(", ")}`);
|
|
322
330
|
if (preview.invalidResolutions.length === 0) io.err(" invalid resolutions: none");
|
|
323
|
-
else for (const invalid of preview.invalidResolutions) io.err(` invalid resolution at seq ${invalid.seq}: ${invalid.detail}`);
|
|
331
|
+
else for (const invalid of preview.invalidResolutions) io.err(` invalid resolution at seq ${invalid.seq}: ${sanitizeTerminalText(invalid.detail)}`);
|
|
324
332
|
if (outcome.error?.code === "journal_miss") {
|
|
325
|
-
io.err(` stopped at the first would-be-live call: ${outcome.error.message}`);
|
|
333
|
+
io.err(` stopped at the first would-be-live call: ${sanitizeTerminalText(outcome.error.message)}`);
|
|
326
334
|
io.err(" a real resume would perform new paid work from this point");
|
|
327
335
|
return 0;
|
|
328
336
|
}
|
|
329
337
|
io.err(` would settle: ${outcome.status}`);
|
|
330
|
-
if (outcome.error !== void 0) io.err(` error: ${outcome.error.message}`);
|
|
331
|
-
for (const pending of outcome.pending) io.err(` pending: ${pending.key} (entry ${pending.entryRef})`);
|
|
338
|
+
if (outcome.error !== void 0) io.err(` error: ${sanitizeTerminalText(outcome.error.message)}`);
|
|
339
|
+
for (const pending of outcome.pending) io.err(` pending: ${sanitizeTerminalText(pending.key)} (entry ${pending.entryRef})`);
|
|
332
340
|
if (outcome.value !== void 0) io.out(JSON.stringify(outcome.value, null, 2));
|
|
333
341
|
return 0;
|
|
334
342
|
}
|
|
335
|
-
/**
|
|
343
|
+
/**
|
|
344
|
+
* Renders the settled outcome; returns the process exit code. Error
|
|
345
|
+
* messages, suspension keys, model refs, and phase names originate from
|
|
346
|
+
* providers, tools, and workflow authors, so each is sanitized before
|
|
347
|
+
* it reaches a terminal line, matching the TUI renderer (v1.24.1 review
|
|
348
|
+
* P2-1). Values print as JSON, which escapes control bytes on its own.
|
|
349
|
+
*/
|
|
336
350
|
function reportOutcome(outcome, io) {
|
|
337
351
|
io.err(`status: ${outcome.status}`);
|
|
338
352
|
if (outcome.value !== void 0) io.out(JSON.stringify(outcome.value, null, 2));
|
|
339
|
-
if (outcome.error !== void 0) io.err(`error: ${outcome.error.message}`);
|
|
353
|
+
if (outcome.error !== void 0) io.err(`error: ${sanitizeTerminalText(outcome.error.message)}`);
|
|
340
354
|
if (outcome.dropped.length > 0) io.err(`dropped: ${outcome.dropped.length} item(s)`);
|
|
341
|
-
for (const pending of outcome.pending) io.err(`pending: ${pending.key} (entry ${pending.entryRef})`);
|
|
355
|
+
for (const pending of outcome.pending) io.err(`pending: ${sanitizeTerminalText(pending.key)} (entry ${pending.entryRef})`);
|
|
342
356
|
io.err(`cost: $${outcome.cost.totalUsd.toFixed(4)}`);
|
|
343
|
-
for (const [model, usd] of Object.entries(outcome.cost.byModel)) io.err(` by model ${model}: $${usd.toFixed(4)}`);
|
|
344
|
-
for (const [phase, usd] of Object.entries(outcome.cost.byPhase)) if (phase !== "") io.err(` by phase ${phase}: $${usd.toFixed(4)}`);
|
|
345
|
-
if (outcome.cost.unpriced.length > 0) io.err(`unpriced models: ${outcome.cost.unpriced.map((u) => u.model).join(", ")}`);
|
|
357
|
+
for (const [model, usd] of Object.entries(outcome.cost.byModel)) io.err(` by model ${sanitizeTerminalText(model)}: $${usd.toFixed(4)}`);
|
|
358
|
+
for (const [phase, usd] of Object.entries(outcome.cost.byPhase)) if (phase !== "") io.err(` by phase ${sanitizeTerminalText(phase)}: $${usd.toFixed(4)}`);
|
|
359
|
+
if (outcome.cost.unpriced.length > 0) io.err(`unpriced models: ${outcome.cost.unpriced.map((u) => sanitizeTerminalText(u.model)).join(", ")}`);
|
|
346
360
|
switch (outcome.status) {
|
|
347
361
|
case "ok":
|
|
348
362
|
case "suspended": return 0;
|
|
@@ -655,6 +669,10 @@ async function loadCompanion(loading, specifier, command, missingMessage) {
|
|
|
655
669
|
* later. In-process hosts keep the wider engine contract (functions,
|
|
656
670
|
* BigInt, cycles record presence without a hash); the CLI does not need
|
|
657
671
|
* it.
|
|
672
|
+
*
|
|
673
|
+
* Diagnostics name the failure class and the way out but never echo the
|
|
674
|
+
* value: workflow args may carry private data, and stderr routinely
|
|
675
|
+
* lands in CI logs (v1.24.1 review P2-1).
|
|
658
676
|
*/
|
|
659
677
|
function parseArgsJson(raw) {
|
|
660
678
|
if (raw === void 0) return;
|
|
@@ -662,12 +680,12 @@ function parseArgsJson(raw) {
|
|
|
662
680
|
try {
|
|
663
681
|
parsed = JSON.parse(raw);
|
|
664
682
|
} catch {
|
|
665
|
-
throw new ConfigError(
|
|
683
|
+
throw new ConfigError("--args is not valid JSON; check the JSON syntax and shell quoting (the value is withheld from diagnostics: workflow args may carry private data)");
|
|
666
684
|
}
|
|
667
685
|
try {
|
|
668
686
|
hashRunArgs(parsed);
|
|
669
687
|
} catch {
|
|
670
|
-
throw new ConfigError(
|
|
688
|
+
throw new ConfigError("--args is not representable as canonical JSON: a numeric value overflows JavaScript's finite range (e.g. 1e400 parses to Infinity). Supply finite JSON so the run's args binding can be hashed and later verified on resume (the value is withheld from diagnostics: workflow args may carry private data)");
|
|
671
689
|
}
|
|
672
690
|
return parsed;
|
|
673
691
|
}
|
|
@@ -711,20 +729,21 @@ async function runCommand(argv, context) {
|
|
|
711
729
|
*/
|
|
712
730
|
function enforceArgsBinding(input) {
|
|
713
731
|
const { meta, argsGiven, args, allowChange, io } = input;
|
|
732
|
+
const runRef = sanitizeTerminalText(meta.runId);
|
|
714
733
|
if (meta.argsProvided === void 0) {
|
|
715
734
|
if (!argsGiven && !allowChange) throw new ConfigError(`run '${meta.runId}' predates the args binding (rulvar < 1.24.0), so the CLI cannot tell whether it was started with --args, and resuming without them silently changes the logical run if any were used at start. Re-supply the original --args, or acknowledge explicitly with --allow-args-change; ${usageOf(GRAMMAR.resume)}`);
|
|
716
|
-
if (argsGiven) io.err(`warning: run '${
|
|
735
|
+
if (argsGiven) io.err(`warning: run '${runRef}' predates the args binding; the supplied --args cannot be verified against the original invocation`);
|
|
717
736
|
return;
|
|
718
737
|
}
|
|
719
738
|
if (meta.argsProvided) {
|
|
720
739
|
if (!argsGiven) {
|
|
721
740
|
if (!allowChange) throw new ConfigError(`run '${meta.runId}' was started WITH args, but this resume supplies none; the workflow would see undefined and every args-dependent call would become new paid work instead of a replay. Re-supply the original --args, or force the change with --allow-args-change; ${usageOf(GRAMMAR.resume)}`);
|
|
722
|
-
io.err(`warning: resuming '${
|
|
741
|
+
io.err(`warning: resuming '${runRef}' without its genesis args (--allow-args-change)`);
|
|
723
742
|
return;
|
|
724
743
|
}
|
|
725
744
|
if (meta.argsHash === void 0) {
|
|
726
745
|
if (!allowChange) throw new ConfigError(`run '${meta.runId}' started WITH args but recorded no verifiable hash (the genesis args were not JCS-serializable), so the CLI cannot confirm the supplied --args match the original; resuming risks silently changing the logical run and re-paying every args-dependent call. Force deliberately with --allow-args-change; ${usageOf(GRAMMAR.resume)}`);
|
|
727
|
-
io.err(`warning: run '${
|
|
746
|
+
io.err(`warning: run '${runRef}' recorded args presence but no hash (genesis args not JCS-serializable); the supplied --args cannot be verified (--allow-args-change)`);
|
|
728
747
|
return;
|
|
729
748
|
}
|
|
730
749
|
let supplied;
|
|
@@ -735,13 +754,13 @@ function enforceArgsBinding(input) {
|
|
|
735
754
|
}
|
|
736
755
|
if (supplied !== meta.argsHash) {
|
|
737
756
|
if (!allowChange) throw new ConfigError(`--args does not match the args run '${meta.runId}' was started with (recorded hash ${meta.argsHash.slice(0, 12)}, supplied ${supplied?.slice(0, 12) ?? "none"}); changed args silently change the logical run and re-pay every args-dependent call. Force deliberately with --allow-args-change; ${usageOf(GRAMMAR.resume)}`);
|
|
738
|
-
io.err(`warning: resuming '${
|
|
757
|
+
io.err(`warning: resuming '${runRef}' with changed args (--allow-args-change)`);
|
|
739
758
|
}
|
|
740
759
|
return;
|
|
741
760
|
}
|
|
742
761
|
if (argsGiven) {
|
|
743
762
|
if (!allowChange) throw new ConfigError(`run '${meta.runId}' was started WITHOUT args, but this resume supplies some; added args silently change the logical run. Drop --args, or force the change with --allow-args-change; ${usageOf(GRAMMAR.resume)}`);
|
|
744
|
-
io.err(`warning: resuming no-args run '${
|
|
763
|
+
io.err(`warning: resuming no-args run '${runRef}' with args (--allow-args-change)`);
|
|
745
764
|
}
|
|
746
765
|
}
|
|
747
766
|
async function resumeCommand(argv, context) {
|
|
@@ -758,7 +777,7 @@ async function resumeCommand(argv, context) {
|
|
|
758
777
|
...store === void 0 ? {} : { storePath: store },
|
|
759
778
|
cwd: context.cwd
|
|
760
779
|
});
|
|
761
|
-
const meta =
|
|
780
|
+
const meta = await readRunMeta(assembled.store, runId);
|
|
762
781
|
if (meta === void 0) throw new ConfigError(`run '${runId}' not found in the store`);
|
|
763
782
|
enforceArgsBinding({
|
|
764
783
|
meta,
|
|
@@ -810,7 +829,7 @@ async function inspectCommand(argv, context) {
|
|
|
810
829
|
...store === void 0 ? {} : { storePath: store },
|
|
811
830
|
cwd: context.cwd
|
|
812
831
|
});
|
|
813
|
-
const meta =
|
|
832
|
+
const meta = await readRunMeta(assembled.store, runId);
|
|
814
833
|
if (meta === void 0) throw new ConfigError(`run '${runId}' not found in the store`);
|
|
815
834
|
const entries = await assembled.store.load(runId);
|
|
816
835
|
context.io.out(`run ${meta.runId}: ${meta.status} (updated ${meta.updatedAt})`);
|
|
@@ -872,7 +891,7 @@ async function planCommand(argv, context) {
|
|
|
872
891
|
});
|
|
873
892
|
const planned = await plannerModule.plan(assembled.engine, goal, planningBudgetUsd === void 0 ? void 0 : { run: { budgetUsd: planningBudgetUsd } });
|
|
874
893
|
context.io.err(`plan: accepted with ${String(planned.lint.length)} advisory diagnostic(s)`);
|
|
875
|
-
for (const diagnostic of planned.lint) context.io.err(` ${diagnostic.ruleId}: ${diagnostic.message}`);
|
|
894
|
+
for (const diagnostic of planned.lint) context.io.err(` ${diagnostic.ruleId}: ${sanitizeTerminalText(diagnostic.message)}`);
|
|
876
895
|
if (dryRun) {
|
|
877
896
|
context.io.out(planned.source);
|
|
878
897
|
return 0;
|
|
@@ -1068,7 +1087,7 @@ async function kbGateCommand(argv, context) {
|
|
|
1068
1087
|
...values.store === void 0 ? {} : { storePath: values.store },
|
|
1069
1088
|
cwd: context.cwd
|
|
1070
1089
|
});
|
|
1071
|
-
const meta =
|
|
1090
|
+
const meta = await readRunMeta(assembled.store, runId);
|
|
1072
1091
|
if (meta === void 0) throw new ConfigError(`run '${runId}' not found in the store`);
|
|
1073
1092
|
if (meta.status === "running") throw new ConfigError(`run '${runId}' is still running; proposals gate from finished runs`);
|
|
1074
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`);
|
|
@@ -1339,7 +1358,7 @@ async function runCli(argv, options) {
|
|
|
1339
1358
|
}
|
|
1340
1359
|
} catch (thrown) {
|
|
1341
1360
|
if (thrown instanceof ConfigError) {
|
|
1342
|
-
options.io.err(`error: ${thrown.message}`);
|
|
1361
|
+
options.io.err(`error: ${sanitizeTerminalText(thrown.message)}`);
|
|
1343
1362
|
return 1;
|
|
1344
1363
|
}
|
|
1345
1364
|
throw thrown;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulvar/cli",
|
|
3
|
-
"version": "1.
|
|
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
|
+
"@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/
|
|
32
|
-
"@rulvar/testing": "1.
|
|
33
|
-
"@rulvar/planner": "1.
|
|
34
|
-
"@rulvar/
|
|
35
|
-
"@rulvar/
|
|
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"
|