@coreplane/switchboard 1.251.0 → 1.252.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 (74) hide show
  1. package/dist/assets/config/config.example.yaml +4 -2
  2. package/dist/assets/deploy/cloudflare/preflight.mjs +19 -21
  3. package/dist/assets/deploy/cloudflare/worker.ts +6 -3
  4. package/dist/assets/deploy/cloudflare-memory/worker.ts +77 -12
  5. package/dist/assets/deploy/cloudflare-resident/memoryGuard.ts +212 -0
  6. package/dist/assets/deploy/cloudflare-resident/refresh.ts +1 -1
  7. package/dist/assets/deploy/cloudflare-resident/worker.ts +317 -56
  8. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +4 -2
  9. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.d.mts +31 -0
  10. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.mjs +119 -0
  11. package/dist/assets/package-lock.json +3 -3
  12. package/dist/assets/package.json +3 -2
  13. package/dist/assets/project.json +13 -9
  14. package/dist/assets/source.json +3 -3
  15. package/dist/assets/src/agents/registry.ts +5 -5
  16. package/dist/assets/src/core/budgets.ts +22 -0
  17. package/dist/assets/src/core/coordinator/contract.ts +42 -0
  18. package/dist/assets/src/core/coordinator/driver.ts +134 -10
  19. package/dist/assets/src/core/drain.ts +50 -0
  20. package/dist/assets/src/core/memory/engine.ts +98 -0
  21. package/dist/assets/src/core/memory/scorer.ts +12 -4
  22. package/dist/assets/src/core/memory/types.ts +69 -12
  23. package/dist/assets/src/core/modelCard.ts +32 -4
  24. package/dist/assets/src/core/modelPricing.ts +111 -1
  25. package/dist/assets/src/core/modelProxy/usage.ts +88 -0
  26. package/dist/assets/src/core/modelRegistry.ts +15 -1
  27. package/dist/assets/src/core/refusal.ts +4 -7
  28. package/dist/assets/src/core/reviewVerdict.ts +4 -0
  29. package/dist/assets/src/core/runEvents.ts +51 -2
  30. package/dist/assets/src/core/runFriction.ts +7 -2
  31. package/dist/assets/src/core/runLedger/types.ts +11 -0
  32. package/dist/assets/src/core/runUsage.ts +67 -13
  33. package/dist/assets/src/core/schedules.ts +3 -0
  34. package/dist/assets/src/core/ship/contract.ts +41 -14
  35. package/dist/assets/src/core/ship/coordinator.ts +380 -53
  36. package/dist/assets/src/core/ship/renewal.ts +10 -5
  37. package/dist/assets/src/core/trace/attrs.ts +24 -0
  38. package/dist/assets/src/core/types.ts +5 -5
  39. package/dist/assets/src/core/verbosity.ts +48 -0
  40. package/dist/assets/src/deploy/liveGate.ts +40 -13
  41. package/dist/assets/src/deploy/restart.ts +11 -12
  42. package/dist/assets/src/execution/residentDepCache.ts +50 -1
  43. package/dist/assets/src/execution/residentDepsStore.ts +40 -2
  44. package/dist/assets/src/execution/residentRefresh.ts +55 -3
  45. package/dist/assets/src/execution/residentSteps.ts +4 -0
  46. package/dist/assets/src/execution/sandboxErrors.ts +8 -0
  47. package/dist/assets/web/dist/.vite/manifest.json +55 -55
  48. package/dist/assets/web/dist/assets/{DeliveryPage-DUXd-Sl-.js → DeliveryPage-3ELQWM0r.js} +1 -1
  49. package/dist/assets/web/dist/assets/HomePage-BG_ok-K2.js +2 -0
  50. package/dist/assets/web/dist/assets/{PendingTurnRow-DDhMhrI7.js → PendingTurnRow-ChCQOLgZ.js} +1 -1
  51. package/dist/assets/web/dist/assets/{ResidentDetailPage-BnEoOnGQ.js → ResidentDetailPage-C9y3nbo8.js} +1 -1
  52. package/dist/assets/web/dist/assets/{ResidentsIndexPage-Dxpgf-l-.js → ResidentsIndexPage-i1RG9e7g.js} +1 -1
  53. package/dist/assets/web/dist/assets/RunFoldRow-D3wVpzBa.js +1 -0
  54. package/dist/assets/web/dist/assets/{RunRoutePage-9klVWhSF.js → RunRoutePage-B3IirUVi.js} +4 -4
  55. package/dist/assets/web/dist/assets/RunsIndexPage-DiFmtGaJ.js +1 -0
  56. package/dist/assets/web/dist/assets/{ScheduledPage-B_GgeJrb.js → ScheduledPage-DvYwM2TE.js} +1 -1
  57. package/dist/assets/web/dist/assets/{SettingsPage-BXX4R113.js → SettingsPage-Bo6yCyXZ.js} +1 -1
  58. package/dist/assets/web/dist/assets/{StatusDot-BOaw8le9.js → StatusDot-CAfS1AUi.js} +1 -1
  59. package/dist/assets/web/dist/assets/{Tooltip-DYZZ4l4V.js → Tooltip-tZoum_T-.js} +1 -1
  60. package/dist/assets/web/dist/assets/{UnitRoutePage-BaSW5Odq.js → UnitRoutePage-BmdOHwNn.js} +1 -1
  61. package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +1 -0
  62. package/dist/assets/web/dist/assets/{dist-BCVXeBJ9.js → dist-DfbEpHXR.js} +1 -1
  63. package/dist/assets/web/dist/assets/indexRow-BT0cPVRw.js +1 -0
  64. package/dist/assets/web/dist/assets/{main-Dkcbtu3u.js → main-5Gm_1Gv8.js} +2 -2
  65. package/dist/assets/web/dist/assets/sseReplay-DmyMXfRC.js +11 -0
  66. package/dist/cli.js +2470 -902
  67. package/package.json +1 -1
  68. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.sh +0 -68
  69. package/dist/assets/web/dist/assets/HomePage-mSiqEEcN.js +0 -2
  70. package/dist/assets/web/dist/assets/RunFoldRow-CSo4-vld.js +0 -1
  71. package/dist/assets/web/dist/assets/RunsIndexPage-BplMIgaw.js +0 -1
  72. package/dist/assets/web/dist/assets/budgets-BvWYKPsY.js +0 -1
  73. package/dist/assets/web/dist/assets/indexRow-Bde9OZxG.js +0 -1
  74. package/dist/assets/web/dist/assets/sseReplay-DPwdsaok.js +0 -9
@@ -17,6 +17,10 @@ export interface AttrDomain {
17
17
  status: "completed" | "failed" | "refused" | "stopped";
18
18
  queuedBeforeMs: number;
19
19
  queuedBehindMs: number;
20
+ /** The run this request restarts (a restart from its request, run-history
21
+ * item 54): the page names the predecessor by id instead of inventing a
22
+ * wait out of its lifetime. On the root at start, like the queued numbers. */
23
+ restartOfRunId: string;
20
24
  runId: string;
21
25
  // slack.receive
22
26
  caughtUp: boolean;
@@ -60,6 +64,14 @@ export interface AttrDomain {
60
64
  outputTokens: number;
61
65
  cacheReadTokens: number;
62
66
  cacheWriteTokens: number;
67
+ /** The meter row (model-proxy.md item 6): who bills the turn (the
68
+ * block), the vendor the card names, the turn's dollars with the layer
69
+ * that priced them, and on a BYOK turn the aggregator's fee (inside `usd`). */
70
+ biller: string;
71
+ vendor: string;
72
+ usd: number;
73
+ feeUsd: number;
74
+ priceSource: "provider" | "operator" | "registry" | "none";
63
75
  ttftMs: number;
64
76
  thinkingMs: number;
65
77
  textMs: number;
@@ -103,6 +115,8 @@ export interface AttrDomain {
103
115
  signal: "SIGTERM" | "SIGINT" | "other";
104
116
  channels: number;
105
117
  missed: number;
118
+ /** Catch-up candidates a receipt (or a fresh verdict) silenced — read, never re-run (slack-channel.md item 7). */
119
+ silenced: number;
106
120
  orphans: number;
107
121
  skipped: number;
108
122
  runs: number;
@@ -125,6 +139,7 @@ export type SpanAttrs = { readonly [K in SpanAttrKey]?: AttrDomain[K] };
125
139
  * from a closed table, never free text (no whitespace, no `?`/`&`, at most 64
126
140
  * chars). */
127
141
  const IDENTIFIER_KEYS: ReadonlySet<SpanAttrKey> = new Set<SpanAttrKey>([
142
+ "restartOfRunId",
128
143
  "runId",
129
144
  "outcome",
130
145
  "refusal",
@@ -135,6 +150,8 @@ const IDENTIFIER_KEYS: ReadonlySet<SpanAttrKey> = new Set<SpanAttrKey>([
135
150
  "callId",
136
151
  "agent",
137
152
  "model",
153
+ "biller",
154
+ "vendor",
138
155
  ]);
139
156
  const IDENTIFIER_RE = /^[A-Za-z0-9_./:@+-]{1,64}$/;
140
157
  /** A Workflow instance id: the platform's rule (`^[a-zA-Z0-9_][a-zA-Z0-9-_]*$`, at most 100). */
@@ -172,6 +189,7 @@ const ATTR_TYPE: Record<SpanAttrKey, "string" | "number" | "boolean"> = {
172
189
  status: "string",
173
190
  queuedBeforeMs: "number",
174
191
  queuedBehindMs: "number",
192
+ restartOfRunId: "string",
175
193
  runId: "string",
176
194
  caughtUp: "boolean",
177
195
  files: "number",
@@ -195,6 +213,11 @@ const ATTR_TYPE: Record<SpanAttrKey, "string" | "number" | "boolean"> = {
195
213
  outputTokens: "number",
196
214
  cacheReadTokens: "number",
197
215
  cacheWriteTokens: "number",
216
+ biller: "string",
217
+ vendor: "string",
218
+ usd: "number",
219
+ feeUsd: "number",
220
+ priceSource: "string",
198
221
  ttftMs: "number",
199
222
  thinkingMs: "number",
200
223
  textMs: "number",
@@ -230,6 +253,7 @@ const ATTR_TYPE: Record<SpanAttrKey, "string" | "number" | "boolean"> = {
230
253
  signal: "string",
231
254
  channels: "number",
232
255
  missed: "number",
256
+ silenced: "number",
233
257
  orphans: "number",
234
258
  skipped: "number",
235
259
  runs: "number",
@@ -230,13 +230,13 @@ export interface UploadTicket {
230
230
  /** What the core needs from a channel to serve one request. */
231
231
  /** What a channel shows for a confirmation (`ChannelIO.offer`): the id its
232
232
  * affordance carries back, the full command line to run, the one risk line
233
- * (empty when the command declares none), the footer naming the scope that
234
- * asked, and when the offer expires (ms epoch, the config object's clock). */
233
+ * (empty when the command declares none), and when the offer expires (ms
234
+ * epoch, the config object's clock). No footer: the button is the affordance,
235
+ * and which scope asked is an operator's fact (`config show` names it). */
235
236
  export interface ConfirmationOffer {
236
237
  id: string;
237
238
  line: string;
238
239
  risk: string;
239
- footer: string;
240
240
  expiresAt: number;
241
241
  /** Present on a question's offer (record 0054): the refusal's sentence,
242
242
  * shown above the line, and the evidence naming the match, shown under it.
@@ -286,8 +286,8 @@ export interface ChannelIO {
286
286
  /**
287
287
  * Show the confirmation a routed write is offered as
288
288
  * (docs/reference/specs/routing-and-config.md item 25, record 0044): the full
289
- * command line the router bound, its one risk line, the footer naming the
290
- * scope that asked, and the id the channel's affordance carries back to
289
+ * command line the router bound, its one risk line, and the id the
290
+ * channel's affordance carries back to
291
291
  * `dispatchClick` — a button whose value is the id, on a channel with
292
292
  * components. Optional; a channel without it (the CLI, an HTTP reply, the
293
293
  * browser) is answered the pasteable line instead, and nothing below the
@@ -0,0 +1,48 @@
1
+ // Verbosity: how much of itself the bot says in the conversation
2
+ // (docs/reference/specs/routing-and-config.md item 28). Three levels on the
3
+ // log-level pattern — each level shows everything the ones below it show:
4
+ //
5
+ // quiet (default) only what needs the person: answers, results, verdicts,
6
+ // refusals, questions, and the card's own progress.
7
+ // verbose plus every acknowledgement of what the system is doing
8
+ // for the person — a follow-up folded into a live run,
9
+ // a plan handed to the runner, the workspace the run is
10
+ // on, a budget clipped by a boundary.
11
+ // debug plus what an operator debugging the bot reads — the
12
+ // router's reason, the ledger's word on the run.
13
+ //
14
+ // A config dimension resolved through the same layers as `effort` (item 2):
15
+ // a `verbosity:<level>` directive on the request (sticky in the thread from
16
+ // the user's turns, item 3) > the user's scope > the channel's > `defaults`.
17
+ // Nothing here decides WHAT a message is; each message site names the level
18
+ // it belongs to, and the spec's table is the one list of them.
19
+
20
+ export const VERBOSITY_LEVELS = ["quiet", "verbose", "debug"] as const;
21
+ export type Verbosity = (typeof VERBOSITY_LEVELS)[number];
22
+
23
+ /** The level a request runs at when no layer sets one. */
24
+ export const DEFAULT_VERBOSITY: Verbosity = "quiet";
25
+
26
+ /** The valid levels, for error messages: `quiet, verbose, debug`. */
27
+ export const VERBOSITY_LEVELS_HINT = VERBOSITY_LEVELS.join(", ");
28
+
29
+ export function isVerbosity(value: unknown): value is Verbosity {
30
+ return typeof value === "string" && (VERBOSITY_LEVELS as readonly string[]).includes(value);
31
+ }
32
+
33
+ /** Whether a message that belongs at `floor` is shown at `level`: a level
34
+ * shows its own messages and every lower level's. */
35
+ export function shows(level: Verbosity, floor: Verbosity): boolean {
36
+ return VERBOSITY_LEVELS.indexOf(level) >= VERBOSITY_LEVELS.indexOf(floor);
37
+ }
38
+
39
+ /** The level for a request through the layers, most specific first; the
40
+ * default when no layer sets one. */
41
+ export function resolveVerbosity(layers: {
42
+ request?: Verbosity;
43
+ user?: Verbosity;
44
+ channel?: Verbosity;
45
+ defaults?: Verbosity;
46
+ }): Verbosity {
47
+ return layers.request ?? layers.user ?? layers.channel ?? layers.defaults ?? DEFAULT_VERBOSITY;
48
+ }
@@ -1,4 +1,4 @@
1
- import { COLD_START_ALLOWANCE_MS, DRAIN_DEADLINE_MS } from "../core/drain.js";
1
+ import { COLD_START_ALLOWANCE_MS, DRAIN_DEADLINE_MS, heldRunsText, type HeldRun } from "../core/drain.js";
2
2
 
3
3
  // "Deployed" is not "live". `wrangler deploy` uploads a Worker version and
4
4
  // starts a container rollout, but the OLD bot container keeps serving while it
@@ -17,6 +17,8 @@ export interface HealthzBody {
17
17
  inFlight?: unknown;
18
18
  draining?: unknown;
19
19
  drainStartedAt?: unknown;
20
+ /** While draining: the registry-active runs holding the drain (`HeldRun[]`). */
21
+ held?: unknown;
20
22
  build?: { commit?: unknown; builtAt?: unknown } | unknown;
21
23
  /** ISO process start — `deploy restart`'s identity (the image, hence `build.commit`, is unchanged). */
22
24
  startedAt?: unknown;
@@ -53,7 +55,8 @@ export type ReadyDecision<Identity> =
53
55
  export type LiveDecision = ReadyDecision<{ commit: string }>;
54
56
  export type RestartDecision = ReadyDecision<{ startedAt: string }>;
55
57
 
56
- function servedCommit(body: HealthzBody): string | undefined {
58
+ /** The `build.commit` a Worker's `/healthz` reports — the commit it serves; undefined when absent. */
59
+ export function servedCommit(body: HealthzBody): string | undefined {
57
60
  const b = body.build;
58
61
  if (typeof b !== "object" || b === null) return undefined;
59
62
  const c = (b as { commit?: unknown }).commit;
@@ -84,14 +87,35 @@ function decideReady<Identity>(
84
87
  const verdict = identify(body);
85
88
  if (verdict.live) return { kind: "live", ...verdict.identity };
86
89
  if (body.draining === true && !verdict.sameIdentity) {
87
- const n = typeof body.inFlight === "number" ? body.inFlight : "?";
88
90
  const since = typeof body.drainStartedAt === "string" ? ` since ${body.drainStartedAt}` : "";
89
- reason = `old container still draining — ${n} run(s) in flight${since}`;
91
+ // What is actually held, when the body says: the registry-active run ids
92
+ // and why. The dispatcher's `inFlight` can read 0 while a registry row
93
+ // holds the drain for its full deadline — a count that mystifies the
94
+ // operator watching the wait; the ids do not.
95
+ const held = heldRuns(body);
96
+ if (held.length > 0) {
97
+ reason = `old container still draining — holding ${held.length} registry-active run(s): ${heldRunsText(held)}${since}`;
98
+ } else {
99
+ const n = typeof body.inFlight === "number" ? body.inFlight : "?";
100
+ reason = `old container still draining — ${n} run(s) in flight${since}`;
101
+ }
90
102
  } else reason = verdict.reason;
91
103
  }
92
104
  return elapsedMs >= deadlineMs ? { kind: "timeout", reason } : { kind: "waiting", reason };
93
105
  }
94
106
 
107
+ /** The `held` rows of a `/healthz` body (src/channels/health.ts): the
108
+ * registry-active runs holding the drain. Rows that are not `{id, why}`
109
+ * strings — an older container's body, or no drain — parse to none. */
110
+ export function heldRuns(body: HealthzBody): HeldRun[] {
111
+ if (!Array.isArray(body.held)) return [];
112
+ return body.held.flatMap((r: unknown) => {
113
+ if (typeof r !== "object" || r === null) return [];
114
+ const { id, why } = r as { id?: unknown; why?: unknown };
115
+ return typeof id === "string" && typeof why === "string" ? [{ id, why }] : [];
116
+ });
117
+ }
118
+
95
119
  /** Two commit identities name the same commit when one is a prefix of the other
96
120
  * (≥7 chars) — `git rev-parse HEAD` vs. a short form; a `-dirty` suffix never matches. */
97
121
  export function sameCommit(a: string, b: string): boolean {
@@ -195,25 +219,28 @@ export interface GaveUpWords {
195
219
  force: string;
196
220
  }
197
221
 
198
- /** `deploy all`'s words: the release job is re-run once the runs finish. */
222
+ /** `deploy all`'s words: the release job is re-run once what refuses clears.
223
+ * In-flight bot runs never refuse (they hand off — run-history item 39); what
224
+ * a bot refusal names is a rollout still settling, and a resident refusal is
225
+ * its runs in flight — which `--force` over the resident would kill. */
199
226
  export const DEPLOY_GAVE_UP_WORDS: GaveUpWords = {
200
227
  notDone: "NOT deployed",
201
- rerun: "re-run the deploy once they finish (a CI job: `gh run rerun RUN_ID --failed`)",
202
- force: "--force to deploy over them (kills the runs in flight that no resume recovers)",
228
+ rerun: "re-run the deploy once it clears (a CI job: `gh run rerun RUN_ID --failed`)",
229
+ force: "--force to deploy over it (kills any resident runs in flight that no resume recovers)",
203
230
  };
204
231
 
205
- /** `deploy restart`'s words. */
232
+ /** `deploy restart`'s words. Its refusals are the fail-closed cases only —
233
+ * runs in flight hand off and never refuse. */
206
234
  export const RESTART_GAVE_UP_WORDS: GaveUpWords = {
207
235
  notDone: "NOT restarted",
208
- rerun: "re-run `deploy restart` once they finish",
209
- force: "--force to stop over them (kills the runs in flight that no resume recovers)",
236
+ rerun: "re-run `deploy restart` once the bot answers",
237
+ force: "--force to stop blind",
210
238
  };
211
239
 
212
240
  /** The failure a preflight still refusing at the end of the wait budget
213
241
  * produces. The wait is only how long to hold before failing: it never ends
214
- * in a deploy over what refused (a rolled container kills the runs it drives,
215
- * and a handoff is a recovery, not a guarantee), so the line names the budget,
216
- * the refusal, that nothing was done, and the two ways forward. */
242
+ * in a deploy over what refused, so the line names the budget, the refusal,
243
+ * that nothing was done, and the two ways forward. */
217
244
  export function preflightGaveUpLine(
218
245
  waitMaxMs: number,
219
246
  reason: string,
@@ -53,21 +53,20 @@ export const RESTART_SCOPE = "deploy:write";
53
53
  export interface RestartVerdict {
54
54
  allow: boolean;
55
55
  forced: boolean;
56
- /** What refuses: runs in flight (a stop rolls the container under them), and the fail-closed cases (no JSON body, an impossible count). */
56
+ /** What refuses (fail-closed: no JSON body, an impossible count). */
57
57
  problems: string[];
58
- /** What is said but does not refuse: a drain under way with nothing in flight. */
58
+ /** What is said but does not refuse: runs in flight (they hand off), a drain under way. */
59
59
  warnings: string[];
60
60
  message: string;
61
61
  }
62
62
  /**
63
63
  * Whether the container may be stopped now — the deploy preflight's rules
64
64
  * (deploy/cloudflare/preflight.mjs `decide`) minus the rollout-state check (a
65
- * restart is not a rollout). Runs in flight REFUSE: the stop rolls the
66
- * container under them, and the handoff (docs/reference/specs/run-history.md
67
- * item 39) is a recovery the next generation may fail, not a guarantee — the
68
- * CLI waits the 409 out instead. A drain under way with nothing in flight is a
69
- * warning. Fail closed on a body that is not JSON or an impossible count;
70
- * `force` allows everything anyway, flagged, naming what it kills.
65
+ * restart is not a rollout). Since the handoff (docs/reference/specs/run-history.md item
66
+ * 39) runs in flight and a drain under way are WARNINGS, not refusals: SIGTERM
67
+ * hands every resumable run to the next generation. Fail closed on a body
68
+ * that is not JSON or an impossible count; `force` allows those anyway, with
69
+ * the warning.
71
70
  */
72
71
  export function decideRestart(body: HealthzBody | undefined, opts: { force: boolean }): RestartVerdict {
73
72
  const problems: string[] = [];
@@ -80,8 +79,8 @@ export function decideRestart(body: HealthzBody | undefined, opts: { force: bool
80
79
  if (!Number.isInteger(body.inFlight) || (body.inFlight as number) < 0) {
81
80
  problems.push(`bot reports an impossible inFlight=${JSON.stringify(body.inFlight)} (counter bug or old Worker)`);
82
81
  } else if ((body.inFlight as number) > 0) {
83
- problems.push(
84
- `${body.inFlight} run(s) in flight — a stop rolls the bot container under them (a handoff is a recovery, not a guarantee)`,
82
+ warnings.push(
83
+ `${body.inFlight} run(s) in flight — handed to the next generation on SIGTERM (run-history item 39); they continue there`,
85
84
  );
86
85
  }
87
86
  if (body.draining === true)
@@ -99,14 +98,14 @@ export function decideRestart(body: HealthzBody | undefined, opts: { force: bool
99
98
  forced: true,
100
99
  problems,
101
100
  warnings,
102
- message: `restart WARNING: stopping by force despite —\n${detail}\n this WILL kill the runs in flight that no resume recovers`,
101
+ message: `restart WARNING: stopping by force despite —\n${detail}`,
103
102
  };
104
103
  return {
105
104
  allow: false,
106
105
  forced: false,
107
106
  problems,
108
107
  warnings,
109
- message: `restart REFUSED —\n${detail}\n wait for them to finish and retry, or pass --force to stop over them`,
108
+ message: `restart REFUSED —\n${detail}\n wait and retry, or pass --force to stop blind`,
110
109
  };
111
110
  }
112
111
 
@@ -17,6 +17,7 @@
17
17
  * fully owned by the thread user, overwritable, and still isolated from the
18
18
  * warm checkout (a copy shares nothing). */
19
19
 
20
+ import { nestedNodeModulesListCmd } from "./residentDepsStore.js";
20
21
  import { shellQuote } from "./shellQuote.js";
21
22
 
22
23
  export const DEP_CACHE_DIRS = ["node_modules", "dist", "build", "out", ".next"] as const;
@@ -217,6 +218,10 @@ export interface DepCacheScriptParse {
217
218
  * node_modules) it left in place because the source has no counterpart —
218
219
  * tree-private entries, not shared inodes (see `mutableCacheSwapScript`). */
219
220
  skipped: string[];
221
+ /** Tree-relative nested node_modules dirs the script hardlinked from the
222
+ * store entry (`deploy/w/node_modules`): each needs its own tool-cache
223
+ * swap, scoped to that root. */
224
+ nested: string[];
220
225
  failedStep: string | null;
221
226
  }
222
227
 
@@ -273,6 +278,47 @@ export function depCacheScript(
273
278
  `fi`,
274
279
  ].join("\n");
275
280
  });
281
+ // A store-backed view also materializes every NESTED node_modules the entry
282
+ // carries (item 59: the entry holds everything the install produced under a
283
+ // node_modules — npm installs a workspace's conflicting versions into the
284
+ // workspace's own node_modules, and a tree without them drifts from its
285
+ // lockfile: hoisted packages lose their dependent and `npm ls --omit=dev`
286
+ // misattributes their subtrees to production). Same mechanism per dir as the
287
+ // top-level one — cp -al, the combined chown/harden walk, the mutable-cache
288
+ // listing — gated on the tree having the parent dir (a worktree at another
289
+ // sha may not) and nothing already there. Each hardlinked dir is named on a
290
+ // `nested=<tree-relative path>` line so the Worker swaps its tool caches.
291
+ const entryRoot = opts.nodeModulesSrc?.endsWith("/node_modules")
292
+ ? opts.nodeModulesSrc.slice(0, -"/node_modules".length)
293
+ : undefined;
294
+ if (entryRoot) {
295
+ const entryQ = shellQuote(entryRoot);
296
+ const wtQ = shellQuote(worktree);
297
+ const nestedWalk = `find "$dst" \\( -type d -exec chown ${owner} {} + \\) -o \\( -type f \\( -perm -g+w -o -perm -o+w \\) -exec chmod go-w {} + \\)`;
298
+ blocks.push(
299
+ [
300
+ `if test -d ${entryQ}; then`,
301
+ ` ${nestedNodeModulesListCmd(entryRoot)} | while IFS= read -r rel; do`,
302
+ ` src=${entryQ}/"$rel"`,
303
+ ` dst=${wtQ}/"$rel"`,
304
+ ` pdir="\${dst%/node_modules}"`,
305
+ ` if test -d "$pdir" && ! test -e "$dst"; then`,
306
+ ` if cp -al "$src" "$dst"; then`,
307
+ ` ${nestedWalk} || { echo err=deps-perms; exit 1; }`,
308
+ ` if ! mlist=$(find "$dst" -mindepth 1 \\( -path "$dst/.*" -o -type d -name .cache \\)); then echo err=deps-mutable-list; exit 1; fi`,
309
+ ` if [ -n "$mlist" ]; then printf '%s\\n' "$mlist" | sed 's/^/mutable=/'; fi`,
310
+ ` echo "nested=$rel"`,
311
+ ` else`,
312
+ ` rm -rf "$dst"`,
313
+ ` cp -R "$src" "$dst" || { echo err=deps-copy; exit 1; }`,
314
+ ` chown -Rh ${owner} "$dst" || { echo err=deps-copy-chown; exit 1; }`,
315
+ ` fi`,
316
+ ` fi`,
317
+ ` done || exit 1`,
318
+ `fi`,
319
+ ].join("\n"),
320
+ );
321
+ }
276
322
  return blocks.join("\n");
277
323
  }
278
324
 
@@ -280,6 +326,7 @@ export function parseDepCacheScriptOutput(stdout: string): DepCacheScriptParse {
280
326
  let mech: DepCacheMaterialization | "none" = "none";
281
327
  const mutableListing: string[] = [];
282
328
  const skipped: string[] = [];
329
+ const nested: string[] = [];
283
330
  let failedStep: string | null = null;
284
331
  for (const raw of stdout.split("\n")) {
285
332
  const line = raw.trim();
@@ -292,11 +339,13 @@ export function parseDepCacheScriptOutput(stdout: string): DepCacheScriptParse {
292
339
  mutableListing.push(m[1]);
293
340
  } else if ((m = /^skipped=(.+)$/.exec(line))) {
294
341
  skipped.push(m[1]);
342
+ } else if ((m = /^nested=(.+)$/.exec(line))) {
343
+ nested.push(m[1]);
295
344
  } else if ((m = /^err=(.+)$/.exec(line))) {
296
345
  failedStep ??= m[1];
297
346
  }
298
347
  }
299
- return { mech, mutableListing, skipped, failedStep };
348
+ return { mech, mutableListing, skipped, nested, failedStep };
300
349
  }
301
350
 
302
351
  /** The per-path swaps for a hardlinked node_modules' tool-managed entries
@@ -115,11 +115,30 @@ export function planDepsMaterialization(input: {
115
115
  /** The checkout snapshot leaves out its top-level node_modules: since item 59
116
116
  * that directory is a hardlink view of an immutable store entry, and the
117
117
  * entry has its own backup (below). Nested node_modules (a workspace package's
118
- * own) stay in — small, and the view mechanism does not cover them. The
118
+ * own) stay in — small, and adoption of an old snapshot re-reads them. The
119
119
  * pattern is anchored at the archive root (mksquashfs wildcard semantics:
120
120
  * a bare name matches only there; `...`-prefixed patterns match anywhere). */
121
121
  export const CHECKOUT_SNAPSHOT_EXCLUDES: readonly string[] = ["node_modules"];
122
122
 
123
+ /** The entry archive is the entry dir itself minus its markers: `.complete`
124
+ * must be written by the restore's own commit, LAST, never extracted — a
125
+ * partial download that carried the marker would read as a complete entry. */
126
+ export const DEPS_ENTRY_BACKUP_EXCLUDES: readonly string[] = [".complete", ".used"];
127
+
128
+ /** List every OUTERMOST node_modules under `rootDir` EXCEPT the top-level
129
+ * one, tree-relative (`deploy/w/node_modules`), one per line. npm installs a
130
+ * workspace's conflicting versions into the workspace's own node_modules
131
+ * (the lockfile names those paths), so an entry or a view that carries only
132
+ * the top-level dir leaves the tree short of what its lockfile mandates —
133
+ * packages hoisted for a nested dependent then read as extraneous, and
134
+ * `npm ls --omit=dev` misattributes their subtrees to production (the
135
+ * licenses:check LGPL failure that named this). `-prune` keeps copies
136
+ * nested INSIDE a listed node_modules with their parent; `.git` is never a
137
+ * source of entries. */
138
+ export function nestedNodeModulesListCmd(rootDir: string): string {
139
+ return `(cd ${shellQuote(rootDir)} && find . \\( -path ./node_modules -o -name .git \\) -prune -o -name node_modules -type d -prune -print | sed 's|^\\./||')`;
140
+ }
141
+
123
142
  /** One backup per lockfile key, taken ONCE right after the entry is committed
124
143
  * (install or adoption) and never again: the entry is immutable, so its
125
144
  * archive is too. Recorded on the DO under this prefix, by key. */
@@ -194,10 +213,20 @@ export function depsHardenScript(input: {
194
213
  emptyOk: boolean;
195
214
  }): string {
196
215
  const nm = shellQuote(`${input.scratchDir}/node_modules`);
216
+ const scratch = shellQuote(input.scratchDir);
197
217
  const absent = input.emptyOk
198
218
  ? `mkdir ${nm} && chown ${input.owner} ${nm}`
199
219
  : `echo "install produced no node_modules in ${input.scratchDir}" >&2; exit 1`;
200
- return [`set -e`, `test -d ${nm} || { ${absent}; }`, `find ${nm} -type f -perm -u+w -exec chmod u-w {} +`].join("\n");
220
+ return [
221
+ `set -e`,
222
+ `test -d ${nm} || { ${absent}; }`,
223
+ `find ${nm} -type f -perm -u+w -exec chmod u-w {} +`,
224
+ // Nested workspace node_modules become entry content too (the commit
225
+ // script moves them), so their inodes are hardened the same way.
226
+ `${nestedNodeModulesListCmd(input.scratchDir)} | while IFS= read -r rel; do`,
227
+ ` find ${scratch}/"$rel" -type f -perm -u+w -exec chmod u-w {} + || exit 1`,
228
+ `done`,
229
+ ].join("\n");
201
230
  }
202
231
 
203
232
  /** Commit an install to the store, as root, in the order that makes the entry
@@ -221,6 +250,7 @@ export function depsStoreCommitScript(input: {
221
250
  keepScratch?: boolean;
222
251
  }): string {
223
252
  const scratchNm = shellQuote(`${input.scratchDir}/node_modules`);
253
+ const scratchDirQ = shellQuote(input.scratchDir);
224
254
  const staging = shellQuote(input.stagingDir);
225
255
  const stagingNm = shellQuote(`${input.stagingDir}/node_modules`);
226
256
  const entry = shellQuote(input.entryDir);
@@ -232,6 +262,14 @@ export function depsStoreCommitScript(input: {
232
262
  `rm -rf ${staging}`,
233
263
  `mkdir ${staging}`,
234
264
  `mv ${scratchNm} ${stagingNm}`,
265
+ // Every outermost NESTED node_modules moves too, at its tree-relative
266
+ // path (the top-level one is already in staging, so the walk never sees
267
+ // it): the entry must hold everything the install produced under a
268
+ // node_modules, or every view of it drifts from its lockfile.
269
+ `${nestedNodeModulesListCmd(input.scratchDir)} | while IFS= read -r rel; do`,
270
+ ` mkdir -p ${staging}/"$(dirname "$rel")" || exit 1`,
271
+ ` mv ${scratchDirQ}/"$rel" ${staging}/"$rel" || exit 1`,
272
+ `done`,
235
273
  // An entry dir WITHOUT its marker is crash debris (the shell died between
236
274
  // the rename and the touch): remove it, or the rename below would nest
237
275
  // the new staging inside it and the touch would mark the pair complete.
@@ -93,15 +93,67 @@ export function planRefresh(input: {
93
93
  * through shared inodes (review 1b). Pruned at any depth (workspaces). */
94
94
  export const NODE_MODULES_CACHE_DIRS = [".cache", ".vite"] as const;
95
95
 
96
- /** The checkout-update shell for the build user (runs inside the checkout):
97
- * fetch from the local mirror, hard-reset to `sha`, then the `-x` clean.
96
+ /** The one command of the rebuild that READS THE MIRROR — run as the build
97
+ * user in the staging tree, inside the stage lock (`stageCheckoutScript`'s
98
+ * lease): an attach's `fetch --prune` into the mirror could otherwise delete
99
+ * a ref out from under this fetch's negotiation. Everything after it (reset,
100
+ * clean, build) touches only the staging tree and runs outside the lock. */
101
+ export const CHECKOUT_FETCH_COMMAND = "git fetch --quiet origin";
102
+
103
+ /** The directories of the staged rebuild (resident-repos.md item 47): the
104
+ * warm checkout, the staging tree the rebuild happens in, and the retired
105
+ * path the replaced checkout waits at between the swap's rename and its
106
+ * removal. All on one filesystem, so every move below is a rename. */
107
+ export interface CheckoutSwapDirs {
108
+ checkout: string;
109
+ staging: string;
110
+ retired: string;
111
+ }
112
+
113
+ /** Root shell that seeds the staging tree, under the mirror lock — the one
114
+ * hardlink copy of the checkout the lock still covers, exactly
115
+ * materializeThreadDeps' consistency guarantee. First the self-heal: a
116
+ * container killed between the swap's two renames leaves no checkout and a
117
+ * complete retired tree, which is moved back before anything else reads the
118
+ * checkout path. Then a previous attempt's leftovers go — the caller already
119
+ * dropped the staging tree OFF the lock (a failed build leaves hundreds of
120
+ * thousands of inodes; the tree is private, so its removal needs no lock),
121
+ * leaving this `rm` a near-instant residual guard; the retired tree's
122
+ * heal-then-remove ordering is what needs the lock. Last, `cp -al` takes
123
+ * the copy (hardlinks — seconds, never a byte copy; run by root, ownership
124
+ * preserved). */
125
+ export function stageCheckoutScript(dirs: CheckoutSwapDirs): string {
126
+ const { checkout, staging, retired } = dirs;
127
+ return (
128
+ `if [ ! -d ${checkout}/.git ] && [ -d ${retired}/.git ]; then mv ${retired} ${checkout}; fi && ` +
129
+ `rm -rf ${staging} ${retired} && cp -al ${checkout} ${staging}`
130
+ );
131
+ }
132
+
133
+ /** Root shell that swaps the built staging tree into the checkout path, under
134
+ * the mirror lock: two renames — milliseconds, so an attach's 60 s wait is
135
+ * never spent on a build. If the second rename fails the retired tree is put
136
+ * back (the checkout path is never left empty for a copier) and the step
137
+ * fails; the retired tree itself is removed by the caller AFTER the lock is
138
+ * released, so the lock is never held for a large `rm`. */
139
+ export function swapCheckoutScript(dirs: CheckoutSwapDirs): string {
140
+ const { checkout, staging, retired } = dirs;
141
+ return (
142
+ `mv ${checkout} ${retired} && mv ${staging} ${checkout} || ` +
143
+ `{ if [ ! -d ${checkout} ] && [ -d ${retired}/.git ]; then mv ${retired} ${checkout}; fi; exit 1; }`
144
+ );
145
+ }
146
+
147
+ /** The checkout-update shell for the build user (runs inside the staging
148
+ * tree, OUTSIDE the mirror lock — the fetch is `CHECKOUT_FETCH_COMMAND`,
149
+ * under the stage lock): hard-reset to `sha`, then the `-x` clean.
98
150
  * `-e node_modules` is a git exclude pattern (matches at any depth, so
99
151
  * workspace packages keep theirs too) that survives `-x`; everything else
100
152
  * gitignored — build output above all — is still removed so the build
101
153
  * allocates fresh inodes (review 1b). Keep-deps additionally sweeps the
102
154
  * build-written caches inside node_modules (NODE_MODULES_CACHE_DIRS). */
103
155
  export function checkoutUpdateCommand(sha: string, clean: CleanScope): string {
104
- const base = `git fetch --quiet origin && git reset --hard --quiet ${sha}`;
156
+ const base = `git reset --hard --quiet ${sha}`;
105
157
  if (clean === "all") return `${base} && git clean -fdx`;
106
158
  const names = NODE_MODULES_CACHE_DIRS.map((d) => `-name ${d}`).join(" -o ");
107
159
  const sweep = `find . -path '*/node_modules/*' -type d \\( ${names} \\) -prune -exec rm -rf {} +`;
@@ -18,6 +18,10 @@ export const RESIDENT_STEP_LABELS = {
18
18
  "for-each-ref": "listing the branches",
19
19
  checkout: "checking out the branch",
20
20
  "checkout-update": "updating the checkout",
21
+ "checkout-stage": "staging the checkout rebuild",
22
+ "checkout-stage-clear": "removing the leftover staging tree",
23
+ "checkout-swap": "swapping in the rebuilt checkout",
24
+ "checkout-retire": "removing the replaced checkout",
21
25
  "rev-parse": "reading the commit",
22
26
  "cat-file": "checking the mirror for the commit",
23
27
  "show-ref": "reading the branch tip",
@@ -344,6 +344,14 @@ export function runtimeUnreachableExecAnswer(message: string): {
344
344
  /** The named reason the Worker answers with, beside `fleet-busy` and `sandbox-starting`. */
345
345
  export const RUNTIME_BUSY_REASON = "runtime-busy" as const;
346
346
 
347
+ /** The resident's own word for a container near its cgroup memory cap
348
+ * (resident-repos.md item 70): a new attach above the soft threshold and a
349
+ * new exec above the hard one are refused with this token — the same 503
350
+ * shape as `mirror-busy`, so the bot falls back or waits legibly while the
351
+ * commands already running finish. Defined here, beside the other machine
352
+ * tokens both sides read, so the Worker and the bot cannot drift. */
353
+ export const MEMORY_PRESSURE_REASON = "memory-pressure" as const;
354
+
347
355
  /** What the token means, in the words the model and the operator see. */
348
356
  export const RUNTIME_BUSY_EXPLANATION =
349
357
  "the thread's sandbox container is running but did not accept the connection inside the platform's allowance — nothing ran, the request is re-sent once it accepts";