@rulvar/cli 1.26.0 → 1.28.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-C3T5nbNg.js";
2
+ import { r as runCli, t as processIo } from "./io-Dubf-yvE.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
@@ -188,6 +188,8 @@ interface CreateServerOptions {
188
188
  * settled tracked runs beyond the cap are released exactly like a
189
189
  * `memoryRetention` verdict (durable state untouched). Live runs are
190
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.
191
193
  */
192
194
  maxTrackedRuns?: number;
193
195
  /**
@@ -198,10 +200,33 @@ interface CreateServerOptions {
198
200
  * cursor carries `x-rulvar-events-dropped: <count>` and a leading SSE
199
201
  * comment naming the first retained seq; the journal remains the
200
202
  * durable record of the run itself. Absent means unbounded (the
201
- * historical behavior).
203
+ * historical behavior). Validated at construction: a positive safe
204
+ * integer, anything else is a typed ConfigError.
202
205
  */
203
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;
204
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;
205
230
  interface RulvarServer {
206
231
  fetch(req: Request): Promise<Response>;
207
232
  }
@@ -315,4 +340,4 @@ declare function toOtel(run: {
315
340
  result: Promise<RunOutcome<unknown>>;
316
341
  }, tracer: TracerLike, options?: ToOtelOptions): Promise<number>;
317
342
  //#endregion
318
- 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,4 +1,4 @@
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";
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-Dubf-yvE.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
  /**
@@ -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,8 +85,22 @@ 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();
80
106
  /**
@@ -107,32 +133,35 @@ function createServer(options) {
107
133
  function attach(run, handle) {
108
134
  run.handle = handle;
109
135
  run.outcome = void 0;
110
- (async () => {
136
+ let pumpFailed = false;
137
+ const pump = (async () => {
111
138
  for await (const event of handle.events) {
112
139
  pushBuffered(run, event);
113
140
  for (const feed of [...run.feeds]) feed(event);
114
141
  }
115
- })().catch(() => void 0);
116
- handle.result.then((outcome) => {
142
+ })().catch(() => {
143
+ pumpFailed = true;
144
+ });
145
+ handle.result.then(async (outcome) => {
117
146
  run.outcome = outcome;
118
- if (outcome.status !== "suspended") {
119
- run.done = true;
120
- for (const feed of [...run.feeds]) feed(null);
121
- run.feeds.clear();
122
- (async () => {
123
- const meta = options.retention === void 0 && options.memoryRetention === void 0 ? void 0 : await metaOf(run.runId);
124
- if (meta !== void 0 && options.retention?.(meta) === true) {
125
- await engine.deleteRun(run.runId);
126
- runs.delete(run.runId);
127
- return;
128
- }
129
- if (meta !== void 0 && options.memoryRetention?.(meta) === true) {
130
- runs.delete(run.runId);
131
- return;
132
- }
133
- enforceTrackedCap();
134
- })().catch(() => void 0);
135
- }
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);
136
165
  }).catch(() => void 0);
137
166
  }
138
167
  function track(runId, workflowName, args, handle) {
@@ -254,6 +283,13 @@ function createServer(options) {
254
283
  const lastEventId = req.headers.get("last-event-id");
255
284
  const encoder = new TextEncoder();
256
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
+ `;
257
293
  let cursor;
258
294
  if (lastEventId !== null) {
259
295
  const parsed = Number(lastEventId);
@@ -282,14 +318,33 @@ function createServer(options) {
282
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)
283
319
 
284
320
  `));
285
- for (let i = startIndex; i < run.buffer.length; i += 1) controller.enqueue(encoder.encode(sseFrame(run.buffer[i])));
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;
328
+ }
329
+ controller.enqueue(encoder.encode(sseFrame(run.buffer[i])));
330
+ }
286
331
  if (run.done) {
287
332
  controller.close();
288
333
  return;
289
334
  }
290
- feed = (event) => {
335
+ feed = (event, note) => {
291
336
  if (event === null) {
292
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));
293
348
  controller.close();
294
349
  } catch {}
295
350
  return;
@@ -312,7 +367,7 @@ function createServer(options) {
312
367
  /** The tracked path: live (or settled-suspended) in this process. */
313
368
  async function resolveTracked(run, key, value) {
314
369
  const section = run.queue.then(async () => {
315
- if (run.done) return json(409, { error: {
370
+ if (run.done || run.outcome !== void 0 && run.outcome.status !== "suspended") return json(409, { error: {
316
371
  code: "config",
317
372
  message: `run '${run.runId}' already settled '${run.outcome?.status ?? "unknown"}'`
318
373
  } });
@@ -835,4 +890,4 @@ async function toOtel(run, tracer, options = {}) {
835
890
  return created;
836
891
  }
837
892
  //#endregion
838
- 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 };
@@ -556,11 +556,26 @@ function isParseArgsError(error) {
556
556
  return typeof code === "string" && code.startsWith("ERR_PARSE_ARGS");
557
557
  }
558
558
  /**
559
+ * A strictly numeric negative token, e.g. -1, -0.5, -.5, or -1e400.
560
+ * Anything else that starts with a dash (another flag, -Infinity, -NaN)
561
+ * stays option shaped for parseArgs, so unknown option and missing
562
+ * value diagnostics are unchanged.
563
+ */
564
+ const NEGATIVE_NUMBER = /^-(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?$/;
565
+ /**
559
566
  * Parses argv against one grammar entry. Everything the grammar does
560
567
  * not name is an error: unknown options (raised by parseArgs), value
561
568
  * flags given twice, both members of an exclusive group, and any
562
569
  * positional beyond the exact arity. Every rejection names the
563
570
  * canonical usage and happens before configs, stores, or adapters load.
571
+ *
572
+ * For numeric flags (placeholder 'N'), a spaced negative value such as
573
+ * `--budget-usd -1` is folded to the equals form before parseArgs, which
574
+ * would otherwise classify the bare `-1` as an option like token and
575
+ * raise its generic ambiguity error. The fold keeps the documented
576
+ * spaced syntax on the canonical parseBudgetValue diagnostic
577
+ * (v1.27.0 review P3); it applies only when the next token is a strictly
578
+ * numeric negative, so every other rejection is untouched.
564
579
  */
565
580
  function parseCommand(grammar, argv) {
566
581
  const options = {};
@@ -568,10 +583,22 @@ function parseCommand(grammar, argv) {
568
583
  type: flag.placeholder === void 0 ? "boolean" : "string",
569
584
  multiple: true
570
585
  };
586
+ const numericFlags = new Set(grammar.flags.filter((flag) => flag.placeholder === "N").map((flag) => `--${flag.name}`));
587
+ const args = [];
588
+ for (let index = 0; index < argv.length; index += 1) {
589
+ const token = argv[index];
590
+ const next = argv[index + 1];
591
+ if (numericFlags.has(token) && next !== void 0 && NEGATIVE_NUMBER.test(next)) {
592
+ args.push(`${token}=${next}`);
593
+ index += 1;
594
+ continue;
595
+ }
596
+ args.push(token);
597
+ }
571
598
  let raw;
572
599
  try {
573
600
  raw = parseArgs({
574
- args: argv,
601
+ args,
575
602
  allowPositionals: true,
576
603
  options
577
604
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/cli",
3
- "version": "1.26.0",
3
+ "version": "1.28.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.26.0"
25
+ "@rulvar/core": "1.28.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/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"
31
+ "@rulvar/testing": "1.28.0",
32
+ "@rulvar/planner": "1.28.0",
33
+ "@rulvar/plan": "1.28.0",
34
+ "@rulvar/store-sqlite": "1.28.0",
35
+ "@rulvar/evals": "1.28.0"
36
36
  },
37
37
  "bin": {
38
38
  "rulvar": "./dist/cli.js"