@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SwitchableRecorder — one recorder object whose backend can change while the
|
|
3
|
+
* control server holds it.
|
|
4
|
+
*
|
|
5
|
+
* WHY. `ControlServer` takes its recorder once, at construction, and keeps it
|
|
6
|
+
* for the life of the process — which for a background recorder is days. The
|
|
7
|
+
* recorder used to be decided before the server even listened: sign in over
|
|
8
|
+
* the network, then build a `RemoteRecorder`, or give up and build a
|
|
9
|
+
* `NullRecorder` for good. So a slow service held every hook off a closed port
|
|
10
|
+
* for the whole start-up, and a service that was down (or a person not yet
|
|
11
|
+
* signed in) at that one moment meant nothing was recorded until a reboot. The
|
|
12
|
+
* server now gets this object instead; `bir-hooks` listens first and swaps the
|
|
13
|
+
* backend in whenever a sign-in appears, is refreshed, or goes away
|
|
14
|
+
* (control/recorder-keeper.ts).
|
|
15
|
+
*
|
|
16
|
+
* WHAT IT PRESERVES.
|
|
17
|
+
*
|
|
18
|
+
* - **Runs stay with the backend that opened them.** A run started on a
|
|
19
|
+
* `NullRecorder` has a local id the service never saw; its later steps must
|
|
20
|
+
* not be posted there (every one would 404). Per-run calls are routed by
|
|
21
|
+
* run id to the backend whose `startRun` returned it. New runs go to the
|
|
22
|
+
* current backend.
|
|
23
|
+
* - **Optional capabilities look exactly like the backend's.** The server
|
|
24
|
+
* probes them with `typeof recorder.getMatch === "function"` and friends
|
|
25
|
+
* (record/recorder.ts), and a `NullRecorder` has none — which is what keeps
|
|
26
|
+
* a signed-out recorder from doing a probe's work at all. Until a backend
|
|
27
|
+
* with a capability has been installed, this object does not have it
|
|
28
|
+
* either. Once one has, the capability stays (a check made just before a
|
|
29
|
+
* swap must never find the method gone when it calls it), and a backend
|
|
30
|
+
* without it answers the way "no service" always answered: no match, run
|
|
31
|
+
* created, a miss, nothing reported. Every argument is passed on as it
|
|
32
|
+
* came — `matchIntent(req, opts)` keeps its deadline and abort signal.
|
|
33
|
+
* - **A caller's budget is the budget.** `flush(runId, opts)` passes `opts`
|
|
34
|
+
* to every backend it drains, and drains them side by side, so a hook
|
|
35
|
+
* that gave a flush 800 ms is not held for twice that.
|
|
36
|
+
* - **The backlog is the process's, not one backend's.** The keeper
|
|
37
|
+
* installs a new backend on every token refresh; `recorderHealth()` adds
|
|
38
|
+
* up the current one and every earlier one still sending, and carries the
|
|
39
|
+
* dropped and failed counts of those that finished — "lossy" must not be
|
|
40
|
+
* forgotten because an hour passed.
|
|
41
|
+
*/
|
|
42
|
+
import { type FlushOptions, type Recorder, type RecorderHealth, type RunMetrics, type StartRunOptions } from "./recorder.js";
|
|
43
|
+
export interface SwitchableRecorderOptions {
|
|
44
|
+
/**
|
|
45
|
+
* Called before a run is started while the backend records nothing — the
|
|
46
|
+
* moment a sign-in that just appeared on disk is worth noticing, because
|
|
47
|
+
* this prompt could be recorded. Must be quick and synchronous; errors are
|
|
48
|
+
* swallowed.
|
|
49
|
+
*/
|
|
50
|
+
beforeStartRunWhileIdle?: () => void;
|
|
51
|
+
/** Called on every `startRun`, after the run exists. Errors are swallowed. */
|
|
52
|
+
onStartRun?: () => void;
|
|
53
|
+
/**
|
|
54
|
+
* What `recorderHealth()` gives as `state` while the current backend has no
|
|
55
|
+
* health of its own (it records nothing): why. The keeper knows; without
|
|
56
|
+
* it, `signed_out`.
|
|
57
|
+
*/
|
|
58
|
+
idleHealthState?: () => RecorderHealth["state"];
|
|
59
|
+
}
|
|
60
|
+
export declare class SwitchableRecorder implements Recorder {
|
|
61
|
+
private current;
|
|
62
|
+
/** runId → the backend whose `startRun` returned it. */
|
|
63
|
+
private readonly owners;
|
|
64
|
+
private readonly opts;
|
|
65
|
+
/**
|
|
66
|
+
* Earlier backends that may still be sending — they own a run, or had
|
|
67
|
+
* sends queued when they were replaced — so {@link recorderHealth} counts
|
|
68
|
+
* their backlog. Dropped once idle; their totals move to {@link carried}.
|
|
69
|
+
*/
|
|
70
|
+
private retired;
|
|
71
|
+
/** Dropped and failed sends of backends no longer tracked. */
|
|
72
|
+
private readonly carried;
|
|
73
|
+
/** What each untracked backend added to {@link carried}, in case it comes back. */
|
|
74
|
+
private readonly carriedFrom;
|
|
75
|
+
constructor(initial: Recorder, opts?: SwitchableRecorderOptions);
|
|
76
|
+
/** The backend new runs go to. */
|
|
77
|
+
get backend(): Recorder;
|
|
78
|
+
/** True when steps reach a service: the backend is not a {@link NullRecorder}. */
|
|
79
|
+
get recording(): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Make `next` the backend for new runs. Runs already started keep theirs,
|
|
82
|
+
* so their remaining steps (and their flush) still go where they began.
|
|
83
|
+
*/
|
|
84
|
+
switchTo(next: Recorder): void;
|
|
85
|
+
/**
|
|
86
|
+
* The access token the current backend holds right now, if it has one.
|
|
87
|
+
* Replay's service derivation and its source-run fetch read it on every use
|
|
88
|
+
* (C5): the token changes on every refresh and on every swap.
|
|
89
|
+
*/
|
|
90
|
+
currentAccessToken(): string | undefined;
|
|
91
|
+
startRun(input: string, metadata?: Record<string, unknown>, options?: StartRunOptions): string;
|
|
92
|
+
recordToolSelected(runId: string, stepIndex: number, d: Parameters<Recorder["recordToolSelected"]>[2]): string;
|
|
93
|
+
recordToolResponse(runId: string, stepIndex: number, d: {
|
|
94
|
+
toolName: string;
|
|
95
|
+
toolOutput?: string;
|
|
96
|
+
toolError?: string;
|
|
97
|
+
}): string;
|
|
98
|
+
recordFinalAnswer(runId: string, stepIndex: number, d: {
|
|
99
|
+
answer: string;
|
|
100
|
+
}): string;
|
|
101
|
+
finishRun(runId: string, finalOutput?: string, metrics?: RunMetrics): void;
|
|
102
|
+
flush(runId: string, opts?: FlushOptions): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* Stop tracking earlier backends that own no run and have nothing queued,
|
|
105
|
+
* keeping what they dropped and what failed.
|
|
106
|
+
*/
|
|
107
|
+
private pruneRetired;
|
|
108
|
+
/**
|
|
109
|
+
* `recorderHealth()` for the whole process: the current backend's state (or,
|
|
110
|
+
* while it records nothing, why), and the backlog of every backend still
|
|
111
|
+
* sending, with the losses of the ones that are done.
|
|
112
|
+
*/
|
|
113
|
+
private processHealth;
|
|
114
|
+
private remember;
|
|
115
|
+
private ownerOf;
|
|
116
|
+
/**
|
|
117
|
+
* Give this object a forwarder for each optional member `backend` has and
|
|
118
|
+
* this object does not yet. Forwarders route by run id when the first
|
|
119
|
+
* argument is one, else to the current backend, and fall back to the
|
|
120
|
+
* no-service answer when that backend lacks the member. Every argument is
|
|
121
|
+
* passed on as it came. `recorderHealth` is the one exception: it answers
|
|
122
|
+
* for every backend still sending (see {@link processHealth}).
|
|
123
|
+
*/
|
|
124
|
+
private adoptCapabilities;
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=switchable-recorder.d.ts.map
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SwitchableRecorder — one recorder object whose backend can change while the
|
|
3
|
+
* control server holds it.
|
|
4
|
+
*
|
|
5
|
+
* WHY. `ControlServer` takes its recorder once, at construction, and keeps it
|
|
6
|
+
* for the life of the process — which for a background recorder is days. The
|
|
7
|
+
* recorder used to be decided before the server even listened: sign in over
|
|
8
|
+
* the network, then build a `RemoteRecorder`, or give up and build a
|
|
9
|
+
* `NullRecorder` for good. So a slow service held every hook off a closed port
|
|
10
|
+
* for the whole start-up, and a service that was down (or a person not yet
|
|
11
|
+
* signed in) at that one moment meant nothing was recorded until a reboot. The
|
|
12
|
+
* server now gets this object instead; `bir-hooks` listens first and swaps the
|
|
13
|
+
* backend in whenever a sign-in appears, is refreshed, or goes away
|
|
14
|
+
* (control/recorder-keeper.ts).
|
|
15
|
+
*
|
|
16
|
+
* WHAT IT PRESERVES.
|
|
17
|
+
*
|
|
18
|
+
* - **Runs stay with the backend that opened them.** A run started on a
|
|
19
|
+
* `NullRecorder` has a local id the service never saw; its later steps must
|
|
20
|
+
* not be posted there (every one would 404). Per-run calls are routed by
|
|
21
|
+
* run id to the backend whose `startRun` returned it. New runs go to the
|
|
22
|
+
* current backend.
|
|
23
|
+
* - **Optional capabilities look exactly like the backend's.** The server
|
|
24
|
+
* probes them with `typeof recorder.getMatch === "function"` and friends
|
|
25
|
+
* (record/recorder.ts), and a `NullRecorder` has none — which is what keeps
|
|
26
|
+
* a signed-out recorder from doing a probe's work at all. Until a backend
|
|
27
|
+
* with a capability has been installed, this object does not have it
|
|
28
|
+
* either. Once one has, the capability stays (a check made just before a
|
|
29
|
+
* swap must never find the method gone when it calls it), and a backend
|
|
30
|
+
* without it answers the way "no service" always answered: no match, run
|
|
31
|
+
* created, a miss, nothing reported. Every argument is passed on as it
|
|
32
|
+
* came — `matchIntent(req, opts)` keeps its deadline and abort signal.
|
|
33
|
+
* - **A caller's budget is the budget.** `flush(runId, opts)` passes `opts`
|
|
34
|
+
* to every backend it drains, and drains them side by side, so a hook
|
|
35
|
+
* that gave a flush 800 ms is not held for twice that.
|
|
36
|
+
* - **The backlog is the process's, not one backend's.** The keeper
|
|
37
|
+
* installs a new backend on every token refresh; `recorderHealth()` adds
|
|
38
|
+
* up the current one and every earlier one still sending, and carries the
|
|
39
|
+
* dropped and failed counts of those that finished — "lossy" must not be
|
|
40
|
+
* forgotten because an hour passed.
|
|
41
|
+
*/
|
|
42
|
+
import { NullRecorder, } from "./recorder.js";
|
|
43
|
+
/**
|
|
44
|
+
* What a capability answers when the backend a call is routed to lacks it —
|
|
45
|
+
* the answers a recorder with no service has always produced (recorder.ts).
|
|
46
|
+
*/
|
|
47
|
+
const NEUTRAL = {
|
|
48
|
+
getMatch: () => Promise.resolve(null),
|
|
49
|
+
runCreated: () => Promise.resolve({ created: true }),
|
|
50
|
+
matchIntent: () => Promise.resolve(null),
|
|
51
|
+
reportExecution: () => undefined,
|
|
52
|
+
backfillContext: () => undefined,
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* `RemoteRecorder`'s private helpers. They are methods at runtime (TypeScript's
|
|
56
|
+
* `private` is erased), and a forwarder for them would be worse than clutter:
|
|
57
|
+
* a capability check by name (`typeof r.sweep === "function"`) would find one.
|
|
58
|
+
* Kept in step with record/remote-recorder.ts by a test that reads its source.
|
|
59
|
+
*/
|
|
60
|
+
const INTERNAL = new Set([
|
|
61
|
+
"constructor",
|
|
62
|
+
"accountChanged",
|
|
63
|
+
"adoptStoredSession",
|
|
64
|
+
"beginProbe",
|
|
65
|
+
"checkCredentials",
|
|
66
|
+
"drain",
|
|
67
|
+
"enforceBound",
|
|
68
|
+
"enqueue",
|
|
69
|
+
"enqueueRun",
|
|
70
|
+
"entryFor",
|
|
71
|
+
"execute",
|
|
72
|
+
"healthyNow",
|
|
73
|
+
"laneIsIdle",
|
|
74
|
+
"markDead",
|
|
75
|
+
"mayHotWait",
|
|
76
|
+
"newEntry",
|
|
77
|
+
"noteBacklogRecovered",
|
|
78
|
+
"noteFileSeen",
|
|
79
|
+
"onSendFailed",
|
|
80
|
+
"patch",
|
|
81
|
+
"pause",
|
|
82
|
+
"pickVictim",
|
|
83
|
+
"post",
|
|
84
|
+
"probeInFlight",
|
|
85
|
+
"probeSoon",
|
|
86
|
+
"refresh",
|
|
87
|
+
"refreshAhead",
|
|
88
|
+
"refreshOverNetwork",
|
|
89
|
+
"refreshSingleFlight",
|
|
90
|
+
"releaseJitterMs",
|
|
91
|
+
"request",
|
|
92
|
+
"resume",
|
|
93
|
+
"retire",
|
|
94
|
+
"retryRejected",
|
|
95
|
+
"send",
|
|
96
|
+
"summarize",
|
|
97
|
+
"sweep",
|
|
98
|
+
"waitForLanes",
|
|
99
|
+
"waitForService",
|
|
100
|
+
"whyNotSend",
|
|
101
|
+
]);
|
|
102
|
+
/** Keep at most this many run → backend entries (flush normally removes them). */
|
|
103
|
+
const MAX_OWNERS = 2_000;
|
|
104
|
+
/** A backend's own `recorderHealth()`, or undefined when it has none (or it threw). */
|
|
105
|
+
function healthOf(r) {
|
|
106
|
+
const fn = r.recorderHealth;
|
|
107
|
+
if (typeof fn !== "function")
|
|
108
|
+
return undefined;
|
|
109
|
+
try {
|
|
110
|
+
return fn.call(r);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Every function-valued member of `obj`, own or inherited, but not Object's. */
|
|
117
|
+
function methodNames(obj) {
|
|
118
|
+
const names = new Set();
|
|
119
|
+
for (let p = obj; p && p !== Object.prototype; p = Object.getPrototypeOf(p)) {
|
|
120
|
+
for (const name of Object.getOwnPropertyNames(p)) {
|
|
121
|
+
if (INTERNAL.has(name) || name.startsWith("_"))
|
|
122
|
+
continue;
|
|
123
|
+
const desc = Object.getOwnPropertyDescriptor(p, name);
|
|
124
|
+
if (desc && typeof desc.value === "function")
|
|
125
|
+
names.add(name);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return [...names];
|
|
129
|
+
}
|
|
130
|
+
export class SwitchableRecorder {
|
|
131
|
+
current;
|
|
132
|
+
/** runId → the backend whose `startRun` returned it. */
|
|
133
|
+
owners = new Map();
|
|
134
|
+
opts;
|
|
135
|
+
/**
|
|
136
|
+
* Earlier backends that may still be sending — they own a run, or had
|
|
137
|
+
* sends queued when they were replaced — so {@link recorderHealth} counts
|
|
138
|
+
* their backlog. Dropped once idle; their totals move to {@link carried}.
|
|
139
|
+
*/
|
|
140
|
+
retired = [];
|
|
141
|
+
/** Dropped and failed sends of backends no longer tracked. */
|
|
142
|
+
carried = { droppedSends: 0, failedSends: 0 };
|
|
143
|
+
/** What each untracked backend added to {@link carried}, in case it comes back. */
|
|
144
|
+
carriedFrom = new WeakMap();
|
|
145
|
+
constructor(initial, opts = {}) {
|
|
146
|
+
this.current = initial;
|
|
147
|
+
this.opts = opts;
|
|
148
|
+
this.adoptCapabilities(initial);
|
|
149
|
+
}
|
|
150
|
+
/** The backend new runs go to. */
|
|
151
|
+
get backend() {
|
|
152
|
+
return this.current;
|
|
153
|
+
}
|
|
154
|
+
/** True when steps reach a service: the backend is not a {@link NullRecorder}. */
|
|
155
|
+
get recording() {
|
|
156
|
+
return !(this.current instanceof NullRecorder);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Make `next` the backend for new runs. Runs already started keep theirs,
|
|
160
|
+
* so their remaining steps (and their flush) still go where they began.
|
|
161
|
+
*/
|
|
162
|
+
switchTo(next) {
|
|
163
|
+
if (next === this.current)
|
|
164
|
+
return;
|
|
165
|
+
const previous = this.current;
|
|
166
|
+
this.current = next;
|
|
167
|
+
// A backend coming back (the one idle recorder, after a sign-in and a
|
|
168
|
+
// sign-out) is counted as current again, not also as retired or carried.
|
|
169
|
+
this.retired = this.retired.filter((r) => r !== next);
|
|
170
|
+
const back = this.carriedFrom.get(next);
|
|
171
|
+
if (back) {
|
|
172
|
+
this.carried.droppedSends -= back.droppedSends;
|
|
173
|
+
this.carried.failedSends -= back.failedSends;
|
|
174
|
+
this.carriedFrom.delete(next);
|
|
175
|
+
}
|
|
176
|
+
this.retired.push(previous);
|
|
177
|
+
this.adoptCapabilities(next);
|
|
178
|
+
this.pruneRetired();
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The access token the current backend holds right now, if it has one.
|
|
182
|
+
* Replay's service derivation and its source-run fetch read it on every use
|
|
183
|
+
* (C5): the token changes on every refresh and on every swap.
|
|
184
|
+
*/
|
|
185
|
+
currentAccessToken() {
|
|
186
|
+
const r = this.current;
|
|
187
|
+
try {
|
|
188
|
+
return typeof r.currentAccessToken === "function" ? r.currentAccessToken() : undefined;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// ── the Recorder surface ────────────────────────────────────────────────
|
|
195
|
+
startRun(input, metadata, options) {
|
|
196
|
+
if (!this.recording && this.opts.beforeStartRunWhileIdle) {
|
|
197
|
+
try {
|
|
198
|
+
this.opts.beforeStartRunWhileIdle();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
/* noticing a sign-in is an optimisation; recording nothing is the fallback */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const backend = this.current;
|
|
205
|
+
const runId = backend.startRun(input, metadata, options);
|
|
206
|
+
this.remember(runId, backend);
|
|
207
|
+
try {
|
|
208
|
+
this.opts.onStartRun?.();
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
/* bookkeeping only */
|
|
212
|
+
}
|
|
213
|
+
return runId;
|
|
214
|
+
}
|
|
215
|
+
recordToolSelected(runId, stepIndex, d) {
|
|
216
|
+
return this.ownerOf(runId).recordToolSelected(runId, stepIndex, d);
|
|
217
|
+
}
|
|
218
|
+
recordToolResponse(runId, stepIndex, d) {
|
|
219
|
+
return this.ownerOf(runId).recordToolResponse(runId, stepIndex, d);
|
|
220
|
+
}
|
|
221
|
+
recordFinalAnswer(runId, stepIndex, d) {
|
|
222
|
+
return this.ownerOf(runId).recordFinalAnswer(runId, stepIndex, d);
|
|
223
|
+
}
|
|
224
|
+
finishRun(runId, finalOutput, metrics) {
|
|
225
|
+
this.ownerOf(runId).finishRun(runId, finalOutput, metrics);
|
|
226
|
+
}
|
|
227
|
+
async flush(runId, opts) {
|
|
228
|
+
const owner = this.ownerOf(runId);
|
|
229
|
+
const current = this.current;
|
|
230
|
+
try {
|
|
231
|
+
// Execution reports go to the current backend (a matched turn has no run
|
|
232
|
+
// of its own to follow), so when the run began on an earlier one, drain
|
|
233
|
+
// the current one's report chain as well — that is what a flush
|
|
234
|
+
// promises. Side by side, each with the caller's budget: one after the
|
|
235
|
+
// other would be twice what the caller allowed.
|
|
236
|
+
await Promise.all([owner.flush(runId, opts), current !== owner ? current.flush(runId, opts) : undefined]);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
// Only now: a step that arrives while the flush runs (a proxy report
|
|
240
|
+
// right behind Stop) still goes where the run began.
|
|
241
|
+
if (this.owners.get(runId) === owner)
|
|
242
|
+
this.owners.delete(runId);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// ── internals ───────────────────────────────────────────────────────────
|
|
246
|
+
/**
|
|
247
|
+
* Stop tracking earlier backends that own no run and have nothing queued,
|
|
248
|
+
* keeping what they dropped and what failed.
|
|
249
|
+
*/
|
|
250
|
+
pruneRetired() {
|
|
251
|
+
if (this.retired.length === 0)
|
|
252
|
+
return;
|
|
253
|
+
const owning = new Set(this.owners.values());
|
|
254
|
+
this.retired = this.retired.filter((r) => {
|
|
255
|
+
if (r === this.current || owning.has(r))
|
|
256
|
+
return true;
|
|
257
|
+
const h = healthOf(r);
|
|
258
|
+
if (h && h.pendingSends > 0)
|
|
259
|
+
return true;
|
|
260
|
+
if (h) {
|
|
261
|
+
this.carried.droppedSends += h.droppedSends;
|
|
262
|
+
this.carried.failedSends += h.failedSends;
|
|
263
|
+
this.carriedFrom.set(r, { droppedSends: h.droppedSends, failedSends: h.failedSends });
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* `recorderHealth()` for the whole process: the current backend's state (or,
|
|
270
|
+
* while it records nothing, why), and the backlog of every backend still
|
|
271
|
+
* sending, with the losses of the ones that are done.
|
|
272
|
+
*/
|
|
273
|
+
processHealth() {
|
|
274
|
+
this.pruneRetired();
|
|
275
|
+
const own = healthOf(this.current);
|
|
276
|
+
const total = {
|
|
277
|
+
pendingSends: 0,
|
|
278
|
+
pendingBytes: 0,
|
|
279
|
+
droppedSends: this.carried.droppedSends,
|
|
280
|
+
failedSends: this.carried.failedSends,
|
|
281
|
+
};
|
|
282
|
+
for (const h of [own, ...this.retired.map(healthOf)]) {
|
|
283
|
+
if (!h)
|
|
284
|
+
continue;
|
|
285
|
+
total.pendingSends += h.pendingSends;
|
|
286
|
+
total.pendingBytes += h.pendingBytes;
|
|
287
|
+
total.droppedSends += h.droppedSends;
|
|
288
|
+
total.failedSends += h.failedSends;
|
|
289
|
+
}
|
|
290
|
+
let state = own?.state ?? "signed_out";
|
|
291
|
+
if (!own && this.opts.idleHealthState) {
|
|
292
|
+
try {
|
|
293
|
+
state = this.opts.idleHealthState();
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
/* keep the default */
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return { state, ...total };
|
|
300
|
+
}
|
|
301
|
+
remember(runId, backend) {
|
|
302
|
+
this.owners.set(runId, backend);
|
|
303
|
+
if (this.owners.size > MAX_OWNERS) {
|
|
304
|
+
// Oldest first (a Map iterates in insertion order). A run that old was
|
|
305
|
+
// abandoned without a flush; routing its stragglers to the current
|
|
306
|
+
// backend is the lesser harm.
|
|
307
|
+
const oldest = this.owners.keys().next().value;
|
|
308
|
+
if (oldest !== undefined)
|
|
309
|
+
this.owners.delete(oldest);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
ownerOf(runId) {
|
|
313
|
+
return (typeof runId === "string" && this.owners.get(runId)) || this.current;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Give this object a forwarder for each optional member `backend` has and
|
|
317
|
+
* this object does not yet. Forwarders route by run id when the first
|
|
318
|
+
* argument is one, else to the current backend, and fall back to the
|
|
319
|
+
* no-service answer when that backend lacks the member. Every argument is
|
|
320
|
+
* passed on as it came. `recorderHealth` is the one exception: it answers
|
|
321
|
+
* for every backend still sending (see {@link processHealth}).
|
|
322
|
+
*/
|
|
323
|
+
adoptCapabilities(backend) {
|
|
324
|
+
for (const name of methodNames(backend)) {
|
|
325
|
+
if (name in this)
|
|
326
|
+
continue;
|
|
327
|
+
if (name === "recorderHealth") {
|
|
328
|
+
Object.defineProperty(this, name, {
|
|
329
|
+
configurable: true,
|
|
330
|
+
enumerable: false,
|
|
331
|
+
writable: true,
|
|
332
|
+
value: () => this.processHealth(),
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
Object.defineProperty(this, name, {
|
|
337
|
+
configurable: true,
|
|
338
|
+
enumerable: false,
|
|
339
|
+
writable: true,
|
|
340
|
+
value: (...args) => {
|
|
341
|
+
const target = this.ownerOf(args[0]);
|
|
342
|
+
const fn = target[name];
|
|
343
|
+
if (typeof fn === "function")
|
|
344
|
+
return fn.apply(target, args);
|
|
345
|
+
return NEUTRAL[name]?.();
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
//# sourceMappingURL=switchable-recorder.js.map
|
|
@@ -24,8 +24,47 @@ export interface TruncateOptions {
|
|
|
24
24
|
maxPayloadBytes?: number;
|
|
25
25
|
maxStringBytes?: number;
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Clip one string to `max` bytes, appending a marker naming what was dropped.
|
|
29
|
+
*
|
|
30
|
+
* A string that was already clipped keeps its *original* size in the new
|
|
31
|
+
* marker, so a leaf cut twice (once for speed, once for the payload cap) still
|
|
32
|
+
* says how big it really was.
|
|
33
|
+
*/
|
|
28
34
|
export declare function truncateString(text: string, max: number): string;
|
|
35
|
+
/**
|
|
36
|
+
* Cap a free-text field and pass it through `transform` (redaction), cheaply.
|
|
37
|
+
*
|
|
38
|
+
* The transform runs on a window a little longer than the cap, not on the
|
|
39
|
+
* whole string — a 3 MB pasted log costs a scan of 36 KB — and *before* the
|
|
40
|
+
* final cut, so a secret straddling the cut is still matched whole instead of
|
|
41
|
+
* leaving its first half behind.
|
|
42
|
+
*/
|
|
43
|
+
export declare function capText(text: string, max: number, transform?: (s: string) => string,
|
|
44
|
+
/** The size before an earlier cut (see {@link preCut}), so the marker tells the truth. */
|
|
45
|
+
originalBytes?: number): string;
|
|
46
|
+
/** How far past a cap {@link capText} redacts, so a secret across the cut is matched whole. */
|
|
47
|
+
export declare const CAP_WINDOW = 4096;
|
|
48
|
+
/**
|
|
49
|
+
* Cut `text` to what {@link capText} will ever look at, and remember how big
|
|
50
|
+
* it was. For a value that waits in a queue before it is capped: the queue
|
|
51
|
+
* holds 36 KB of a 3 MB prompt, not the prompt.
|
|
52
|
+
*/
|
|
53
|
+
export declare function preCut(text: string, max: number): {
|
|
54
|
+
text: string;
|
|
55
|
+
bytes: number;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Cap a string that is supposed to be JSON (a serialized tool input or result)
|
|
59
|
+
* without breaking it: an oversized one becomes the same
|
|
60
|
+
* `{ truncated, originalBytes, preview }` object {@link serializeCapped} uses.
|
|
61
|
+
* `slack` tolerates the few bytes a redaction pass can add.
|
|
62
|
+
*
|
|
63
|
+
* Valid JSON in, valid JSON out, whatever `transform` does: a transform that
|
|
64
|
+
* breaks the document (a text rule that met an escape) is run on each string
|
|
65
|
+
* leaf instead. Pass `redactJsonText` to redact structurally in the first place.
|
|
66
|
+
*/
|
|
67
|
+
export declare function capJsonText(text: string, max?: number, transform?: (s: string) => string, originalBytes?: number, slack?: number): string;
|
|
29
68
|
/** Deep-copy `value`, clipping every string leaf. Input is never mutated. */
|
|
30
69
|
export declare function truncateStrings(value: unknown, max: number, depth?: number): unknown;
|
|
31
70
|
/**
|
package/dist/record/truncate.js
CHANGED
|
@@ -21,14 +21,121 @@ export const DEFAULT_MAX_PAYLOAD_BYTES = 64 * 1024;
|
|
|
21
21
|
/** 8 KiB per individual string leaf. */
|
|
22
22
|
export const DEFAULT_MAX_STRING_BYTES = 8 * 1024;
|
|
23
23
|
const utf8 = (s) => Buffer.byteLength(s, "utf8");
|
|
24
|
-
/**
|
|
24
|
+
/** The marker {@link truncateString} appends, so a second cut can report the first size. */
|
|
25
|
+
const MARKER = /… \[truncated \d+ of (\d+) bytes\]$/;
|
|
26
|
+
/** The first `max` bytes of `text`, never splitting a multi-byte character. */
|
|
27
|
+
function headBytes(text, max) {
|
|
28
|
+
// Every UTF-16 unit is at least one UTF-8 byte, so the first `max` bytes lie
|
|
29
|
+
// inside the first `max` units: encode that much, not a 20 MB leaf.
|
|
30
|
+
return Buffer.from(text.slice(0, max), "utf8").subarray(0, max).toString("utf8").replace(/�$/, "");
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Clip one string to `max` bytes, appending a marker naming what was dropped.
|
|
34
|
+
*
|
|
35
|
+
* A string that was already clipped keeps its *original* size in the new
|
|
36
|
+
* marker, so a leaf cut twice (once for speed, once for the payload cap) still
|
|
37
|
+
* says how big it really was.
|
|
38
|
+
*/
|
|
25
39
|
export function truncateString(text, max) {
|
|
26
40
|
const bytes = utf8(text);
|
|
27
41
|
if (bytes <= max)
|
|
28
42
|
return text;
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
43
|
+
const prior = MARKER.exec(text);
|
|
44
|
+
const body = prior ? text.slice(0, prior.index) : text;
|
|
45
|
+
const original = prior ? Math.max(Number(prior[1]), bytes) : bytes;
|
|
46
|
+
const head = headBytes(body, max);
|
|
47
|
+
return `${head}… [truncated ${original - utf8(head)} of ${original} bytes]`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Cap a free-text field and pass it through `transform` (redaction), cheaply.
|
|
51
|
+
*
|
|
52
|
+
* The transform runs on a window a little longer than the cap, not on the
|
|
53
|
+
* whole string — a 3 MB pasted log costs a scan of 36 KB — and *before* the
|
|
54
|
+
* final cut, so a secret straddling the cut is still matched whole instead of
|
|
55
|
+
* leaving its first half behind.
|
|
56
|
+
*/
|
|
57
|
+
export function capText(text, max, transform = (s) => s,
|
|
58
|
+
/** The size before an earlier cut (see {@link preCut}), so the marker tells the truth. */
|
|
59
|
+
originalBytes) {
|
|
60
|
+
const original = Math.max(originalBytes ?? 0, utf8(text));
|
|
61
|
+
if (original <= max)
|
|
62
|
+
return transform(text);
|
|
63
|
+
const window = transform(text.slice(0, max + CAP_WINDOW));
|
|
64
|
+
const head = headBytes(window, max);
|
|
65
|
+
return `${head}… [truncated ${Math.max(0, original - utf8(head))} of ${original} bytes]`;
|
|
66
|
+
}
|
|
67
|
+
/** How far past a cap {@link capText} redacts, so a secret across the cut is matched whole. */
|
|
68
|
+
export const CAP_WINDOW = 4096;
|
|
69
|
+
/**
|
|
70
|
+
* Cut `text` to what {@link capText} will ever look at, and remember how big
|
|
71
|
+
* it was. For a value that waits in a queue before it is capped: the queue
|
|
72
|
+
* holds 36 KB of a 3 MB prompt, not the prompt.
|
|
73
|
+
*/
|
|
74
|
+
export function preCut(text, max) {
|
|
75
|
+
const bytes = utf8(text);
|
|
76
|
+
return { text: bytes <= max + CAP_WINDOW ? text : text.slice(0, max + CAP_WINDOW), bytes };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Cap a string that is supposed to be JSON (a serialized tool input or result)
|
|
80
|
+
* without breaking it: an oversized one becomes the same
|
|
81
|
+
* `{ truncated, originalBytes, preview }` object {@link serializeCapped} uses.
|
|
82
|
+
* `slack` tolerates the few bytes a redaction pass can add.
|
|
83
|
+
*
|
|
84
|
+
* Valid JSON in, valid JSON out, whatever `transform` does: a transform that
|
|
85
|
+
* breaks the document (a text rule that met an escape) is run on each string
|
|
86
|
+
* leaf instead. Pass `redactJsonText` to redact structurally in the first place.
|
|
87
|
+
*/
|
|
88
|
+
export function capJsonText(text, max = DEFAULT_MAX_PAYLOAD_BYTES, transform = (s) => s, originalBytes, slack = 1024) {
|
|
89
|
+
const original = Math.max(originalBytes ?? 0, utf8(text));
|
|
90
|
+
if (original <= max + slack)
|
|
91
|
+
return keepJson(text, transform);
|
|
92
|
+
return JSON.stringify({
|
|
93
|
+
truncated: true,
|
|
94
|
+
originalBytes: original,
|
|
95
|
+
preview: capText(text, Math.max(1024, Math.floor(max / 2)), transform, original),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* `transform(text)` — unless `text` was JSON and the result is not. Checked
|
|
100
|
+
* only when the transform changed something, so a payload with nothing to
|
|
101
|
+
* redact costs no parse at all.
|
|
102
|
+
*/
|
|
103
|
+
function keepJson(text, transform) {
|
|
104
|
+
const out = transform(text);
|
|
105
|
+
if (out === text)
|
|
106
|
+
return out;
|
|
107
|
+
try {
|
|
108
|
+
JSON.parse(out);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* the transform broke it — or it was never JSON */
|
|
113
|
+
}
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(text);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return out; // never JSON: there is no structure to keep
|
|
120
|
+
}
|
|
121
|
+
return JSON.stringify(mapStrings(parsed, transform, 0)) ?? "null";
|
|
122
|
+
}
|
|
123
|
+
/** A copy of parsed JSON with `f` applied to every string leaf. */
|
|
124
|
+
function mapStrings(value, f, depth) {
|
|
125
|
+
if (typeof value === "string")
|
|
126
|
+
return f(value);
|
|
127
|
+
if (!value || typeof value !== "object")
|
|
128
|
+
return value;
|
|
129
|
+
if (depth > 64)
|
|
130
|
+
return "[max depth]";
|
|
131
|
+
if (Array.isArray(value))
|
|
132
|
+
return value.map((v) => mapStrings(v, f, depth + 1));
|
|
133
|
+
const out = {};
|
|
134
|
+
for (const [k, v] of Object.entries(value)) {
|
|
135
|
+
// `__proto__` is an ordinary key in parsed JSON; keep it one.
|
|
136
|
+
Object.defineProperty(out, k, { value: mapStrings(v, f, depth + 1), enumerable: true, writable: true, configurable: true });
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
32
139
|
}
|
|
33
140
|
/** Deep-copy `value`, clipping every string leaf. Input is never mutated. */
|
|
34
141
|
export function truncateStrings(value, max, depth = 0) {
|