@coreplane/switchboard 0.0.0 → 1.18.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 (131) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +18 -1
  3. package/dist/assets/.dockerignore +27 -0
  4. package/dist/assets/.env.example +33 -0
  5. package/dist/assets/Dockerfile +111 -0
  6. package/dist/assets/config/config.example.yaml +359 -0
  7. package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
  8. package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
  9. package/dist/assets/deploy/bin/cf-logs +32 -0
  10. package/dist/assets/deploy/cloudflare/package.json +29 -0
  11. package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
  12. package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
  13. package/dist/assets/deploy/cloudflare/worker.ts +382 -0
  14. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
  15. package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
  16. package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
  17. package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
  18. package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
  19. package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
  20. package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
  21. package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
  22. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
  23. package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
  24. package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
  25. package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
  26. package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
  27. package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
  28. package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
  29. package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
  30. package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
  31. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
  32. package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
  33. package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
  34. package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
  35. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
  36. package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
  37. package/dist/assets/deploy/profile.example.json +13 -0
  38. package/dist/assets/deploy/secrets.manifest.json +108 -0
  39. package/dist/assets/docker-entrypoint.sh +15 -0
  40. package/dist/assets/package-lock.json +18407 -0
  41. package/dist/assets/package.json +104 -0
  42. package/dist/assets/project.json +219 -0
  43. package/dist/assets/source.json +5 -0
  44. package/dist/assets/src/core/authz/actor.ts +100 -0
  45. package/dist/assets/src/core/authz/authorize.ts +169 -0
  46. package/dist/assets/src/core/authz/grants.ts +347 -0
  47. package/dist/assets/src/core/authz/policy.ts +281 -0
  48. package/dist/assets/src/core/authz/resource.ts +147 -0
  49. package/dist/assets/src/core/authz/types.ts +164 -0
  50. package/dist/assets/src/core/drain.ts +54 -0
  51. package/dist/assets/src/core/ingressTokens.ts +64 -0
  52. package/dist/assets/src/core/memory/engine.ts +115 -0
  53. package/dist/assets/src/core/memory/scorer.ts +147 -0
  54. package/dist/assets/src/core/memory/types.ts +120 -0
  55. package/dist/assets/src/core/normalizeSpans.ts +299 -0
  56. package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
  57. package/dist/assets/src/core/redact.ts +113 -0
  58. package/dist/assets/src/core/runEvents.ts +537 -0
  59. package/dist/assets/src/core/runFriction.ts +665 -0
  60. package/dist/assets/src/core/runLedger/decisions.ts +126 -0
  61. package/dist/assets/src/core/runLedger/types.ts +177 -0
  62. package/dist/assets/src/core/runRecord.ts +627 -0
  63. package/dist/assets/src/core/runShape.ts +61 -0
  64. package/dist/assets/src/core/schedules.ts +452 -0
  65. package/dist/assets/src/core/time/formatDuration.ts +61 -0
  66. package/dist/assets/src/core/trace/attrs.ts +203 -0
  67. package/dist/assets/src/core/trace/classify.ts +49 -0
  68. package/dist/assets/src/core/trace/clock.ts +6 -0
  69. package/dist/assets/src/core/trace/context.ts +9 -0
  70. package/dist/assets/src/core/trace/ids.ts +23 -0
  71. package/dist/assets/src/core/trace/partition.ts +235 -0
  72. package/dist/assets/src/core/trace/sinks.ts +68 -0
  73. package/dist/assets/src/core/trace/streamSpans.ts +163 -0
  74. package/dist/assets/src/core/trace/traceparent.ts +29 -0
  75. package/dist/assets/src/core/trace/tracer.ts +247 -0
  76. package/dist/assets/src/core/trace/types.ts +125 -0
  77. package/dist/assets/src/core/trace/workerTrace.ts +97 -0
  78. package/dist/assets/src/deploy/buildStamp.ts +93 -0
  79. package/dist/assets/src/deploy/liveGate.ts +203 -0
  80. package/dist/assets/src/deploy/profile.ts +162 -0
  81. package/dist/assets/src/deploy/restart.ts +393 -0
  82. package/dist/assets/src/effort.ts +17 -0
  83. package/dist/assets/src/execution/bashTimeout.ts +78 -0
  84. package/dist/assets/src/execution/bindingPurge.ts +43 -0
  85. package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
  86. package/dist/assets/src/execution/residentCleanliness.ts +95 -0
  87. package/dist/assets/src/execution/residentCredentials.ts +81 -0
  88. package/dist/assets/src/execution/residentDepCache.ts +321 -0
  89. package/dist/assets/src/execution/residentDepsStore.ts +326 -0
  90. package/dist/assets/src/execution/residentDetach.ts +48 -0
  91. package/dist/assets/src/execution/residentDisk.ts +107 -0
  92. package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
  93. package/dist/assets/src/execution/residentExecWrap.ts +100 -0
  94. package/dist/assets/src/execution/residentHead.ts +85 -0
  95. package/dist/assets/src/execution/residentReadonly.ts +72 -0
  96. package/dist/assets/src/execution/residentRefresh.ts +429 -0
  97. package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
  98. package/dist/assets/src/execution/residentState.ts +47 -0
  99. package/dist/assets/src/execution/residentStepReport.ts +98 -0
  100. package/dist/assets/src/execution/residentStepTrace.ts +97 -0
  101. package/dist/assets/src/execution/residentSteps.ts +99 -0
  102. package/dist/assets/src/execution/residentText.ts +83 -0
  103. package/dist/assets/src/execution/residentTrace.ts +119 -0
  104. package/dist/assets/src/execution/sandboxEnv.ts +42 -0
  105. package/dist/assets/src/execution/sandboxErrors.ts +159 -0
  106. package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
  107. package/dist/assets/src/execution/shellQuote.ts +8 -0
  108. package/dist/assets/src/mcp/registry.ts +242 -0
  109. package/dist/assets/src/providers/types.ts +152 -0
  110. package/dist/assets/web/dist/.vite/manifest.json +176 -0
  111. package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
  112. package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
  113. package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
  114. package/dist/assets/web/dist/assets/ResidentDetailPage-D3shEnzl.js +1 -0
  115. package/dist/assets/web/dist/assets/ResidentsIndexPage-DWIubQ05.js +1 -0
  116. package/dist/assets/web/dist/assets/RunRoutePage-BMjuE-oX.js +126 -0
  117. package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
  118. package/dist/assets/web/dist/assets/RunsIndexPage-C3_jYIo0.js +1 -0
  119. package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
  120. package/dist/assets/web/dist/assets/ScheduledPage-g1W58mtN.js +1 -0
  121. package/dist/assets/web/dist/assets/StatusDot-DcPRw3zu.js +1 -0
  122. package/dist/assets/web/dist/assets/Tooltip-DJUkMYjo.js +1 -0
  123. package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
  124. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
  125. package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
  126. package/dist/assets/web/dist/assets/main-CyM5f4JC.js +28 -0
  127. package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
  128. package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
  129. package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
  130. package/dist/cli.js +34494 -0
  131. package/package.json +43 -10
@@ -0,0 +1,47 @@
1
+ // The resident lifecycle vocabulary, shared by the resident Worker
2
+ // (deploy/cloudflare-resident/worker.ts, which owns the transitions) and the
3
+ // bot's executor selection (factory.ts, which decides what to do with a probed
4
+ // state). One definition, no runtime imports, so a renamed or added state is a
5
+ // compile error on BOTH sides instead of a silently changed gate.
6
+
7
+ export type ResidentLifecycleState = "onboarding" | "warm" | "refreshing" | "restoring" | "degraded" | "down";
8
+
9
+ /** States in which the resident serves the last snapshot and its /attach route
10
+ * refuses nothing: the bot attaches. `degraded` is serviceable only for the
11
+ * reason classes that leave the checkout intact — see `degradedIsServiceable`. */
12
+ export const SERVICEABLE_STATES: ReadonlySet<ResidentLifecycleState> = new Set<ResidentLifecycleState>([
13
+ "warm",
14
+ "refreshing",
15
+ "degraded",
16
+ ]);
17
+
18
+ // A `degraded` reason names its cause (docs/reference/specs/resident-repos.md item 7).
19
+ // Only reasons that PROVE the previous checkout + dep cache are intact attach:
20
+ // `github-unreachable: …` (the fetch failed before the checkout was touched)
21
+ // and `alarm-missed: …` (the watchdog re-armed a dead chain; nothing ran).
22
+ // `stale-mid-flight: …` does NOT qualify: it is stamped when a `refreshing`
23
+ // marker was orphaned by a cycle that died mid-flight, and that death may have
24
+ // been inside the rebuild lock section (after `git clean -fdx`, mid-install) —
25
+ // exactly the torn checkout this gate exists to avoid; the +5s recovery cycle
26
+ // rebuilds it within seconds anyway. A failure INSIDE the rebuild —
27
+ // `checkout-update-failed`, `install-failed`, `build-failed`,
28
+ // `snapshot-failed`, `refresh-failed` — leaves the checkout at a new sha with
29
+ // absent/partial deps, and a fresh thread would hardlink that broken cache.
30
+ // `disk-full: …` (`residentDisk.ts`) is not on the list on purpose: the
31
+ // checkout may be intact, but a full disk cannot take a worktree, a credential
32
+ // file, or even `/etc/gitconfig.lock`, so the attach would fail every time
33
+ // (recorded as `github-unreachable`, every run would attach and die at
34
+ // git-setup). Everything not on the allow-list, including unknown reasons,
35
+ // stays cold.
36
+ const SERVICEABLE_DEGRADED_REASON = /^(?:github-unreachable|alarm-missed)(?::|$)/;
37
+
38
+ export function degradedIsServiceable(reason: string | undefined): boolean {
39
+ return SERVICEABLE_DEGRADED_REASON.test(reason ?? "");
40
+ }
41
+
42
+ /** The bot's attach decision for a probed `{state, reason}`: true only for
43
+ * `warm`, `refreshing`, and a `degraded` whose reason proves the checkout intact. */
44
+ export function isServiceable(state: string, reason?: string): boolean {
45
+ if (!SERVICEABLE_STATES.has(state as ResidentLifecycleState)) return false;
46
+ return state !== "degraded" || degradedIsServiceable(reason);
47
+ }
@@ -0,0 +1,98 @@
1
+ import { redactSecrets, stripAnsi } from "../core/redact.js";
2
+
3
+ // How a failed resident step describes itself (docs/reference/specs/resident-repos.md
4
+ // item 53). Pure, so the shape is a unit test and not a live post-mortem.
5
+ //
6
+ // The rule this module exists to enforce: a failure report may never CHOOSE
7
+ // between the two streams. It used to — `tail(r.stderr || r.stdout)` — and
8
+ // that cost a whole diagnosis once: a resident went `down (provision-failed at
9
+ // install: exit 1: [WARN] The "pnpm" field in package.json is no longer read
10
+ // by pnpm …)`. That warning cannot fail an install: the same command prints
11
+ // those exact bytes on stderr and exits 0 (reproducible on the same tree
12
+ // inside the resident's own base image). The pnpm family reports through its
13
+ // own logger on STDOUT, so `stderr || stdout` let a harmless warning shadow
14
+ // the error that named the exit — and nothing else recorded the command's
15
+ // output, so the real cause was gone for good.
16
+ //
17
+ // Every stream a step can write is therefore reported, labelled, tail-first
18
+ // (a tool's error is its last output), each bounded on its own so one noisy
19
+ // stream cannot crowd out the other.
20
+
21
+ /** What the sandbox exec hands back for one command. */
22
+ export interface StepResult {
23
+ stdout: string;
24
+ stderr: string;
25
+ exitCode: number;
26
+ timedOut: boolean;
27
+ }
28
+
29
+ /** Chars kept per stream in the STORED reason — it travels into DO storage,
30
+ * `GET /residents`, `repo list` and a Slack reply, so it stays short. The
31
+ * full output goes to the Worker log instead (`stepFailureLog`). */
32
+ export const STEP_REPORT_PER_STREAM = 400;
33
+
34
+ /** Chars kept per stream in the LOG line: enough for a pnpm/vitest error
35
+ * block with its context, still bounded so one runaway step cannot flood
36
+ * the Worker's logs. */
37
+ export const STEP_LOG_PER_STREAM = 4000;
38
+
39
+ /** The last `budget` chars of `s`, marked with a leading `…` when cut. Empty
40
+ * (after trimming) yields "" so the caller can drop the label entirely. */
41
+ function tailOf(s: string, budget: number): string {
42
+ // Item 62: the tail lands in a stored reason and on a card — strip and
43
+ // redact BEFORE cutting, so a cut can never split a credential.
44
+ const trimmed = redactSecrets(stripAnsi(s)).trim();
45
+ if (trimmed.length <= budget) return trimmed;
46
+ return `…${trimmed.slice(-budget)}`;
47
+ }
48
+
49
+ function exitPhrase(r: StepResult): string {
50
+ return `exit ${r.exitCode}${r.timedOut ? " (timed out)" : ""}`;
51
+ }
52
+
53
+ /** The StepResult for a wait the SDK gave up on while the process was still
54
+ * alive (`ProcessWaitTimeoutError`: "Process output did not complete within
55
+ * <ms>ms") — after the caller killed it. It is the step's OWN timeout, shaped
56
+ * exactly like one the supervisor enforced (`timedOut: true` → "(timed out)"
57
+ * in the report), so `classifyRefreshFailure` files it as `<step>-failed`,
58
+ * never as an interruption. `exitCode` is the status observed after the
59
+ * kill, or -1 when the process still had not reported one: the report says
60
+ * which, and never invents a status. */
61
+ export function abandonedWaitStepResult(input: { detail: string; exitCode: number | null }): StepResult {
62
+ const exit = input.exitCode === null ? "killed, no exit status observed" : `killed, exit ${input.exitCode}`;
63
+ return {
64
+ stdout: "",
65
+ stderr: `${input.detail} — process was still running: ${exit}`,
66
+ exitCode: input.exitCode ?? -1,
67
+ timedOut: true,
68
+ };
69
+ }
70
+
71
+ /** The stored `provision-failed at <step>: <this>` / refresh-error detail.
72
+ *
73
+ * Throws when handed a success: the only correct caller is a failure branch,
74
+ * and a "failure" description of exit 0 would be a lie in the record. */
75
+ export function describeStepFailure(r: StepResult, perStream = STEP_REPORT_PER_STREAM): string {
76
+ if (r.exitCode === 0 && !r.timedOut) {
77
+ throw new Error(`describeStepFailure: not a failure (exit ${r.exitCode})`);
78
+ }
79
+ const parts: string[] = [];
80
+ const out = tailOf(r.stdout, perStream);
81
+ const err = tailOf(r.stderr, perStream);
82
+ if (out) parts.push(`stdout: ${out}`);
83
+ if (err) parts.push(`stderr: ${err}`);
84
+ return `${exitPhrase(r)}: ${parts.length > 0 ? parts.join("; ") : "no output"}`;
85
+ }
86
+
87
+ /** The operator's escape hatch: what `console.log` writes when a step fails,
88
+ * so the Worker log (observability is on for this Worker) holds the error
89
+ * block itself even when the stored reason only had room for its tail. */
90
+ export function stepFailureLog(step: string, r: StepResult, perStream = STEP_LOG_PER_STREAM): string {
91
+ const out = tailOf(r.stdout, perStream);
92
+ const err = tailOf(r.stderr, perStream);
93
+ return [
94
+ `step ${step} failed: ${exitPhrase(r)}`,
95
+ `--- stdout ---\n${out || "(empty)"}`,
96
+ `--- stderr ---\n${err || "(empty)"}`,
97
+ ].join("\n");
98
+ }
@@ -0,0 +1,97 @@
1
+ // The resident's own measurement of a request's steps (docs/reference/specs/tracing.md
2
+ // item 19; docs/reference/specs/resident-repos.md item 63): every command the Worker runs
3
+ // for one `/attach` or `/op` — clone, fetch, install, the mutex wait — as
4
+ // offsets from the request's start, handed back in the answer's `trace` so
5
+ // the bot grafts them under the span that made the call. A pure module both
6
+ // sides import: the Worker records, the bot re-validates (residentTrace.ts).
7
+ // No I/O, no clock of its own: the caller supplies every stamp.
8
+
9
+ /** One measured step, in the resident's own time as offsets from its request start. */
10
+ export type ResidentStep = {
11
+ /** The step's name — an identifier from the Worker's step vocabulary (`clone`,
12
+ * `install`, `mutex_wait`…); sanitized on both sides. */
13
+ name: string;
14
+ /** Milliseconds after the request started. */
15
+ startMs: number;
16
+ durationMs: number;
17
+ status: "ok" | "error";
18
+ exitCode?: number;
19
+ timedOut?: boolean;
20
+ /** `mutex_wait` only: how long the request waited for the mirror lock. */
21
+ waitedMs?: number;
22
+ };
23
+
24
+ /** The most steps one answer carries, and the most bytes: an attach is about
25
+ * a dozen steps, a full build a few dozen; past the cap the newest are kept. */
26
+ export const STEP_TRACE_MAX = 64;
27
+ export const STEP_TRACE_MAX_BYTES = 8 * 1024;
28
+ export const STEP_NAME_MAX = 32;
29
+
30
+ const STEP_NAME = /[^a-z0-9_-]+/g;
31
+
32
+ /** A step name as the trace carries it: lowercase `[a-z0-9_-]`, at most 32
33
+ * chars, `step` when nothing is left. */
34
+ export function sanitizeStepName(raw: unknown): string {
35
+ const s = String(raw ?? "")
36
+ .toLowerCase()
37
+ .replace(STEP_NAME, "-")
38
+ .replace(/^-+|-+$/g, "")
39
+ .slice(0, STEP_NAME_MAX);
40
+ return s || "step";
41
+ }
42
+
43
+ export interface StepTrace {
44
+ /** Record one finished command. */
45
+ record(
46
+ step: string,
47
+ stamps: { startedAt: number; endedAt: number; exitCode?: number; timedOut?: boolean; ok?: boolean },
48
+ ): void;
49
+ /** Record a wait for the mirror lock that ended at `endedAt`. */
50
+ mutexWait(waitedMs: number, endedAt: number): void;
51
+ /** The steps so far, bounded, as offsets from `t0`. */
52
+ steps(): ResidentStep[];
53
+ }
54
+
55
+ /** A collector for one request: `t0` is the request's start on the Worker's clock. */
56
+ export function createStepTrace(t0: number): StepTrace {
57
+ const steps: ResidentStep[] = [];
58
+ const sizes: number[] = [];
59
+ let bytes = 2; // the array's brackets
60
+ const push = (s: ResidentStep) => {
61
+ // Bounded by count and by bytes (a running total: each step's serialized
62
+ // size plus its comma); the newest steps are the ones a slow attach is
63
+ // about, so the oldest go first.
64
+ const size = JSON.stringify(s).length + 1;
65
+ steps.push(s);
66
+ sizes.push(size);
67
+ bytes += size;
68
+ while (steps.length > STEP_TRACE_MAX || bytes > STEP_TRACE_MAX_BYTES) {
69
+ steps.shift();
70
+ bytes -= sizes.shift() ?? 0;
71
+ }
72
+ };
73
+ return {
74
+ record(step, stamps) {
75
+ const ok = stamps.ok ?? ((stamps.exitCode === 0 || stamps.exitCode === undefined) && !stamps.timedOut);
76
+ push({
77
+ name: sanitizeStepName(step),
78
+ startMs: Math.max(0, Math.round(stamps.startedAt - t0)),
79
+ durationMs: Math.max(0, Math.round(stamps.endedAt - stamps.startedAt)),
80
+ status: ok ? "ok" : "error",
81
+ ...(stamps.exitCode !== undefined ? { exitCode: stamps.exitCode } : {}),
82
+ ...(stamps.timedOut ? { timedOut: true } : {}),
83
+ });
84
+ },
85
+ mutexWait(waitedMs, endedAt) {
86
+ const wait = Math.max(0, Math.round(waitedMs));
87
+ push({
88
+ name: "mutex_wait",
89
+ startMs: Math.max(0, Math.round(endedAt - wait - t0)),
90
+ durationMs: wait,
91
+ status: "ok",
92
+ waitedMs: wait,
93
+ });
94
+ },
95
+ steps: () => steps.map((s) => ({ ...s })),
96
+ };
97
+ }
@@ -0,0 +1,99 @@
1
+ // The resident's step vocabulary (docs/reference/specs/tracing.md item 15): every command
2
+ // the resident Worker runs is named here, and only here. The Worker's runners
3
+ // (`runOk`, `gitWithCred`, `buildUserRun`, `restoreExtracted` in
4
+ // deploy/cloudflare-resident/worker.ts) take a `ResidentStepName`, so a step
5
+ // the table does not know is a type error there; the run page's display table
6
+ // reads the labels, so no grafted step ever renders as `a Switchboard step`.
7
+ // Node-free: the Worker bundles this file.
8
+
9
+ export const RESIDENT_STEP_LABELS = {
10
+ clone: "cloning the repo",
11
+ "checkout-clone": "cloning the checkout",
12
+ "worktree-clone": "cloning the worktree",
13
+ "worktree-detach": "checking out the expected commit",
14
+ "op-clone": "cloning the op tree",
15
+ fetch: "fetching the branch",
16
+ "wake-fetch": "fetching the latest commits",
17
+ "reclaim-fetch": "fetching to reclaim the mirror",
18
+ "for-each-ref": "listing the branches",
19
+ checkout: "checking out the branch",
20
+ "checkout-update": "updating the checkout",
21
+ "rev-parse": "reading the commit",
22
+ "cat-file": "checking the mirror for the commit",
23
+ "show-ref": "reading the branch tip",
24
+ "detect-default-branch": "detecting the default branch",
25
+ git: "a git command",
26
+ "git-setup": "configuring git",
27
+ "stage-perms": "securing the credential stage",
28
+ install: "installing dependencies",
29
+ build: "building",
30
+ test: "running the tests",
31
+ "clear-markers": "clearing the build markers",
32
+ "lockfile-key": "hashing the lockfile",
33
+ "deps-store-dir": "preparing the dependency store",
34
+ "deps-scratch": "cloning a scratch tree for the dependency install",
35
+ "deps-install": "installing the dependency store",
36
+ "deps-harden": "locking down the installed dependencies",
37
+ "deps-commit": "recording the installed dependencies",
38
+ "deps-adopt": "adopting the dependency store",
39
+ "deps-restore-scratch": "restoring dependencies to scratch",
40
+ "deps-restore-chown": "setting the restored dependencies' owner",
41
+ "deps-restore-commit": "recording the restored dependencies",
42
+ "deps-scratch-chown": "setting the dependency scratch owner",
43
+ "deps-evict": "evicting an old dependency store",
44
+ "unlink-deps-view": "unlinking the dependency view",
45
+ "clear-installing-marker": "clearing the install marker",
46
+ chown: "setting the workspace owner",
47
+ "worktree-chown": "setting the worktree owner",
48
+ "op-chown": "setting the op tree's owner",
49
+ "worktree-clean": "cleaning the worktree",
50
+ "clean-workspace": "cleaning the workspace",
51
+ "clean-before-restore": "cleaning before the restore",
52
+ "unmount-restores": "unmounting earlier restores",
53
+ "mirror-restore-extract": "extracting the mirror snapshot",
54
+ "checkout-restore-extract": "extracting the checkout snapshot",
55
+ "deps-restore-extract": "extracting the dependency snapshot",
56
+ evict: "evicting a stale tree",
57
+ "thread-dir": "preparing a directory",
58
+ "threads-dir": "preparing a directory",
59
+ "op-dir": "preparing a directory",
60
+ "ops-dir": "preparing a directory",
61
+ "stage-dir": "preparing a directory",
62
+ stat: "checking a file",
63
+ touch: "touching a marker",
64
+ nproc: "counting CPUs",
65
+ mutex_wait: "waiting for the workspace",
66
+ kill: "stopping the previous step",
67
+ } as const satisfies Record<string, string>;
68
+
69
+ /** A step named in the table. */
70
+ export type ResidentStepLabelKey = keyof typeof RESIDENT_STEP_LABELS;
71
+
72
+ /** A build-user step sweeps the previous step's leftover processes first; that sweep is named after the step it precedes. */
73
+ export const STALE_SWEEP_SUFFIX = "-stale-sweep";
74
+
75
+ /** Every name the Worker's command runners accept: a table entry, or a table entry's stale sweep. */
76
+ export type ResidentStepName = ResidentStepLabelKey | `${ResidentStepLabelKey}${typeof STALE_SWEEP_SUFFIX}`;
77
+
78
+ export const RESIDENT_STEP_NAMES: readonly ResidentStepLabelKey[] = Object.keys(
79
+ RESIDENT_STEP_LABELS,
80
+ ) as ResidentStepLabelKey[];
81
+
82
+ function labelKeyOf(name: string): ResidentStepLabelKey | undefined {
83
+ return Object.prototype.hasOwnProperty.call(RESIDENT_STEP_LABELS, name) ? (name as ResidentStepLabelKey) : undefined;
84
+ }
85
+
86
+ /** The label for a step name, or undefined for a name outside the vocabulary. */
87
+ export function residentStepLabel(name: string): string | undefined {
88
+ const direct = labelKeyOf(name);
89
+ if (direct !== undefined) return RESIDENT_STEP_LABELS[direct];
90
+ if (name.endsWith(STALE_SWEEP_SUFFIX)) {
91
+ const base = labelKeyOf(name.slice(0, -STALE_SWEEP_SUFFIX.length));
92
+ if (base !== undefined) return `clearing leftovers before ${RESIDENT_STEP_LABELS[base]}`;
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ export function isResidentStepName(name: string): name is ResidentStepName {
98
+ return residentStepLabel(name) !== undefined;
99
+ }
@@ -0,0 +1,83 @@
1
+ /** The one place resident-supplied text is made safe to show.
2
+ *
3
+ * A resident's `reason`, `error` and `summary` strings are built from remote
4
+ * output (git, npm, the container's shell) and from other threads' identifiers
5
+ * (the disk-pressure refusal once listed them). They reach card titles, Slack
6
+ * replies, `repo list` and stored records, so every one of them crosses this
7
+ * module — on the resident at the write and the exit, and on the bot at the
8
+ * parse, permanently: a reason stored by an older resident survives that
9
+ * resident's deploy and its rollbacks.
10
+ *
11
+ * Node-free and import-light so the resident Worker imports it by relative
12
+ * path exactly like the bot does. */
13
+ import { redactAndCap, stripAnsi } from "../core/redact.js";
14
+ import type { ResidentLifecycleState } from "./residentState.js";
15
+
16
+ /** Cap for one displayed resident string. Long enough for the longest reason
17
+ * the resident legitimately builds — the disk-pressure refusal with its math,
18
+ * eviction count and keep tokens (~230 chars), which item 55 shows whole — and
19
+ * for the runtime-replaced guidance (~120); short enough for a card note. */
20
+ export const RESIDENT_TEXT_CAP = 300;
21
+
22
+ /** Strip terminal control sequences, redact credential shapes, cap. The identity
23
+ * on the discriminator literals the bot compares (`runtime-replaced`,
24
+ * `op-refused…`, `disk-pressure:`), pinned by test. */
25
+ export function residentText(text: string): string {
26
+ return redactAndCap(stripAnsi(text), RESIDENT_TEXT_CAP);
27
+ }
28
+
29
+ const SANITIZED_FIELDS = ["error", "reason", "summary"] as const;
30
+
31
+ /** How deep the sanitizer descends. The deepest resident shape today is
32
+ * `/residents` → `residents[]` → `live` → `reason` (depth 3); the bound exists
33
+ * so a hostile body cannot make the walk unbounded. */
34
+ const SANITIZE_DEPTH = 4;
35
+
36
+ /** A parsed resident body with its free-text fields made safe, at every level:
37
+ * `/residents` nests each resident's `state`/`reason` (or an `error`) under
38
+ * `residents[].live`, and `repo list` renders those. In each plain object only
39
+ * `error`, `reason` and `summary` are touched (when strings); `stderr` is
40
+ * rewritten only when it mirrors `error` (the thread routes' failure shape
41
+ * copies the error into stderr). Every other field — `needs`, `state`,
42
+ * `stdout`, `status`, bindings, numbers — passes through untouched. Arrays are
43
+ * walked; scalars pass through; the input is never mutated. */
44
+ export function sanitizeResidentBody<T>(data: T): T {
45
+ return walk(data, SANITIZE_DEPTH) as T;
46
+ }
47
+
48
+ function walk(value: unknown, depth: number): unknown {
49
+ if (value === null || typeof value !== "object" || depth < 0) return value;
50
+ if (Array.isArray(value)) return value.map((v) => walk(v, depth - 1));
51
+ const src = value as Record<string, unknown>;
52
+ const out: Record<string, unknown> = {};
53
+ for (const [key, v] of Object.entries(src)) {
54
+ out[key] =
55
+ (SANITIZED_FIELDS as readonly string[]).includes(key) && typeof v === "string"
56
+ ? residentText(v)
57
+ : walk(v, depth - 1);
58
+ }
59
+ if (typeof src.stderr === "string" && src.stderr === src.error && typeof out.error === "string") {
60
+ out.stderr = out.error;
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /** The states a probe may report: the resident's own lifecycle union plus the
66
+ * two the bot mints locally. Anything else — a future state, or free text from
67
+ * a hostile body — reads as `unknown`, which `isServiceable` refuses. */
68
+ export type ProbeState = ResidentLifecycleState | "not-onboarded" | "unknown";
69
+
70
+ const PROBE_STATES: ReadonlySet<string> = new Set<ProbeState>([
71
+ "onboarding",
72
+ "warm",
73
+ "refreshing",
74
+ "restoring",
75
+ "degraded",
76
+ "down",
77
+ "not-onboarded",
78
+ "unknown",
79
+ ]);
80
+
81
+ export function residentState(value: unknown): ProbeState {
82
+ return typeof value === "string" && PROBE_STATES.has(value) ? (value as ProbeState) : "unknown";
83
+ }
@@ -0,0 +1,119 @@
1
+ // The bot's side of the resident's step trace (docs/reference/specs/tracing.md item 19):
2
+ // an Anti-Corruption Layer at the parse boundary. Whatever the resident sent
3
+ // is rebuilt field by field from an allowlist — names sanitized, numbers
4
+ // finite and clamped, status a literal, error text dropped — and then grafted
5
+ // under the span that made the call, rebased to that span's start and clipped
6
+ // to now, so a skewed, oversized, orphaned, mis-named or hostile trace can
7
+ // only ever produce fewer, shorter, plainly-named spans.
8
+
9
+ import type { Span } from "../core/trace/types.js";
10
+ import type { SpanAttrs } from "../core/trace/attrs.js";
11
+ import { sanitizeStepName, STEP_TRACE_MAX, STEP_TRACE_MAX_BYTES, type ResidentStep } from "./residentStepTrace.js";
12
+
13
+ /** Every step the resident's answer carries, made safe. Anything that is not a
14
+ * well-formed step is dropped; the list is bounded like the Worker's. */
15
+ export function sanitizeGraftedSteps(raw: unknown): ResidentStep[] {
16
+ if (!Array.isArray(raw)) return [];
17
+ const out: ResidentStep[] = [];
18
+ for (const item of raw) {
19
+ if (typeof item !== "object" || item === null) continue;
20
+ const r = item as Record<string, unknown>;
21
+ if (!finite(r.startMs) || !finite(r.durationMs)) continue;
22
+ const step: ResidentStep = {
23
+ name: sanitizeStepName(r.name),
24
+ startMs: Math.max(0, Math.round(r.startMs)),
25
+ durationMs: Math.max(0, Math.round(r.durationMs)),
26
+ status: r.status === "error" ? "error" : "ok",
27
+ };
28
+ if (finite(r.exitCode) && Number.isInteger(r.exitCode) && r.exitCode >= 0 && r.exitCode <= 255) {
29
+ step.exitCode = r.exitCode;
30
+ }
31
+ if (r.timedOut === true) step.timedOut = true;
32
+ if (finite(r.waitedMs)) step.waitedMs = Math.max(0, Math.round(r.waitedMs));
33
+ out.push(step);
34
+ if (out.length > STEP_TRACE_MAX) out.shift();
35
+ }
36
+ while (out.length > 0 && JSON.stringify(out).length > STEP_TRACE_MAX_BYTES) out.shift();
37
+ return out;
38
+ }
39
+
40
+ function finite(v: unknown): v is number {
41
+ return typeof v === "number" && Number.isFinite(v);
42
+ }
43
+
44
+ /** The steps a failed request ran, pinned on the error it became. */
45
+ export interface ResidentTrace {
46
+ steps: ResidentStep[];
47
+ residentMs?: number;
48
+ }
49
+
50
+ const TRACE_OF = new WeakMap<object, ResidentTrace>();
51
+
52
+ /** Pin a failed request's sanitized steps on the error that reports it, so a
53
+ * caller that catches the error (or wraps it as a `cause`) can still graft
54
+ * them: a failed attach's trace is the one that says which step blew the
55
+ * budget. Returns the same error. */
56
+ export function withResidentTrace<E extends object>(err: E, trace: ResidentTrace): E {
57
+ TRACE_OF.set(err, trace);
58
+ return err;
59
+ }
60
+
61
+ /** The trace pinned on `err` or on one of its causes (to depth 5), if any. */
62
+ export function residentTraceOf(err: unknown): ResidentTrace | undefined {
63
+ let cur: unknown = err;
64
+ for (let depth = 0; depth < 5 && typeof cur === "object" && cur !== null; depth++) {
65
+ const found = TRACE_OF.get(cur);
66
+ if (found) return found;
67
+ cur = (cur as { cause?: unknown }).cause;
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ export interface GraftInput {
73
+ /** The span that made the call (`dispatch.workspace.attach`, `run.command`). */
74
+ parent: Span;
75
+ /** The graft prefix: the parent's own name, so the steps read `<parent>.<step>`. */
76
+ prefix: "dispatch.workspace.attach" | "run.command" | "resident";
77
+ /** The bot's stamp for the request's start — the parent span's start. */
78
+ baseAt: number;
79
+ /** The bot's now: no grafted span ends after it. */
80
+ clipAt: number;
81
+ /** The resident's own total for the request (`attachMs`, `durationMs`), for
82
+ * `clockSkewMs`: the bot's wait minus this, signed (negative = the clocks
83
+ * disagree). */
84
+ residentTotalMs?: number;
85
+ }
86
+
87
+ /** Graft sanitized steps under the parent: each becomes `<prefix>.<name>`,
88
+ * rebased so the resident's request start is the parent's start and clipped
89
+ * to `clipAt`. Returns how many landed. `clockSkewMs` — the difference between
90
+ * what the bot saw and what the resident measured (network and overhead) —
91
+ * goes on the parent. */
92
+ export function graftResidentSteps(steps: readonly ResidentStep[], input: GraftInput): number {
93
+ const { parent, prefix, baseAt, clipAt } = input;
94
+ let grafted = 0;
95
+ for (const step of steps) {
96
+ const startedAt = Math.min(clipAt, baseAt + step.startMs);
97
+ const endedAt = Math.min(clipAt, startedAt + step.durationMs);
98
+ const attrs: SpanAttrs = {
99
+ backend: "resident",
100
+ ...(step.exitCode !== undefined ? { exitCode: step.exitCode } : {}),
101
+ ...(step.timedOut ? { timedOut: true } : {}),
102
+ ...(step.waitedMs !== undefined ? { waitedMs: step.waitedMs } : {}),
103
+ };
104
+ parent.graft(`${prefix}.${step.name}`, {
105
+ startedAt,
106
+ endedAt,
107
+ status: step.status,
108
+ attrs,
109
+ ...(step.status === "error" ? { errorKind: "infra" as const } : {}),
110
+ });
111
+ grafted++;
112
+ }
113
+ if (input.residentTotalMs !== undefined) {
114
+ // Signed on purpose: negative when the resident measured more than the bot
115
+ // waited, i.e. the two clocks disagree — as worth seeing as the overhead.
116
+ parent.setAttrs({ clockSkewMs: Math.max(0, clipAt - baseAt) - Math.max(0, input.residentTotalMs) });
117
+ }
118
+ return grafted;
119
+ }
@@ -0,0 +1,42 @@
1
+ // The env map a per-thread sandbox request carries (docs/reference/specs/execution.md
2
+ // item 5): ONE reader, shared by the sandbox Worker and its tests. Deliberately
3
+ // free of node: imports so wrangler can bundle it into the Worker, like
4
+ // bashTimeout.ts, shellQuote.ts and sandboxErrors.ts.
5
+ //
6
+ // Why the body and not headers: Workers Logs record every invocation's request
7
+ // HEADERS and redact them by a name heuristic — a header named like a token
8
+ // (`x-env-gh_token`) shows as REDACTED, but any other env name
9
+ // (`x-env-PROBE_VAR: hello`) is logged in clear. Request bodies are not
10
+ // recorded. So the executor sends the map as `env` in the JSON body on every
11
+ // route and the Worker reads it from there — the ONLY channel. Request headers
12
+ // are never a credential channel.
13
+
14
+ /** A shell identifier: what an env NAME must be after upper-casing. Same rule
15
+ * as the resident Worker's `ENV_NAME_RE`; anything else is dropped, never
16
+ * interpolated. */
17
+ export const ENV_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
18
+
19
+ /** The validated env map for one request, read from `body.env` alone (an
20
+ * object of string values). Names are upper-cased and must match
21
+ * `ENV_NAME_PATTERN`; non-string values and an `env` that is not a plain
22
+ * object are dropped. Request headers are never read — Workers Logs record
23
+ * them (see above). Never throws — a malformed request yields `{}`. */
24
+ export function envFromRequest(req: { body: unknown }): Record<string, string> {
25
+ const out: Record<string, string> = {};
26
+ const env = isPlainObject(req.body) ? req.body.env : undefined;
27
+ if (isPlainObject(env)) {
28
+ for (const [name, value] of Object.entries(env)) put(out, name, value);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ function put(out: Record<string, string>, rawName: string, value: unknown): void {
34
+ if (typeof value !== "string") return;
35
+ const name = rawName.toUpperCase();
36
+ if (!ENV_NAME_PATTERN.test(name)) return;
37
+ out[name] = value;
38
+ }
39
+
40
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
41
+ return typeof v === "object" && v !== null && !Array.isArray(v);
42
+ }