@basein/runner 0.2.11 → 0.3.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.
Files changed (144) hide show
  1. package/README.md +540 -350
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2803 -306
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. package/scripts/install.sh +104 -7
@@ -15,9 +15,10 @@
15
15
  * the reason a hook fails (design §1, rule 4), and a hook's answer must not
16
16
  * race the line that explains it.
17
17
  */
18
- import { appendFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
18
+ import { appendFileSync, existsSync, readFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
19
19
  import { dirname, join } from "node:path";
20
20
  import { controlDir, controlKey, ensureDir } from "../control/paths.js";
21
+ import { writeFileAtomic } from "./atomic-write.js";
21
22
  /** Events worth keeping: the run boundaries, every replay decision, the money. */
22
23
  const JOURNALED_PREFIXES = ["run.", "replay.", "plan.", "execution."];
23
24
  const JOURNALED_EVENTS = new Set(["session.start", "control.listening", "proxy.registered"]);
@@ -25,6 +26,14 @@ export function isJournaled(event) {
25
26
  return JOURNALED_EVENTS.has(event) || JOURNALED_PREFIXES.some((p) => event.startsWith(p));
26
27
  }
27
28
  export const JOURNAL_MAX_BYTES = 4 * 1024 * 1024;
29
+ /**
30
+ * The longest string one journal field keeps. The journal explains decisions;
31
+ * a field that carries a whole error page or a pasted log would spend the
32
+ * 4 MB budget on one line and rotate the useful ones out.
33
+ */
34
+ export const JOURNAL_FIELD_MAX_CHARS = 4_000;
35
+ /** A journal nobody has written to for this long belongs to a project that is gone. */
36
+ export const JOURNAL_STALE_MS = 30 * 24 * 60 * 60 * 1000;
28
37
  export function journalPath(cwd) {
29
38
  return join(controlDir(), "journal", `${controlKey(cwd)}.jsonl`);
30
39
  }
@@ -34,8 +43,40 @@ export function openJournal(cwd) {
34
43
  const path = journalPath(cwd);
35
44
  ensureDir(dirname(path));
36
45
  current = path;
46
+ pruneStaleJournals(dirname(path), path);
37
47
  return path;
38
48
  }
49
+ /**
50
+ * Delete other projects' journals untouched for {@link JOURNAL_STALE_MS}.
51
+ * One file per directory ever recorded in, each up to 4 MB, never pruned, is
52
+ * how a year of throwaway checkouts becomes a gigabyte nobody asked for.
53
+ * Best-effort, and never the open file.
54
+ */
55
+ export function pruneStaleJournals(dir, keep, now = Date.now()) {
56
+ let removed = 0;
57
+ try {
58
+ for (const name of readdirSync(dir)) {
59
+ if (!name.endsWith(".jsonl"))
60
+ continue;
61
+ const file = join(dir, name);
62
+ if (file === keep)
63
+ continue;
64
+ try {
65
+ if (now - statSync(file).mtimeMs > JOURNAL_STALE_MS) {
66
+ unlinkSync(file);
67
+ removed += 1;
68
+ }
69
+ }
70
+ catch {
71
+ /* in use, or already gone */
72
+ }
73
+ }
74
+ }
75
+ catch {
76
+ /* no directory yet */
77
+ }
78
+ return removed;
79
+ }
39
80
  export function closeJournal() {
40
81
  current = undefined;
41
82
  }
@@ -51,7 +92,10 @@ export function journal(event, fields = {}) {
51
92
  for (const [key, value] of Object.entries(fields)) {
52
93
  if (value === undefined || value === null)
53
94
  continue;
54
- entry[key] = value;
95
+ entry[key] =
96
+ typeof value === "string" && value.length > JOURNAL_FIELD_MAX_CHARS
97
+ ? `${value.slice(0, JOURNAL_FIELD_MAX_CHARS)}…(+${value.length - JOURNAL_FIELD_MAX_CHARS} chars)`
98
+ : value;
55
99
  }
56
100
  try {
57
101
  appendFileSync(current, `${JSON.stringify(entry)}\n`);
@@ -73,7 +117,8 @@ function rotate(path) {
73
117
  return;
74
118
  const lines = readFileSync(path, "utf8").split("\n").filter(Boolean);
75
119
  const keep = lines.slice(Math.floor(lines.length / 2));
76
- writeFileSync(path, keep.length ? `${keep.join("\n")}\n` : "");
120
+ // Atomic: `bir investigate` may be reading the file while a hook rotates it.
121
+ writeFileAtomic(path, keep.length ? `${keep.join("\n")}\n` : "");
77
122
  }
78
123
  /** Every entry in a journal file, oldest first. A malformed line is skipped, not fatal. */
79
124
  export function readJournal(path) {
@@ -18,10 +18,20 @@ export declare const VERBOSE: boolean;
18
18
  * session: the audit log is the only record of what the recorder decided.
19
19
  */
20
20
  export declare const QUIET: boolean;
21
+ /**
22
+ * The longest value one field may print. An audit line is for a person reading
23
+ * a log, and one field that carries a whole HTML error page per failed step
24
+ * buries every other line during exactly the incident someone is reading it
25
+ * for. The cut is marked, never silent.
26
+ */
27
+ export declare const LOG_FIELD_MAX_CHARS = 2000;
28
+ /** Clip one field's text for a log line. */
29
+ export declare function clipField(text: string, max?: number): string;
21
30
  /** Print one audit line: `[bir] <iso ts> <event> key=value …`. */
22
31
  export declare function logLine(event: string, fields?: Record<string, unknown>): void;
23
32
  /** A detail line, printed only under `BIR_VERBOSE=1`. */
24
33
  export declare function logDetail(event: string, fields?: Record<string, unknown>): void;
25
34
  export declare function logOnce(key: string, event: string, fields?: Record<string, unknown>): void;
35
+ export declare function logEvery(key: string, intervalMs: number, event: string, fields?: Record<string, unknown>): boolean;
26
36
  export declare function errText(err: unknown): string;
27
37
  //# sourceMappingURL=log.d.ts.map
package/dist/util/log.js CHANGED
@@ -19,6 +19,17 @@ export const VERBOSE = process.env.BIR_VERBOSE === "1";
19
19
  */
20
20
  export const QUIET = process.env.BIR_QUIET === "1";
21
21
  import { journal } from "./journal.js";
22
+ /**
23
+ * The longest value one field may print. An audit line is for a person reading
24
+ * a log, and one field that carries a whole HTML error page per failed step
25
+ * buries every other line during exactly the incident someone is reading it
26
+ * for. The cut is marked, never silent.
27
+ */
28
+ export const LOG_FIELD_MAX_CHARS = 2_000;
29
+ /** Clip one field's text for a log line. */
30
+ export function clipField(text, max = LOG_FIELD_MAX_CHARS) {
31
+ return text.length > max ? `${text.slice(0, max)}…(+${text.length - max} chars)` : text;
32
+ }
22
33
  /** Print one audit line: `[bir] <iso ts> <event> key=value …`. */
23
34
  export function logLine(event, fields = {}) {
24
35
  // The journal keeps the decision lines for `bir investigate` (journal.ts).
@@ -30,7 +41,7 @@ export function logLine(event, fields = {}) {
30
41
  for (const [key, value] of Object.entries(fields)) {
31
42
  if (value === undefined || value === null)
32
43
  continue;
33
- const text = String(value);
44
+ const text = clipField(String(value));
34
45
  pairs.push(/[\s"]/.test(text) ? `${key}=${JSON.stringify(text)}` : `${key}=${text}`);
35
46
  }
36
47
  const line = `[bir] ${new Date().toISOString()} ${event}${pairs.length ? ` ${pairs.join(" ")}` : ""}`;
@@ -49,6 +60,22 @@ export function logOnce(key, event, fields = {}) {
49
60
  seen.add(key);
50
61
  logLine(event, fields);
51
62
  }
63
+ /**
64
+ * Log at most once per `intervalMs` per `key`, saying how many were held back.
65
+ * For lines an outage would otherwise repeat on every tool call.
66
+ */
67
+ const lastAt = new Map();
68
+ export function logEvery(key, intervalMs, event, fields = {}) {
69
+ const now = Date.now();
70
+ const prev = lastAt.get(key);
71
+ if (prev && now - prev.at < intervalMs) {
72
+ prev.suppressed += 1;
73
+ return false;
74
+ }
75
+ lastAt.set(key, { at: now, suppressed: 0 });
76
+ logLine(event, prev?.suppressed ? { ...fields, suppressed: prev.suppressed } : fields);
77
+ return true;
78
+ }
52
79
  export function errText(err) {
53
80
  return err instanceof Error ? err.message : String(err);
54
81
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * off-switch — make the whole runner inert, instantly and reversibly.
3
+ *
4
+ * When a session misbehaves and the runner might be why — slow hooks during a
5
+ * service incident, a replay that did the wrong thing, support asking to rule
6
+ * it out — the user needs one switch that takes it out of the path *without*
7
+ * uninstalling, and without a restart of anything. Three spellings, checked in
8
+ * this order:
9
+ *
10
+ * BIR_OFF=1 in the environment: off (BIR_OFF=0: on,
11
+ * whatever the files say — for debugging)
12
+ * ~/.baseinstrunner/off a file: off everywhere
13
+ * ~/.baseinstrunner/off.d/<key> a file: off for one project (`controlKey`)
14
+ *
15
+ * `bir off [--here]` and `bir on [--here]` write and remove the files.
16
+ *
17
+ * What "off" means to each part (each part owns the details):
18
+ * - bir-proxy: a pure pipe to the real server — no recording, no schema
19
+ * change, no control server.
20
+ * - bir-hooks: every hook route answers `{}` at once, before touching the
21
+ * service or the transcript. The recorder stays up so the hooks keep
22
+ * getting that instant answer instead of a refused connection, which Claude
23
+ * Code would show as a hook error.
24
+ * - bir-scenario: lists no tools.
25
+ *
26
+ * The answer is cached for {@link CACHE_MS}, so a hook pays one `stat` a second
27
+ * at most, and a flip takes effect within a second.
28
+ */
29
+ /** The global flag file. */
30
+ export declare function offFlagPath(): string;
31
+ /** The per-project flag file for `cwd`. */
32
+ export declare function projectOffFlagPath(cwd: string): string;
33
+ export type OffSource = "env" | "global" | "project";
34
+ /**
35
+ * Why the runner is off for `cwd` (or globally, without one), or `null` when
36
+ * it is on. Never throws: an unreadable home directory means "on", because
37
+ * "off" must be something a user chose.
38
+ */
39
+ export declare function offSource(cwd?: string): OffSource | null;
40
+ /** True when the runner must stay out of the way for `cwd`. */
41
+ export declare function isRunnerOff(cwd?: string): boolean;
42
+ /** Write or remove a flag. `cwd` scopes it to one project. */
43
+ export declare function setRunnerOff(off: boolean, cwd?: string): void;
44
+ /** Tests only: forget cached answers. */
45
+ export declare function clearOffCache(): void;
46
+ //# sourceMappingURL=off-switch.d.ts.map
@@ -0,0 +1,91 @@
1
+ /**
2
+ * off-switch — make the whole runner inert, instantly and reversibly.
3
+ *
4
+ * When a session misbehaves and the runner might be why — slow hooks during a
5
+ * service incident, a replay that did the wrong thing, support asking to rule
6
+ * it out — the user needs one switch that takes it out of the path *without*
7
+ * uninstalling, and without a restart of anything. Three spellings, checked in
8
+ * this order:
9
+ *
10
+ * BIR_OFF=1 in the environment: off (BIR_OFF=0: on,
11
+ * whatever the files say — for debugging)
12
+ * ~/.baseinstrunner/off a file: off everywhere
13
+ * ~/.baseinstrunner/off.d/<key> a file: off for one project (`controlKey`)
14
+ *
15
+ * `bir off [--here]` and `bir on [--here]` write and remove the files.
16
+ *
17
+ * What "off" means to each part (each part owns the details):
18
+ * - bir-proxy: a pure pipe to the real server — no recording, no schema
19
+ * change, no control server.
20
+ * - bir-hooks: every hook route answers `{}` at once, before touching the
21
+ * service or the transcript. The recorder stays up so the hooks keep
22
+ * getting that instant answer instead of a refused connection, which Claude
23
+ * Code would show as a hook error.
24
+ * - bir-scenario: lists no tools.
25
+ *
26
+ * The answer is cached for {@link CACHE_MS}, so a hook pays one `stat` a second
27
+ * at most, and a flip takes effect within a second.
28
+ */
29
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ import { configDir, controlKey } from "../control/paths.js";
32
+ const CACHE_MS = 1_000;
33
+ /** The global flag file. */
34
+ export function offFlagPath() {
35
+ return join(configDir(), "off");
36
+ }
37
+ /** The per-project flag file for `cwd`. */
38
+ export function projectOffFlagPath(cwd) {
39
+ return join(configDir(), "off.d", controlKey(cwd));
40
+ }
41
+ const cache = new Map();
42
+ /**
43
+ * Why the runner is off for `cwd` (or globally, without one), or `null` when
44
+ * it is on. Never throws: an unreadable home directory means "on", because
45
+ * "off" must be something a user chose.
46
+ */
47
+ export function offSource(cwd) {
48
+ const env = process.env.BIR_OFF;
49
+ if (env === "1" || env === "true")
50
+ return "env";
51
+ if (env === "0" || env === "false")
52
+ return null;
53
+ const key = cwd ?? "";
54
+ const now = Date.now();
55
+ const hit = cache.get(key);
56
+ if (hit && now - hit.at < CACHE_MS)
57
+ return hit.source;
58
+ let source = null;
59
+ try {
60
+ if (existsSync(offFlagPath()))
61
+ source = "global";
62
+ else if (cwd && existsSync(projectOffFlagPath(cwd)))
63
+ source = "project";
64
+ }
65
+ catch {
66
+ source = null;
67
+ }
68
+ cache.set(key, { at: now, source });
69
+ return source;
70
+ }
71
+ /** True when the runner must stay out of the way for `cwd`. */
72
+ export function isRunnerOff(cwd) {
73
+ return offSource(cwd) !== null;
74
+ }
75
+ /** Write or remove a flag. `cwd` scopes it to one project. */
76
+ export function setRunnerOff(off, cwd) {
77
+ const path = cwd ? projectOffFlagPath(cwd) : offFlagPath();
78
+ if (off) {
79
+ mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
80
+ writeFileSync(path, `${new Date().toISOString()}\n`, { mode: 0o600 });
81
+ }
82
+ else {
83
+ rmSync(path, { force: true });
84
+ }
85
+ cache.clear();
86
+ }
87
+ /** Tests only: forget cached answers. */
88
+ export function clearOffCache() {
89
+ cache.clear();
90
+ }
91
+ //# sourceMappingURL=off-switch.js.map
@@ -0,0 +1,133 @@
1
+ /**
2
+ * service-health — one process-wide answer to "is the BaseIn service answering?"
3
+ *
4
+ * The runner's first rule (§1 goal 4) is that it is never the reason a session
5
+ * is slow or broken. The service is the one dependency that can break that rule
6
+ * without anything here being wrong: a blackholed host, a captive portal, a
7
+ * degraded API or a 30 s gateway timeout all turn every hook that *waits* for it
8
+ * into seconds of dead air, on every prompt and every tool call, for as long as
9
+ * the incident lasts.
10
+ *
11
+ * So waiting is a privilege the service earns by answering. Every service call
12
+ * reports here — {@link ServiceHealth.ok} on an answer, {@link ServiceHealth.fail}
13
+ * on a network error, a timeout, a 5xx, a 429 or a body that is not the API's —
14
+ * and every hot path asks {@link ServiceHealth.canWait} before it awaits one.
15
+ * After a failure the answer is "no" for a cool-down that doubles on each
16
+ * further failure (up to {@link MAX_COOLDOWN_MS}), so a dead service costs a
17
+ * hook one budget per cool-down at most, and usually nothing: background sends
18
+ * keep reporting, and they fail first. One answer closes it again — except the
19
+ * late answer of a request that was already running when a hot path gave up on
20
+ * the service ({@link ServiceHealth.slow}): that is the slowness, arriving,
21
+ * not a recovery.
22
+ *
23
+ * Nothing here retries or queues. It is a verdict, not a transport.
24
+ */
25
+ /** First cool-down after a failure. Doubles per consecutive failure. */
26
+ export declare const BASE_COOLDOWN_MS = 5000;
27
+ /** The cool-down never grows past this, so recovery is noticed within a minute. */
28
+ export declare const MAX_COOLDOWN_MS = 60000;
29
+ /**
30
+ * Default ceiling for one service request made off the hot path (a step
31
+ * upload, a refresh, a finish). `BIR_SERVICE_TIMEOUT_MS` overrides it.
32
+ *
33
+ * Without one, Node's fetch waits out the OS connect timeout on a blackholed
34
+ * host (10-20 s, 75 s on some systems) and up to 300 s on a server that accepts
35
+ * and never answers, holding a socket and a chain slot the whole time.
36
+ */
37
+ export declare const DEFAULT_SERVICE_TIMEOUT_MS = 10000;
38
+ export declare function serviceTimeoutMs(): number;
39
+ export interface HealthSnapshot {
40
+ /** False while a cool-down is running: hot paths do not wait. */
41
+ healthy: boolean;
42
+ consecutiveFailures: number;
43
+ /** Epoch ms the cool-down ends, when one is running. */
44
+ openUntil?: number;
45
+ lastError?: string;
46
+ lastErrorAt?: number;
47
+ lastOkAt?: number;
48
+ }
49
+ export interface ServiceHealthOptions {
50
+ baseCooldownMs?: number;
51
+ maxCooldownMs?: number;
52
+ /** Injected by tests. */
53
+ now?: () => number;
54
+ }
55
+ export declare class ServiceHealth {
56
+ private failures;
57
+ private openUntil;
58
+ /** When a hot path last gave up waiting ({@link slow}); 0 = never. */
59
+ private slowMark;
60
+ private lastError?;
61
+ private lastErrorAt?;
62
+ private lastOkAt?;
63
+ private readonly baseCooldownMs;
64
+ private readonly maxCooldownMs;
65
+ private readonly now;
66
+ constructor(opts?: ServiceHealthOptions);
67
+ /**
68
+ * May a hot path (a hook the user is waiting on) wait for the service now?
69
+ *
70
+ * False during a cool-down. After one ends the service is half-open: the next
71
+ * request finds out, and a failure then starts a longer cool-down. Electing
72
+ * that one request is the caller's business: RemoteRecorder sends one probe
73
+ * for all its lanes, and its hook-facing calls ask for a background one
74
+ * rather than probing themselves.
75
+ */
76
+ canWait(): boolean;
77
+ /** This verdict's clock, for {@link ok}'s `startedAt`. */
78
+ stamp(): number;
79
+ /** The first cool-down, for callers that scale their own pauses to it. */
80
+ get baseCooldown(): number;
81
+ /**
82
+ * The service answered. Closes any cool-down — unless `startedAt` (the
83
+ * {@link stamp} when the request that answered was sent) is before the last
84
+ * {@link slow} mark: a request already in flight when a hook gave up on the
85
+ * service answering late is the overrun itself, and closing on it made every
86
+ * next hook wait again. A request sent after the mark closes it as usual, and
87
+ * so does any answer after a `fail()` that no `slow()` followed. Without
88
+ * `startedAt` the answer closes it, as before.
89
+ */
90
+ ok(startedAt?: number): void;
91
+ /** The service failed to answer usefully. Starts (or extends) a cool-down. */
92
+ fail(reason: string): void;
93
+ /**
94
+ * A hot path gave up waiting on its budget. Counts as a failure, and marks
95
+ * the moment: an answer to a request sent before it does not end the
96
+ * cool-down ({@link ok}).
97
+ */
98
+ slow(what: string): void;
99
+ snapshot(): HealthSnapshot;
100
+ /** Tests only. */
101
+ reset(): void;
102
+ }
103
+ /** The process-wide instance every service caller reports to. */
104
+ export declare const serviceHealth: ServiceHealth;
105
+ /**
106
+ * Is this HTTP status the service being unwell, rather than answering "no"?
107
+ * 5xx, 408 and 429 are; a 4xx such as 401 or 404 is an answer.
108
+ */
109
+ export declare function isUnhealthyStatus(status: number): boolean;
110
+ /**
111
+ * Wait for `pending` on a hot path, but never longer than `budgetMs`, and not
112
+ * at all while the service is in a cool-down.
113
+ *
114
+ * Resolves `undefined` when it did not wait or the budget ran out; `pending`
115
+ * keeps running in the background (its rejection is observed here, so it can
116
+ * never become an unhandled rejection). A budget overrun is reported to
117
+ * `health` as a slow call, which is what makes the *next* hook free.
118
+ *
119
+ * `pending` should itself report `ok`/`fail` when it settles (RemoteRecorder
120
+ * does, with the time it sent the request, so its own late answer does not
121
+ * undo the overrun); this only adds the verdict a caller who stopped waiting
122
+ * can give.
123
+ */
124
+ export declare function awaitWithin<T>(pending: Promise<T>, budgetMs: number, what: string, health?: ServiceHealth): Promise<T | undefined>;
125
+ /**
126
+ * An AbortSignal that fires after `ms`, or when `parent` does.
127
+ *
128
+ * `AbortSignal.any` is Node 20.3+, and `engines` says 20, so this composes the
129
+ * two by hand. The timer is unref'd: a pending request must never be what
130
+ * keeps a process alive.
131
+ */
132
+ export declare function timeoutSignal(ms?: number, parent?: AbortSignal): AbortSignal;
133
+ //# sourceMappingURL=service-health.d.ts.map
@@ -0,0 +1,217 @@
1
+ /**
2
+ * service-health — one process-wide answer to "is the BaseIn service answering?"
3
+ *
4
+ * The runner's first rule (§1 goal 4) is that it is never the reason a session
5
+ * is slow or broken. The service is the one dependency that can break that rule
6
+ * without anything here being wrong: a blackholed host, a captive portal, a
7
+ * degraded API or a 30 s gateway timeout all turn every hook that *waits* for it
8
+ * into seconds of dead air, on every prompt and every tool call, for as long as
9
+ * the incident lasts.
10
+ *
11
+ * So waiting is a privilege the service earns by answering. Every service call
12
+ * reports here — {@link ServiceHealth.ok} on an answer, {@link ServiceHealth.fail}
13
+ * on a network error, a timeout, a 5xx, a 429 or a body that is not the API's —
14
+ * and every hot path asks {@link ServiceHealth.canWait} before it awaits one.
15
+ * After a failure the answer is "no" for a cool-down that doubles on each
16
+ * further failure (up to {@link MAX_COOLDOWN_MS}), so a dead service costs a
17
+ * hook one budget per cool-down at most, and usually nothing: background sends
18
+ * keep reporting, and they fail first. One answer closes it again — except the
19
+ * late answer of a request that was already running when a hot path gave up on
20
+ * the service ({@link ServiceHealth.slow}): that is the slowness, arriving,
21
+ * not a recovery.
22
+ *
23
+ * Nothing here retries or queues. It is a verdict, not a transport.
24
+ */
25
+ import { logLine } from "./log.js";
26
+ /** First cool-down after a failure. Doubles per consecutive failure. */
27
+ export const BASE_COOLDOWN_MS = 5_000;
28
+ /** The cool-down never grows past this, so recovery is noticed within a minute. */
29
+ export const MAX_COOLDOWN_MS = 60_000;
30
+ /**
31
+ * Default ceiling for one service request made off the hot path (a step
32
+ * upload, a refresh, a finish). `BIR_SERVICE_TIMEOUT_MS` overrides it.
33
+ *
34
+ * Without one, Node's fetch waits out the OS connect timeout on a blackholed
35
+ * host (10-20 s, 75 s on some systems) and up to 300 s on a server that accepts
36
+ * and never answers, holding a socket and a chain slot the whole time.
37
+ */
38
+ export const DEFAULT_SERVICE_TIMEOUT_MS = 10_000;
39
+ export function serviceTimeoutMs() {
40
+ const n = Number(process.env.BIR_SERVICE_TIMEOUT_MS);
41
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_SERVICE_TIMEOUT_MS;
42
+ }
43
+ export class ServiceHealth {
44
+ failures = 0;
45
+ openUntil = 0;
46
+ /** When a hot path last gave up waiting ({@link slow}); 0 = never. */
47
+ slowMark = 0;
48
+ lastError;
49
+ lastErrorAt;
50
+ lastOkAt;
51
+ baseCooldownMs;
52
+ maxCooldownMs;
53
+ now;
54
+ constructor(opts = {}) {
55
+ this.baseCooldownMs = opts.baseCooldownMs ?? BASE_COOLDOWN_MS;
56
+ this.maxCooldownMs = opts.maxCooldownMs ?? MAX_COOLDOWN_MS;
57
+ this.now = opts.now ?? Date.now;
58
+ }
59
+ /**
60
+ * May a hot path (a hook the user is waiting on) wait for the service now?
61
+ *
62
+ * False during a cool-down. After one ends the service is half-open: the next
63
+ * request finds out, and a failure then starts a longer cool-down. Electing
64
+ * that one request is the caller's business: RemoteRecorder sends one probe
65
+ * for all its lanes, and its hook-facing calls ask for a background one
66
+ * rather than probing themselves.
67
+ */
68
+ canWait() {
69
+ return this.failures === 0 || this.now() >= this.openUntil;
70
+ }
71
+ /** This verdict's clock, for {@link ok}'s `startedAt`. */
72
+ stamp() {
73
+ return this.now();
74
+ }
75
+ /** The first cool-down, for callers that scale their own pauses to it. */
76
+ get baseCooldown() {
77
+ return this.baseCooldownMs;
78
+ }
79
+ /**
80
+ * The service answered. Closes any cool-down — unless `startedAt` (the
81
+ * {@link stamp} when the request that answered was sent) is before the last
82
+ * {@link slow} mark: a request already in flight when a hook gave up on the
83
+ * service answering late is the overrun itself, and closing on it made every
84
+ * next hook wait again. A request sent after the mark closes it as usual, and
85
+ * so does any answer after a `fail()` that no `slow()` followed. Without
86
+ * `startedAt` the answer closes it, as before.
87
+ */
88
+ ok(startedAt) {
89
+ const now = this.now();
90
+ if (startedAt !== undefined && this.failures > 0 && startedAt < this.slowMark) {
91
+ this.lastOkAt = now;
92
+ return;
93
+ }
94
+ if (this.failures > 0)
95
+ logLine("service.recovered", { after: this.failures });
96
+ this.failures = 0;
97
+ this.openUntil = 0;
98
+ this.lastOkAt = now;
99
+ }
100
+ /** The service failed to answer usefully. Starts (or extends) a cool-down. */
101
+ fail(reason) {
102
+ this.failures += 1;
103
+ const cooldown = Math.min(this.maxCooldownMs, this.baseCooldownMs * 2 ** Math.min(this.failures - 1, 16));
104
+ const now = this.now();
105
+ this.openUntil = Math.max(this.openUntil, now + cooldown);
106
+ this.lastError = reason.slice(0, 200);
107
+ this.lastErrorAt = now;
108
+ // Logged on the transition only: an outage must not write a line per step.
109
+ if (this.failures === 1)
110
+ logLine("service.degraded", { reason: this.lastError, cooldown_ms: cooldown });
111
+ }
112
+ /**
113
+ * A hot path gave up waiting on its budget. Counts as a failure, and marks
114
+ * the moment: an answer to a request sent before it does not end the
115
+ * cool-down ({@link ok}).
116
+ */
117
+ slow(what) {
118
+ this.slowMark = this.now();
119
+ this.fail(`slow:${what}`);
120
+ }
121
+ snapshot() {
122
+ return {
123
+ healthy: this.canWait(),
124
+ consecutiveFailures: this.failures,
125
+ ...(this.failures > 0 ? { openUntil: this.openUntil } : {}),
126
+ ...(this.lastError !== undefined ? { lastError: this.lastError, lastErrorAt: this.lastErrorAt } : {}),
127
+ ...(this.lastOkAt !== undefined ? { lastOkAt: this.lastOkAt } : {}),
128
+ };
129
+ }
130
+ /** Tests only. */
131
+ reset() {
132
+ this.failures = 0;
133
+ this.openUntil = 0;
134
+ this.slowMark = 0;
135
+ this.lastError = undefined;
136
+ this.lastErrorAt = undefined;
137
+ this.lastOkAt = undefined;
138
+ }
139
+ }
140
+ /** The process-wide instance every service caller reports to. */
141
+ export const serviceHealth = new ServiceHealth();
142
+ /**
143
+ * Is this HTTP status the service being unwell, rather than answering "no"?
144
+ * 5xx, 408 and 429 are; a 4xx such as 401 or 404 is an answer.
145
+ */
146
+ export function isUnhealthyStatus(status) {
147
+ return status >= 500 || status === 408 || status === 429;
148
+ }
149
+ /**
150
+ * Wait for `pending` on a hot path, but never longer than `budgetMs`, and not
151
+ * at all while the service is in a cool-down.
152
+ *
153
+ * Resolves `undefined` when it did not wait or the budget ran out; `pending`
154
+ * keeps running in the background (its rejection is observed here, so it can
155
+ * never become an unhandled rejection). A budget overrun is reported to
156
+ * `health` as a slow call, which is what makes the *next* hook free.
157
+ *
158
+ * `pending` should itself report `ok`/`fail` when it settles (RemoteRecorder
159
+ * does, with the time it sent the request, so its own late answer does not
160
+ * undo the overrun); this only adds the verdict a caller who stopped waiting
161
+ * can give.
162
+ */
163
+ export async function awaitWithin(pending, budgetMs, what, health = serviceHealth) {
164
+ pending.catch(() => undefined);
165
+ if (budgetMs <= 0 || !health.canWait())
166
+ return undefined;
167
+ let timer;
168
+ const timedOut = Symbol("timeout");
169
+ try {
170
+ const winner = await Promise.race([
171
+ pending,
172
+ new Promise((resolve) => {
173
+ timer = setTimeout(() => resolve(timedOut), budgetMs);
174
+ timer.unref?.();
175
+ }),
176
+ ]);
177
+ if (winner === timedOut) {
178
+ health.slow(what);
179
+ return undefined;
180
+ }
181
+ return winner;
182
+ }
183
+ catch {
184
+ return undefined;
185
+ }
186
+ finally {
187
+ if (timer)
188
+ clearTimeout(timer);
189
+ }
190
+ }
191
+ /**
192
+ * An AbortSignal that fires after `ms`, or when `parent` does.
193
+ *
194
+ * `AbortSignal.any` is Node 20.3+, and `engines` says 20, so this composes the
195
+ * two by hand. The timer is unref'd: a pending request must never be what
196
+ * keeps a process alive.
197
+ */
198
+ export function timeoutSignal(ms = serviceTimeoutMs(), parent) {
199
+ const controller = new AbortController();
200
+ const timer = setTimeout(() => controller.abort(new DOMException(`timed out after ${ms} ms`, "TimeoutError")), ms);
201
+ timer.unref?.();
202
+ if (parent) {
203
+ if (parent.aborted) {
204
+ clearTimeout(timer);
205
+ controller.abort(parent.reason);
206
+ }
207
+ else {
208
+ parent.addEventListener("abort", () => {
209
+ clearTimeout(timer);
210
+ controller.abort(parent.reason);
211
+ }, { once: true });
212
+ }
213
+ }
214
+ controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
215
+ return controller.signal;
216
+ }
217
+ //# sourceMappingURL=service-health.js.map