@rulvar/cli 1.26.0 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +27 -2
- package/dist/index.js +81 -26
- package/package.json +7 -7
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
|
@@ -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
|
-
|
|
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(() =>
|
|
116
|
-
|
|
142
|
+
})().catch(() => {
|
|
143
|
+
pumpFailed = true;
|
|
144
|
+
});
|
|
145
|
+
handle.result.then(async (outcome) => {
|
|
117
146
|
run.outcome = outcome;
|
|
118
|
-
if (outcome.status
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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)
|
|
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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulvar/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"description": "Rulvar shell: run/resume/runs/inspect/plan/kb commands, TUI progress, createServer, createWorker, OTel exporter.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -22,17 +22,17 @@
|
|
|
22
22
|
"access": "public"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@rulvar/core": "1.
|
|
25
|
+
"@rulvar/core": "1.27.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^22.20.0",
|
|
29
29
|
"tsdown": "^0.22.3",
|
|
30
30
|
"typescript": "~6.0.3",
|
|
31
|
-
"@rulvar/
|
|
32
|
-
"@rulvar/
|
|
33
|
-
"@rulvar/
|
|
34
|
-
"@rulvar/
|
|
35
|
-
"@rulvar/
|
|
31
|
+
"@rulvar/store-sqlite": "1.27.0",
|
|
32
|
+
"@rulvar/planner": "1.27.0",
|
|
33
|
+
"@rulvar/testing": "1.27.0",
|
|
34
|
+
"@rulvar/plan": "1.27.0",
|
|
35
|
+
"@rulvar/evals": "1.27.0"
|
|
36
36
|
},
|
|
37
37
|
"bin": {
|
|
38
38
|
"rulvar": "./dist/cli.js"
|