@basein/runner 0.2.12 → 0.3.1
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/README.md +540 -369
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -21
- package/dist/bin/bir-scenario.js +593 -122
- package/dist/bin/bir.d.ts +12 -6
- package/dist/bin/bir.js +2935 -489
- package/dist/bin/investigate.js +40 -15
- package/dist/bin/scenario-edit.d.ts +7 -1
- package/dist/bin/scenario-edit.js +43 -21
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2787 -302
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +369 -20
- package/dist/replay/controller.js +1176 -107
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1304
- package/docs/calculatedReplayGuide.md +1212 -1087
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -311
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- package/scripts/install.sh +104 -7
package/dist/bin/investigate.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* declined and what mode armed; the service knows the baseline, the ledger and
|
|
18
18
|
* whether a run ever became a recording. Both lists are printed, worst first.
|
|
19
19
|
*/
|
|
20
|
+
import { ReadClock, jsonText, readWithin, serviceProblem, underMcp, unreachable } from "./service-read.js";
|
|
20
21
|
const num = (v) => {
|
|
21
22
|
if (v === undefined || v === null || v === "")
|
|
22
23
|
return undefined;
|
|
@@ -170,8 +171,8 @@ export const DECLINES = {
|
|
|
170
171
|
fix: "wait for the calculation, or start one with `bir scenario calc <runId>`, then run the prompt again.",
|
|
171
172
|
},
|
|
172
173
|
similarity: {
|
|
173
|
-
cause: "the prompt was a hit on the recording but below the steering threshold (
|
|
174
|
-
fix: "phrase the prompt closer to the recorded one, or lower
|
|
174
|
+
cause: "the prompt was a hit on the recording but below the steering threshold (BIR_MIN_STEER_SIMILARITY, default 0.92).",
|
|
175
|
+
fix: "phrase the prompt closer to the recorded one, or lower BIR_MIN_STEER_SIMILARITY.",
|
|
175
176
|
},
|
|
176
177
|
coverage: {
|
|
177
178
|
cause: "no step of the scenario could run anywhere: its tools are neither wrapped MCP servers registered in this session nor built-ins.",
|
|
@@ -435,16 +436,22 @@ export function renderService(out, s, viewerIsOwner) {
|
|
|
435
436
|
renderFindings(out, s.findings ?? [], "Service findings");
|
|
436
437
|
}
|
|
437
438
|
// ── the command ───────────────────────────────────────────────────────────────
|
|
438
|
-
async function fetchInvestigation(deps, id, limit) {
|
|
439
|
-
const reply = await deps.service(`/investigate/${id}${limit ? `?limit=${limit}` : ""}`);
|
|
440
|
-
if ("error" in reply)
|
|
441
|
-
return { note: `service not consulted: ${reply.error}
|
|
439
|
+
async function fetchInvestigation(deps, id, limit, clock) {
|
|
440
|
+
const reply = await clock.within(deps.service(`/investigate/${id}${limit ? `?limit=${limit}` : ""}`));
|
|
441
|
+
if ("error" in reply) {
|
|
442
|
+
return { note: `service not consulted: ${serviceProblem(reply.error)}`, unreachable: unreachable(reply.error) };
|
|
443
|
+
}
|
|
442
444
|
if (reply.status === 404)
|
|
443
445
|
return { note: `the service has nothing for ${id} (not yours, or never reached it)` };
|
|
444
446
|
if (reply.status !== 200)
|
|
445
447
|
return { note: `service answered HTTP ${reply.status}` };
|
|
446
448
|
return { data: reply.body };
|
|
447
449
|
}
|
|
450
|
+
/** A turn without its raw journal lines, which only a person reading the whole record wants. */
|
|
451
|
+
function withoutEvents(turn) {
|
|
452
|
+
const { events, ...rest } = turn;
|
|
453
|
+
return { ...rest, eventCount: events.length };
|
|
454
|
+
}
|
|
448
455
|
/** Which id the service should be asked about for a turn. */
|
|
449
456
|
function serviceIdFor(turn) {
|
|
450
457
|
return (turn.reported?.ticket ??
|
|
@@ -463,9 +470,10 @@ export async function investigateCommand(args, deps) {
|
|
|
463
470
|
params.set("limit", String(args.limit));
|
|
464
471
|
if (args.user)
|
|
465
472
|
params.set("user", args.user);
|
|
466
|
-
const reply = await deps.service(`/investigate/executions${params.size ? `?${params}` : ""}`);
|
|
473
|
+
const reply = await readWithin(deps.service(`/investigate/executions${params.size ? `?${params}` : ""}`));
|
|
467
474
|
if ("error" in reply) {
|
|
468
|
-
|
|
475
|
+
const words = serviceProblem(reply.error);
|
|
476
|
+
out(`Could not reach the service: ${words}${words === reply.error ? "" : ` (${reply.error})`}`);
|
|
469
477
|
return 1;
|
|
470
478
|
}
|
|
471
479
|
if (reply.status === 403) {
|
|
@@ -482,7 +490,7 @@ export async function investigateCommand(args, deps) {
|
|
|
482
490
|
}
|
|
483
491
|
const body = reply.body;
|
|
484
492
|
if (args.json) {
|
|
485
|
-
out(
|
|
493
|
+
out(jsonText(body));
|
|
486
494
|
return 0;
|
|
487
495
|
}
|
|
488
496
|
const rows = body.executions ?? [];
|
|
@@ -503,7 +511,7 @@ export async function investigateCommand(args, deps) {
|
|
|
503
511
|
const limit = args.limit ?? 20;
|
|
504
512
|
const recent = turns.slice(-limit).reverse();
|
|
505
513
|
if (args.json) {
|
|
506
|
-
out(
|
|
514
|
+
out(jsonText(underMcp() ? recent.map(withoutEvents) : recent));
|
|
507
515
|
return 0;
|
|
508
516
|
}
|
|
509
517
|
if (recent.length === 0) {
|
|
@@ -550,14 +558,31 @@ export async function investigateCommand(args, deps) {
|
|
|
550
558
|
}
|
|
551
559
|
const findings = turn ? localFindings(turn) : [];
|
|
552
560
|
const serviceId = turn ? serviceIdFor(turn) : id;
|
|
553
|
-
|
|
561
|
+
// Both reads below share one bound, so a slow "no" followed by a held
|
|
562
|
+
// second try still ends inside the time the `bir` MCP server allows a
|
|
563
|
+
// reading tool — with the journal half, which needed no service at all.
|
|
564
|
+
const clock = new ReadClock();
|
|
565
|
+
const service = await fetchInvestigation(deps, serviceId, args.limit, clock);
|
|
554
566
|
// A matched turn's own run id is unknown to the service; when the scenario
|
|
555
|
-
// lookup fails, the matched run is still worth a try
|
|
556
|
-
|
|
557
|
-
|
|
567
|
+
// lookup fails, the matched run is still worth a try — unless the service
|
|
568
|
+
// could not be reached at all, when a second try only doubles the wait, or
|
|
569
|
+
// the bound is spent.
|
|
570
|
+
const fallbackId = turn &&
|
|
571
|
+
service.note &&
|
|
572
|
+
!service.unreachable &&
|
|
573
|
+
turn.matched?.runId &&
|
|
574
|
+
serviceId !== turn.matched.runId &&
|
|
575
|
+
clock.remaining() > 0
|
|
576
|
+
? turn.matched.runId
|
|
577
|
+
: undefined;
|
|
578
|
+
const second = fallbackId ? await fetchInvestigation(deps, fallbackId, args.limit, clock) : undefined;
|
|
558
579
|
const chosen = service.data ? service : second?.data ? second : service;
|
|
559
580
|
if (args.json) {
|
|
560
|
-
|
|
581
|
+
// For a model (the `bir` MCP server), the turn goes without its raw
|
|
582
|
+
// journal lines: every field they carry is already parsed into the turn,
|
|
583
|
+
// and a long turn's lines are most of the output.
|
|
584
|
+
const shown = turn ? (underMcp() ? withoutEvents(turn) : turn) : null;
|
|
585
|
+
out(jsonText({ turn: shown, findings, service: chosen.data ?? null, note: chosen.note ?? null }));
|
|
561
586
|
return 0;
|
|
562
587
|
}
|
|
563
588
|
if (turn) {
|
|
@@ -143,7 +143,13 @@ export interface ScenarioEditArgs {
|
|
|
143
143
|
}
|
|
144
144
|
/** The `bir scenario` words this module answers; `list` and `replay` stay in bir.ts. */
|
|
145
145
|
export declare const SCENARIO_EDIT_SUBCOMMANDS: ReadonlySet<string>;
|
|
146
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* The `bir` MCP server's tools that only read. Offered with scenario editing
|
|
148
|
+
* on, or where the server's environment has `BIR_SCENARIO_TOOLS=read` — no
|
|
149
|
+
* longer in every session: their definitions ride along with every request a
|
|
150
|
+
* session makes, and only somebody working on a scenario ever calls them (the
|
|
151
|
+
* same answers are `bir scenario show` and `bir investigate` in a terminal).
|
|
152
|
+
*/
|
|
147
153
|
export declare const READ_TOOLS: readonly ["scenario_show", "scenario_edits", "investigate"];
|
|
148
154
|
/** The ones that change a scenario: offered only after `bir scenario editing on` (D2). */
|
|
149
155
|
export declare const EDIT_TOOLS: readonly ["scenario_check", "scenario_edit", "scenario_undo"];
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import { normalizePath } from "../control/paths.js";
|
|
32
32
|
import { SCENARIO_SERVER_KEY, editingEnabled, projectRecord, readSidecar, setEditing, writeSidecar, } from "../config/generate.js";
|
|
33
33
|
import { errText } from "../util/log.js";
|
|
34
|
+
import { ReadClock, jsonText, readWithin, serviceProblem } from "./service-read.js";
|
|
34
35
|
/** The `bir scenario` words this module answers; `list` and `replay` stay in bir.ts. */
|
|
35
36
|
export const SCENARIO_EDIT_SUBCOMMANDS = new Set([
|
|
36
37
|
"show",
|
|
@@ -41,7 +42,13 @@ export const SCENARIO_EDIT_SUBCOMMANDS = new Set([
|
|
|
41
42
|
"calc",
|
|
42
43
|
"editing",
|
|
43
44
|
]);
|
|
44
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* The `bir` MCP server's tools that only read. Offered with scenario editing
|
|
47
|
+
* on, or where the server's environment has `BIR_SCENARIO_TOOLS=read` — no
|
|
48
|
+
* longer in every session: their definitions ride along with every request a
|
|
49
|
+
* session makes, and only somebody working on a scenario ever calls them (the
|
|
50
|
+
* same answers are `bir scenario show` and `bir investigate` in a terminal).
|
|
51
|
+
*/
|
|
45
52
|
export const READ_TOOLS = ["scenario_show", "scenario_edits", "investigate"];
|
|
46
53
|
/** The ones that change a scenario: offered only after `bir scenario editing on` (D2). */
|
|
47
54
|
export const EDIT_TOOLS = ["scenario_check", "scenario_edit", "scenario_undo"];
|
|
@@ -91,7 +98,7 @@ async function show(args, deps) {
|
|
|
91
98
|
if (!target)
|
|
92
99
|
return usageError(deps, "show");
|
|
93
100
|
const byScenario = target.startsWith("scn_");
|
|
94
|
-
const reply = await deps.service("GET", byScenario ? `/scenarios/${seg(target)}` : `/recordings/runs/${seg(target)}/scenario`);
|
|
101
|
+
const reply = await readWithin(deps.service("GET", byScenario ? `/scenarios/${seg(target)}` : `/recordings/runs/${seg(target)}/scenario`));
|
|
95
102
|
if ("error" in reply || reply.status !== 200) {
|
|
96
103
|
return explain(reply, args, deps, {
|
|
97
104
|
verb: "Could not read the scenario",
|
|
@@ -102,7 +109,7 @@ async function show(args, deps) {
|
|
|
102
109
|
});
|
|
103
110
|
}
|
|
104
111
|
if (args.step === undefined) {
|
|
105
|
-
out(
|
|
112
|
+
out(jsonText(reply.body));
|
|
106
113
|
return 0;
|
|
107
114
|
}
|
|
108
115
|
const served = reply.body;
|
|
@@ -119,7 +126,7 @@ async function show(args, deps) {
|
|
|
119
126
|
out(`${scenario.id ?? target} has no step ${args.step}${indexes.length ? ` (its steps are ${Math.min(...indexes)}–${Math.max(...indexes)})` : " (it has no steps)"}.`);
|
|
120
127
|
return 1;
|
|
121
128
|
}
|
|
122
|
-
out(
|
|
129
|
+
out(jsonText({ scenarioId: scenario.id ?? null, chainRevision: scenario.chainRevision ?? null, ...step }));
|
|
123
130
|
return 0;
|
|
124
131
|
}
|
|
125
132
|
/**
|
|
@@ -184,7 +191,7 @@ async function checkOrEdit(args, deps, save) {
|
|
|
184
191
|
return explain(reply, args, deps, { verb: "Not checked", ...explained });
|
|
185
192
|
const report = reply.body?.report;
|
|
186
193
|
if (args.json) {
|
|
187
|
-
out(
|
|
194
|
+
out(jsonText(reply.body));
|
|
188
195
|
return report?.ok || report?.noChange ? 0 : 1;
|
|
189
196
|
}
|
|
190
197
|
if (!report) {
|
|
@@ -225,7 +232,7 @@ async function checkOrEdit(args, deps, save) {
|
|
|
225
232
|
return explain(reply, args, deps, { verb: "Not saved", ...explained });
|
|
226
233
|
}
|
|
227
234
|
if (args.json) {
|
|
228
|
-
out(
|
|
235
|
+
out(jsonText(reply.body));
|
|
229
236
|
return refused ? 1 : 0;
|
|
230
237
|
}
|
|
231
238
|
if (answer?.report)
|
|
@@ -489,7 +496,7 @@ function refusal(out, report, opening, hint) {
|
|
|
489
496
|
}
|
|
490
497
|
// ── edits / undo ──────────────────────────────────────────────────────────────
|
|
491
498
|
async function fetchEdits(scenarioId, deps) {
|
|
492
|
-
const reply = await deps.service("GET", `/scenarios/${seg(scenarioId)}/edits`);
|
|
499
|
+
const reply = await readWithin(deps.service("GET", `/scenarios/${seg(scenarioId)}/edits`));
|
|
493
500
|
if ("error" in reply || reply.status !== 200)
|
|
494
501
|
return { reply };
|
|
495
502
|
const edits = reply.body?.edits;
|
|
@@ -500,15 +507,18 @@ async function listEdits(args, deps) {
|
|
|
500
507
|
const target = args.positionals[1];
|
|
501
508
|
if (!target)
|
|
502
509
|
return usageError(deps, "edits");
|
|
503
|
-
|
|
510
|
+
// A `run_` id is two reads — which scenario, then its history — inside one
|
|
511
|
+
// bound: the `bir` MCP server gives a reading tool one read's worth of time.
|
|
512
|
+
const clock = new ReadClock();
|
|
513
|
+
const resolved = await resolveTarget(target, args, deps, clock);
|
|
504
514
|
if ("code" in resolved)
|
|
505
515
|
return resolved.code;
|
|
506
|
-
const reply = await deps.service("GET", `/scenarios/${seg(resolved.id)}/edits`);
|
|
516
|
+
const reply = await clock.within(deps.service("GET", `/scenarios/${seg(resolved.id)}/edits`));
|
|
507
517
|
if ("error" in reply || reply.status !== 200) {
|
|
508
518
|
return explain(reply, args, deps, { verb: "Could not read the history", target });
|
|
509
519
|
}
|
|
510
520
|
if (args.json) {
|
|
511
|
-
out(
|
|
521
|
+
out(jsonText(reply.body));
|
|
512
522
|
return 0;
|
|
513
523
|
}
|
|
514
524
|
const edits = reply.body?.edits ?? [];
|
|
@@ -626,7 +636,7 @@ async function undo(args, deps) {
|
|
|
626
636
|
});
|
|
627
637
|
}
|
|
628
638
|
if (args.json) {
|
|
629
|
-
out(
|
|
639
|
+
out(jsonText(reply.body));
|
|
630
640
|
return refused ? 1 : 0;
|
|
631
641
|
}
|
|
632
642
|
if (answer?.report)
|
|
@@ -687,7 +697,7 @@ async function calc(args, deps) {
|
|
|
687
697
|
if ("error" in reply)
|
|
688
698
|
return explain(reply, args, deps, { verb: "Could not start calculation", target });
|
|
689
699
|
if (args.json) {
|
|
690
|
-
out(
|
|
700
|
+
out(jsonText(reply.body ?? null));
|
|
691
701
|
return reply.status === 202 ? 0 : 1;
|
|
692
702
|
}
|
|
693
703
|
const { status, body } = reply;
|
|
@@ -771,11 +781,19 @@ function describeEditing(out, sidecar, cwd) {
|
|
|
771
781
|
if (!hasScenarioServer(sidecar, cwd)) {
|
|
772
782
|
out(" (no `bir` MCP server is installed here, so no tool is offered at all — `bir install --replay` adds it)");
|
|
773
783
|
}
|
|
774
|
-
|
|
775
|
-
|
|
784
|
+
// The reading tools come with the switch too: they are for whoever is
|
|
785
|
+
// working on a scenario, and every other session would pay for their
|
|
786
|
+
// definitions on every request without ever calling them.
|
|
787
|
+
if (on) {
|
|
788
|
+
out(` the bir MCP server offers run_scenario, ${READ_TOOLS.join(", ")} (they read)`);
|
|
776
789
|
out(` and ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
|
|
777
|
-
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
out(" the bir MCP server offers run_scenario");
|
|
778
793
|
out(` \`bir scenario editing on\` adds ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
|
|
794
|
+
out(` and ${READ_TOOLS.join(", ")} (they read)`);
|
|
795
|
+
out(" (BIR_SCENARIO_TOOLS=read in the server's environment offers the reading ones alone)");
|
|
796
|
+
}
|
|
779
797
|
out(" The `bir scenario` commands work either way: this switch decides only the MCP tools.");
|
|
780
798
|
}
|
|
781
799
|
function hasScenarioServer(sidecar, cwd) {
|
|
@@ -796,7 +814,7 @@ function usageError(deps, sub, why) {
|
|
|
796
814
|
* `{error, details}` shape (with `extra` beside them when there is more to say).
|
|
797
815
|
*/
|
|
798
816
|
function localFailure(args, deps, code, text, extra = {}) {
|
|
799
|
-
deps.out(args.json ?
|
|
817
|
+
deps.out(args.json ? jsonText({ error: code, details: text, ...extra }) : text);
|
|
800
818
|
return 1;
|
|
801
819
|
}
|
|
802
820
|
/**
|
|
@@ -822,10 +840,10 @@ function noWholeRunScenario(target, segments, args, deps) {
|
|
|
822
840
|
* `scn_` id (D7). A `run_` id reaches only the run's **whole-run** scenario; a
|
|
823
841
|
* `scn_` id is used as it is — sub-task scenarios have nothing else.
|
|
824
842
|
*/
|
|
825
|
-
async function resolveTarget(target, args, deps) {
|
|
843
|
+
async function resolveTarget(target, args, deps, clock = new ReadClock()) {
|
|
826
844
|
if (target.startsWith("scn_"))
|
|
827
845
|
return { id: target };
|
|
828
|
-
const reply = await deps.service("GET", `/recordings/runs/${seg(target)}/scenario`);
|
|
846
|
+
const reply = await clock.within(deps.service("GET", `/recordings/runs/${seg(target)}/scenario`));
|
|
829
847
|
if ("error" in reply || reply.status !== 200) {
|
|
830
848
|
return {
|
|
831
849
|
code: explain(reply, args, deps, {
|
|
@@ -849,14 +867,18 @@ async function resolveTarget(target, args, deps) {
|
|
|
849
867
|
function explain(reply, args, deps, e) {
|
|
850
868
|
const { out } = deps;
|
|
851
869
|
if ("error" in reply) {
|
|
870
|
+
// In words, not the transport's ("fetch failed"): this is what reaches a
|
|
871
|
+
// model through the `bir` MCP server. A person at a terminal also gets the
|
|
872
|
+
// raw reason in brackets, for a bug report.
|
|
873
|
+
const words = serviceProblem(reply.error);
|
|
852
874
|
if (args.json)
|
|
853
|
-
out(
|
|
875
|
+
out(jsonText({ error: "service_unreachable", details: words }));
|
|
854
876
|
else
|
|
855
|
-
out(`Could not reach the service: ${reply.error}`);
|
|
877
|
+
out(`Could not reach the service: ${words}${words === reply.error ? "" : ` (${reply.error})`}`);
|
|
856
878
|
return 1;
|
|
857
879
|
}
|
|
858
880
|
if (args.json) {
|
|
859
|
-
out(
|
|
881
|
+
out(jsonText(reply.body ?? null));
|
|
860
882
|
return 1;
|
|
861
883
|
}
|
|
862
884
|
const body = (reply.body && typeof reply.body === "object" ? reply.body : {});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scenario-liveness — the two things `bir-scenario` does besides answering MCP:
|
|
3
|
+
* it tells its project's recorder that a delivery tool is live
|
|
4
|
+
* (`POST /scenario/hello`), and it brings back a recorder that died
|
|
5
|
+
* mid-session (`bir-hooks revive`).
|
|
6
|
+
*
|
|
7
|
+
* WHY HELLO. A direct plan tells the model to call `mcp__bir__run_scenario`
|
|
8
|
+
* before any other tool. The control server used to announce one whenever the
|
|
9
|
+
* MCP config listed the `bir` server — but a configured server is not a
|
|
10
|
+
* connected one: a stale node path, a crash, the server disabled in `/mcp`, or
|
|
11
|
+
* a session run with `BIR_OFF=1` (which lists no tools) all leave the model
|
|
12
|
+
* told to call a tool it does not have. So this server says, on `initialize`,
|
|
13
|
+
* on `tools/list`, when the off switch flips, whenever its recorder changes,
|
|
14
|
+
* and every {@link HELLO_EVERY_MS} while it runs: this pid, this version, this
|
|
15
|
+
* many tools, off or not. The control server announces a direct plan only on
|
|
16
|
+
* the word of a live pid that lists a tool.
|
|
17
|
+
*
|
|
18
|
+
* WHY REVIVE. Every hook but SessionStart is an HTTP post to the recorder.
|
|
19
|
+
* With the recorder gone — killed by an antivirus, out of memory — Claude Code
|
|
20
|
+
* shows a hook error on every prompt and tool call until the next session
|
|
21
|
+
* start, `/clear` or compaction, because nothing else starts one mid-session.
|
|
22
|
+
* This server is the one long-lived process in every session of an installed
|
|
23
|
+
* project, wrapped servers or none, so it starts `bir-hooks revive --cwd <dir>`
|
|
24
|
+
* (which runs the ordinary ensure, under the ordinary start lock).
|
|
25
|
+
*
|
|
26
|
+
* NEITHER MAY COST THE SESSION ANYTHING.
|
|
27
|
+
* - A hello is fire-and-forget: nothing on the MCP path awaits it, it is
|
|
28
|
+
* bounded by {@link HELLO_TIMEOUT_MS}, one is in flight at a time, and a
|
|
29
|
+
* recorder that cannot take it (not up yet, busy, an older version with no
|
|
30
|
+
* such route) costs a retry later, never an error.
|
|
31
|
+
* - A revive is a detached child that is never waited for, and it is started
|
|
32
|
+
* only when the discovery file names a pid that is DEAD and the recorder's
|
|
33
|
+
* address REFUSES the connection. A recorder that is alive but slow is never
|
|
34
|
+
* replaced (that is how duplicates that cannot bind were made). A missing
|
|
35
|
+
* discovery file is never revived either: `bir down` removes it, and a
|
|
36
|
+
* recorder somebody stopped on purpose stays stopped. Only an installed
|
|
37
|
+
* project is revived, at most once per {@link REVIVE_EVERY_MS} per process
|
|
38
|
+
* (after a random wait of up to {@link REVIVE_JITTER_MS}, so the several
|
|
39
|
+
* processes of a session that see the same death do not all start one),
|
|
40
|
+
* less often each time a revive did not bring a recorder back, and not in
|
|
41
|
+
* the server's first {@link REVIVE_GRACE_MS}, while the SessionStart hook
|
|
42
|
+
* is starting the recorder itself.
|
|
43
|
+
* - Neither writes a byte to stdout (the host's JSON-RPC stream), and
|
|
44
|
+
* neither throws.
|
|
45
|
+
*/
|
|
46
|
+
/** A hello waits no longer than this for the recorder's answer. */
|
|
47
|
+
export declare const HELLO_TIMEOUT_MS = 1000;
|
|
48
|
+
/** A hello is repeated this often while the server runs (the recorder trusts one for 30 min). */
|
|
49
|
+
export declare const HELLO_EVERY_MS: number;
|
|
50
|
+
/**
|
|
51
|
+
* How often the discovery file is looked at: whether the recorder changed (a
|
|
52
|
+
* hello is owed to the new one) or died (a revive may be). One `stat`, and a
|
|
53
|
+
* parse only when the file changed.
|
|
54
|
+
*/
|
|
55
|
+
export declare const WATCH_EVERY_MS = 2000;
|
|
56
|
+
/** At most one revive per this long per process; doubled each time one did not help. */
|
|
57
|
+
export declare const REVIVE_EVERY_MS = 30000;
|
|
58
|
+
/** The longest a revive waits, at random, before it looks again and starts. */
|
|
59
|
+
export declare const REVIVE_JITTER_MS = 5000;
|
|
60
|
+
/** The longest wait between revives that did not bring a recorder back. */
|
|
61
|
+
export declare const REVIVE_MAX_EVERY_MS: number;
|
|
62
|
+
/**
|
|
63
|
+
* No revive in a server's first this-long: it starts with the session, and so
|
|
64
|
+
* does the SessionStart hook, whose own ensure (up to 11 s, ensure-hook.ts) is
|
|
65
|
+
* what starts the recorder then — typically over a dead record left from
|
|
66
|
+
* before a reboot. Reviving beside it would only race it.
|
|
67
|
+
*/
|
|
68
|
+
export declare const REVIVE_GRACE_MS = 12000;
|
|
69
|
+
/** How long the "does anything listen there" probe waits before calling the recorder busy. */
|
|
70
|
+
export declare const REFUSED_PROBE_MS = 2000;
|
|
71
|
+
/** What a hello says. */
|
|
72
|
+
export interface HelloBody {
|
|
73
|
+
pid: number;
|
|
74
|
+
version: string;
|
|
75
|
+
/** How many tools this server lists now (0 when off). */
|
|
76
|
+
tools: number;
|
|
77
|
+
off: boolean;
|
|
78
|
+
}
|
|
79
|
+
/** A discovery record as written, its pid not yet checked. */
|
|
80
|
+
export interface RecorderRecord {
|
|
81
|
+
url: string;
|
|
82
|
+
token: string;
|
|
83
|
+
pid: number;
|
|
84
|
+
}
|
|
85
|
+
/** How a request to the recorder's address ended. */
|
|
86
|
+
export type Reach = "answered" | "refused" | "timeout" | "error";
|
|
87
|
+
/**
|
|
88
|
+
* This project's discovery record, whether or not its pid is alive — the
|
|
89
|
+
* shared reader drops a dead pid's record, and a dead pid is exactly what a
|
|
90
|
+
* revive needs to see. Parsed again only when the file changed. Never throws.
|
|
91
|
+
*/
|
|
92
|
+
export declare function readRecorderRecord(cwd: string): RecorderRecord | undefined;
|
|
93
|
+
/** Whether `cwd` is a project `bir setup`/`bir install` put the runner in. Never throws. */
|
|
94
|
+
export declare function projectInstalled(cwd: string): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Start `bir-hooks revive --cwd <cwd>` from this package, detached, and forget
|
|
97
|
+
* it: it reads nothing, writes nothing we could see, and exits by itself.
|
|
98
|
+
*/
|
|
99
|
+
export declare function launchRevive(cwd: string): Promise<void>;
|
|
100
|
+
export interface LivenessOptions {
|
|
101
|
+
/** The project this server runs for. */
|
|
102
|
+
cwd: string;
|
|
103
|
+
/** What this server lists now. Called for every hello. */
|
|
104
|
+
state: () => {
|
|
105
|
+
tools: number;
|
|
106
|
+
off: boolean;
|
|
107
|
+
};
|
|
108
|
+
now?: () => number;
|
|
109
|
+
random?: () => number;
|
|
110
|
+
fetch?: typeof fetch;
|
|
111
|
+
record?: (cwd: string) => RecorderRecord | undefined;
|
|
112
|
+
alive?: (pid: number) => boolean;
|
|
113
|
+
installed?: (cwd: string) => boolean;
|
|
114
|
+
launch?: (cwd: string) => Promise<void> | void;
|
|
115
|
+
sleep?: (ms: number) => Promise<void>;
|
|
116
|
+
env?: NodeJS.ProcessEnv;
|
|
117
|
+
/** Default {@link REVIVE_GRACE_MS}. */
|
|
118
|
+
reviveGraceMs?: number;
|
|
119
|
+
}
|
|
120
|
+
/** See the module comment. One per `bir-scenario` process. */
|
|
121
|
+
export declare class ScenarioLiveness {
|
|
122
|
+
private readonly opts;
|
|
123
|
+
private readonly cwd;
|
|
124
|
+
private readonly now;
|
|
125
|
+
private readonly random;
|
|
126
|
+
private readonly fetchImpl;
|
|
127
|
+
private readonly record;
|
|
128
|
+
private readonly alive;
|
|
129
|
+
private readonly installed;
|
|
130
|
+
private readonly launch;
|
|
131
|
+
private readonly sleep;
|
|
132
|
+
private readonly env;
|
|
133
|
+
private timer;
|
|
134
|
+
private stopped;
|
|
135
|
+
/** No revive before this (see {@link REVIVE_GRACE_MS}). */
|
|
136
|
+
private readonly reviveFrom;
|
|
137
|
+
private helloBusy;
|
|
138
|
+
/** A hello asked for while one was in flight: sent when it ends, with the state as it is then. */
|
|
139
|
+
private helloOwed;
|
|
140
|
+
/** The recorder the last hello was answered by (`url#pid`). */
|
|
141
|
+
private reached;
|
|
142
|
+
/** The recorder the last hello was sent to, answered or not. */
|
|
143
|
+
private tried;
|
|
144
|
+
private lastHelloAt;
|
|
145
|
+
private helloFailures;
|
|
146
|
+
private helloNotBefore;
|
|
147
|
+
private reviveBusy;
|
|
148
|
+
private reviveNotBefore;
|
|
149
|
+
/** Revives started since a recorder was last seen alive. */
|
|
150
|
+
private revivesInARow;
|
|
151
|
+
/** When this process last started one (0: never). */
|
|
152
|
+
private lastReviveAt;
|
|
153
|
+
/** Revives started by this process. */
|
|
154
|
+
revives: number;
|
|
155
|
+
/** Hellos a recorder answered. */
|
|
156
|
+
hellos: number;
|
|
157
|
+
constructor(opts: LivenessOptions);
|
|
158
|
+
/** Watch the discovery file every {@link WATCH_EVERY_MS}, on a timer that never keeps the process alive. */
|
|
159
|
+
start(everyMs?: number): void;
|
|
160
|
+
/** The host is gone: nothing more is sent or started. */
|
|
161
|
+
stop(): void;
|
|
162
|
+
/**
|
|
163
|
+
* Tell the recorder what this server lists, now — or, while a hello is in
|
|
164
|
+
* flight, as soon as it ends. Returns at once; never throws.
|
|
165
|
+
*/
|
|
166
|
+
hello(why: string): void;
|
|
167
|
+
/**
|
|
168
|
+
* Something on the MCP path found no recorder (`run_scenario` found no live
|
|
169
|
+
* discovery file, or its request was refused): look now rather than at the
|
|
170
|
+
* next tick. Returns at once; never throws.
|
|
171
|
+
*/
|
|
172
|
+
recorderGone(why: string): void;
|
|
173
|
+
/** One look at the discovery file. Exposed for tests; never throws. */
|
|
174
|
+
tick(): Promise<void>;
|
|
175
|
+
/**
|
|
176
|
+
* Where a hello goes, the way `run_scenario`'s request does: `BIR_CONTROL_URL`
|
|
177
|
+
* (with `BIR_CONTROL_TOKEN`, or the discovery file's token), else the
|
|
178
|
+
* discovery file — whose pid must be alive.
|
|
179
|
+
*/
|
|
180
|
+
private target;
|
|
181
|
+
private state;
|
|
182
|
+
private sendHello;
|
|
183
|
+
/** Whether anything listens at `url`: only a refused connection counts as nothing. */
|
|
184
|
+
private probe;
|
|
185
|
+
/**
|
|
186
|
+
* A recorder is up: the revives before it helped (or were not needed), so the
|
|
187
|
+
* next death waits the plain {@link REVIVE_EVERY_MS} since the last one, not
|
|
188
|
+
* a doubled wait.
|
|
189
|
+
*/
|
|
190
|
+
private recorderSeenAlive;
|
|
191
|
+
/** Start a revive if — and only if — the recorder is dead, refused and this project's. */
|
|
192
|
+
private checkRevive;
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=scenario-liveness.d.ts.map
|