@coreplane/switchboard 1.228.0 → 1.230.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 (38) hide show
  1. package/dist/assets/config/config.example.yaml +17 -13
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +56 -29
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +80 -36
  4. package/dist/assets/package-lock.json +2400 -26
  5. package/dist/assets/package.json +3 -2
  6. package/dist/assets/source.json +3 -3
  7. package/dist/assets/src/agents/registry.ts +4 -34
  8. package/dist/assets/src/core/authz/actor.ts +24 -6
  9. package/dist/assets/src/core/authz/policy.ts +8 -0
  10. package/dist/assets/src/core/authz/resource.ts +3 -1
  11. package/dist/assets/src/core/authz/types.ts +10 -1
  12. package/dist/assets/src/core/chatMessage.ts +1 -1
  13. package/dist/assets/src/core/coordinator/contract.ts +19 -0
  14. package/dist/assets/src/core/runEvents.ts +38 -15
  15. package/dist/assets/src/core/runFriction.ts +5 -4
  16. package/dist/assets/src/core/runLedger/sessionLog.ts +13 -0
  17. package/dist/assets/src/core/runRecord.ts +33 -0
  18. package/dist/assets/src/core/trace/attrs.ts +3 -0
  19. package/dist/assets/src/core/trace/streamSpans.ts +4 -1
  20. package/dist/assets/src/execution/residentDepCache.ts +34 -8
  21. package/dist/assets/src/execution/residentRebind.ts +27 -4
  22. package/dist/assets/web/dist/.vite/manifest.json +20 -20
  23. package/dist/assets/web/dist/assets/CostsPage-B08bdD-1.js +2 -0
  24. package/dist/assets/web/dist/assets/{ResidentDetailPage-B1Q9pabX.js → ResidentDetailPage-DYxDruS0.js} +1 -1
  25. package/dist/assets/web/dist/assets/{ResidentsIndexPage-CP7U_4aK.js → ResidentsIndexPage-DIBRBmuq.js} +1 -1
  26. package/dist/assets/web/dist/assets/RunRoutePage-B4s8hJlW.js +13 -0
  27. package/dist/assets/web/dist/assets/{RunsIndexPage-Cgp4t4C8.js → RunsIndexPage-CaW07KOf.js} +1 -1
  28. package/dist/assets/web/dist/assets/{ScheduledPage-DthDA2xG.js → ScheduledPage-Cr7269s9.js} +1 -1
  29. package/dist/assets/web/dist/assets/{StatusDot-DDc88Kbs.js → StatusDot-lCLi16zY.js} +1 -1
  30. package/dist/assets/web/dist/assets/{Tooltip-CBapNhsh.js → Tooltip-DKMPwNyy.js} +1 -1
  31. package/dist/assets/web/dist/assets/{dist-BnwSD1cL.js → dist-DRkTB2rt.js} +1 -1
  32. package/dist/assets/web/dist/assets/main-CAVqMbiX.css +1 -0
  33. package/dist/assets/web/dist/assets/{main-ZhQGbZ2E.js → main-CXKOiCOK.js} +2 -2
  34. package/dist/cli.js +8166 -8242
  35. package/package.json +1 -2
  36. package/dist/assets/web/dist/assets/CostsPage-5pl3HB2F.js +0 -2
  37. package/dist/assets/web/dist/assets/RunRoutePage-dCC25f_b.js +0 -12
  38. package/dist/assets/web/dist/assets/main-xLAsdkfB.css +0 -1
@@ -317,19 +317,13 @@ workspaceDir: ./workspaces
317
317
  # maxRounds: 3 # review rounds per unit
318
318
  # maxMinutes: 120 # the ship preset's wall-clock budget in minutes (the registry's default)
319
319
 
320
- # Which loop drives each preset's runs (docs/reference/specs/harness-pi.md):
321
- # `native`, the in-process turn loop, or `pi`, the pi coding agent started
322
- # inside the run's own execution container and driven over its RPC protocol —
323
- # with the run's model-proxy bearer as its only key, every event bridged onto
324
- # the run's own record, our tools and tool rules as its extension. Every
325
- # preset declares `native` until the pi series moves it; a deployment moves a
326
- # preset early here. A preset with a workspace (coding, review, explore) runs
327
- # pi in the run's container; a preset without one (general, research,
328
- # conductor) runs it as a child of the bot, with none of pi's own shell or
329
- # file tools. A preset the registry does not know, or a value that is
330
- # neither harness, fails the load by name.
331
- # harness:
332
- # coding: pi
320
+ # `harness` is a retired key (docs/reference/specs/harness-pi.md item 1). It
321
+ # moved the presets onto pi one at a time; every preset runs on pi now — the pi
322
+ # coding agent started inside the run's own execution container (or as a child
323
+ # of the bot for a preset without a workspace) with the run's model-proxy
324
+ # bearer as its only key — and there is no other loop. A block written during
325
+ # the series still loads when every value is `pi`; `native` names the deleted
326
+ # loop and fails the load by name, as does an unknown preset. Remove the block.
333
327
 
334
328
  # What the harness writes into pi's per-run settings for every run on pi
335
329
  # (docs/reference/specs/harness-pi.md item 4): pi's compaction thresholds, in
@@ -369,6 +363,16 @@ workspaceDir: ./workspaces
369
363
  # model: anthropic/claude-haiku-4-5
370
364
  # answer: text
371
365
 
366
+ # Linked threads (docs/decisions/0037-a-linked-thread-is-quoted-not-joined.md;
367
+ # docs/reference/specs/routing-and-config.md item 22). Off by default. On, a
368
+ # permalink in a request to a thread in another PUBLIC channel the bot is a
369
+ # member of is quoted onto the request turn as an untrusted block — text only,
370
+ # at most three per request, never a private channel from elsewhere, never a
371
+ # DM or a Slack Connect channel — and the run's record and page show what was
372
+ # quoted. A bot older than 1.228.0 refuses a config that carries this key.
373
+ # references:
374
+ # enabled: true
375
+
372
376
  # Spend reporting: GET /costs (Access-gated). Optional. See docs/reference/specs/costs.md.
373
377
  # costs:
374
378
  # cloudflareAccountId: <32-hex account id>
@@ -51,6 +51,7 @@ import {
51
51
  planSessionTrim,
52
52
  roleOfStoredRow,
53
53
  rowKind,
54
+ SEARCH_MAX_HITS,
54
55
  sessionsToDrop,
55
56
  tailCut,
56
57
  textOfStoredRow,
@@ -148,7 +149,7 @@ const traceSinks = [workerLogSink((line) => console.log(line))];
148
149
  // keeps the 512 KB one.
149
150
  // POST /runs/put {storeKey, record, policy?, policyUpdatedAt?} → {ok, retained, stored, rewritten}
150
151
  // POST /runs/get {storeKey, id} → {record: RunRecord | null} (unknown/expired: null, 200)
151
- // POST /runs/list {storeKey, limit?, before?, beforeId?, sinceMs?, agent?, channel?, threadKey?}
152
+ // POST /runs/list {storeKey, limit?, before?, beforeId?, sinceMs?, agent?, channel?, threadKey?, parentRunId?}
152
153
  // → {items: RunListItem[], nextBefore?: {finishedAt, id}} (cursor = the last row's list key)
153
154
  // POST /runs/events {storeKey, id, afterSeq?, limit?} → {events: (RunEvent & {seq})[] | null, nextAfterSeq?}
154
155
  // (`events: null` when the run is unknown or hidden by retention; `seq` is the registry's stamp)
@@ -225,7 +226,6 @@ const MAX_MATCH_TOKENS = 24;
225
226
  /** A `recall` query's size and the most hits one answers (session-log item 10):
226
227
  * a query is a few words, and the tool's default is five. */
227
228
  const MAX_SEARCH_QUERY_BYTES = 1_024;
228
- const MAX_SEARCH_HITS = 50;
229
229
  /** Request body ceiling, checked against Content-Length before parsing. A full
230
230
  * batch (50 × 4000-char texts + keywords + envelope) fits comfortably. */
231
231
  const MAX_BODY_BYTES = 512 * 1024;
@@ -1152,31 +1152,16 @@ export class RunHistoryDO extends DurableObject<Env> {
1152
1152
  value TEXT NOT NULL
1153
1153
  );
1154
1154
  `);
1155
- // The one column migration this DO has (the run-visibility stamp): a table
1156
- // created before the visibility stamp gains the column with `unknown` for
1157
- // every existing row — so a run written before the stamp is never public.
1158
- // Then the indexes the visibility predicate's leaves walk (`channel_id IN`,
1159
- // `channel_visibility IN`, `user_id =`), each ordered like the page.
1160
- const columns = new Set(
1161
- this.sql
1162
- .exec<{ name: string }>(`PRAGMA table_info(runs)`)
1163
- .toArray()
1164
- .map((c) => c.name),
1165
- );
1166
- if (!columns.has("channel_visibility"))
1167
- this.sql.exec(`ALTER TABLE runs ADD COLUMN channel_visibility TEXT NOT NULL DEFAULT 'unknown'`);
1168
- // The session a run was a range of (session-log item 7), so the sweep can
1169
- // tell which sessions still have a kept run; null for a record without one.
1170
- if (!columns.has("session_key")) this.sql.exec(`ALTER TABLE runs ADD COLUMN session_key TEXT`);
1171
- // What the run cost in tokens (costs.md, cost by user): the record's `usage`
1172
- // as JSON; NULL for a record written before the field existed, until the
1173
- // by-user aggregate fills it in from the run's stored events.
1174
- if (!columns.has("usage_json")) this.sql.exec(`ALTER TABLE runs ADD COLUMN usage_json TEXT`);
1155
+ this.migrateRunsTable();
1156
+ // The indexes the visibility predicate's leaves walk (`channel_id IN`,
1157
+ // `channel_visibility IN`, `user_id =`), each ordered like the page; the
1158
+ // session the sweep asks about; the parent a children listing filters on.
1175
1159
  this.sql.exec(`
1176
1160
  CREATE INDEX IF NOT EXISTS runs_channel_finished ON runs(channel_id, finished_at DESC, run_id DESC);
1177
1161
  CREATE INDEX IF NOT EXISTS runs_visibility_finished ON runs(channel_visibility, finished_at DESC, run_id DESC);
1178
1162
  CREATE INDEX IF NOT EXISTS runs_user_finished ON runs(user_id, finished_at DESC, run_id DESC);
1179
1163
  CREATE INDEX IF NOT EXISTS runs_session ON runs(session_key);
1164
+ CREATE INDEX IF NOT EXISTS runs_parent ON runs(parent_run_id, finished_at DESC, run_id DESC);
1180
1165
  `);
1181
1166
  // The sessions registry (session-log item 7): every session log a run of
1182
1167
  // this store claimed, with its thread — the sweep cannot enumerate the
@@ -1333,6 +1318,37 @@ export class RunHistoryDO extends DurableObject<Env> {
1333
1318
  return r ? rowToLive(r) : undefined;
1334
1319
  }
1335
1320
 
1321
+ /** The `runs` table's column migrations, run at every construction and
1322
+ * idempotent: a table created before a column existed gains it, with the
1323
+ * value a row written back then should read. The run-visibility stamp: a
1324
+ * run written before the stamp is `unknown`, never public. The session a
1325
+ * run was a range of (session-log item 7), so the sweep can tell which
1326
+ * sessions still have a kept run; null for a record without one. What the
1327
+ * run cost in tokens (costs.md, cost by user), NULL until the by-user
1328
+ * aggregate fills it from the run's stored events. The parent a child names
1329
+ * (run-history item 46), the column a children listing filters on: the
1330
+ * record already carries it in `summary_json`, so existing rows are filled
1331
+ * from there once, and every later `put` writes it beside the row. */
1332
+ migrateRunsTable(): void {
1333
+ const columns = new Set(
1334
+ this.sql
1335
+ .exec<{ name: string }>(`PRAGMA table_info(runs)`)
1336
+ .toArray()
1337
+ .map((c) => c.name),
1338
+ );
1339
+ if (!columns.has("channel_visibility"))
1340
+ this.sql.exec(`ALTER TABLE runs ADD COLUMN channel_visibility TEXT NOT NULL DEFAULT 'unknown'`);
1341
+ if (!columns.has("session_key")) this.sql.exec(`ALTER TABLE runs ADD COLUMN session_key TEXT`);
1342
+ if (!columns.has("usage_json")) this.sql.exec(`ALTER TABLE runs ADD COLUMN usage_json TEXT`);
1343
+ if (!columns.has("parent_run_id")) {
1344
+ this.sql.exec(`ALTER TABLE runs ADD COLUMN parent_run_id TEXT`);
1345
+ this.sql.exec(
1346
+ `UPDATE runs SET parent_run_id = json_extract(summary_json, '$.parentRunId')
1347
+ WHERE json_type(summary_json, '$.parentRunId') = 'text'`,
1348
+ );
1349
+ }
1350
+ }
1351
+
1336
1352
  private liveByThread(threadKey: string): LiveRunRow | undefined {
1337
1353
  const r = this.sql.exec<LiveRow>(`SELECT * FROM live_runs WHERE thread_key = ?`, threadKey).toArray()[0];
1338
1354
  return r ? rowToLive(r) : undefined;
@@ -1832,8 +1848,8 @@ export class RunHistoryDO extends DurableObject<Env> {
1832
1848
  );
1833
1849
  this.sql.exec(
1834
1850
  `INSERT INTO runs (run_id, label, agent, model, channel_id, user_id, thread_key, channel_visibility, repo, started_at, finished_at, stored_at, status,
1835
- event_count, stored_event_count, truncated, bytes, diagnosis_json, summary_json, session_key, usage_json)
1836
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1851
+ event_count, stored_event_count, truncated, bytes, diagnosis_json, summary_json, session_key, usage_json, parent_run_id)
1852
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1837
1853
  ON CONFLICT(run_id) DO UPDATE SET
1838
1854
  label = excluded.label, agent = excluded.agent, model = excluded.model, channel_id = excluded.channel_id,
1839
1855
  user_id = excluded.user_id, thread_key = excluded.thread_key, channel_visibility = excluded.channel_visibility,
@@ -1842,7 +1858,8 @@ export class RunHistoryDO extends DurableObject<Env> {
1842
1858
  event_count = excluded.event_count, stored_event_count = excluded.stored_event_count, truncated = excluded.truncated,
1843
1859
  bytes = excluded.bytes, diagnosis_json = excluded.diagnosis_json, summary_json = excluded.summary_json,
1844
1860
  session_key = excluded.session_key,
1845
- usage_json = COALESCE(excluded.usage_json, runs.usage_json)`,
1861
+ usage_json = COALESCE(excluded.usage_json, runs.usage_json),
1862
+ parent_run_id = excluded.parent_run_id`,
1846
1863
  stored.id,
1847
1864
  stored.label ?? null,
1848
1865
  stored.agent ?? null,
@@ -1864,6 +1881,7 @@ export class RunHistoryDO extends DurableObject<Env> {
1864
1881
  JSON.stringify(summary),
1865
1882
  stored.session?.key ?? null,
1866
1883
  stored.usage ? JSON.stringify(stored.usage) : null,
1884
+ stored.parentRunId ?? null,
1867
1885
  );
1868
1886
  // The session's registry row learns its newest finish (session-log item
1869
1887
  // 7); a record that reaches the store without a claim (the plain put
@@ -2199,6 +2217,10 @@ export class RunHistoryDO extends DurableObject<Env> {
2199
2217
  where.push(`thread_key = ?`);
2200
2218
  params.push(q.threadKey);
2201
2219
  }
2220
+ if (q.parentRunId !== undefined) {
2221
+ where.push(`parent_run_id = ?`);
2222
+ params.push(q.parentRunId);
2223
+ }
2202
2224
  if (q.visibleTo !== undefined && q.visibleTo.kind !== "all") where.push(visibilitySql(q.visibleTo, params));
2203
2225
  const select = `SELECT run_id, agent, channel_id, finished_at, bytes, event_count, summary_json FROM runs WHERE ${where.join(" AND ")} ORDER BY finished_at DESC, run_id DESC`;
2204
2226
  // `LIMIT` holds on the over-bound path too: the kept set is the newest
@@ -2441,6 +2463,11 @@ function parseRunList(body: unknown): Validated<{ storeKey: string; query: RunLi
2441
2463
  return invalid(`${field} must be a string of at most ${MAX_KEY_CHARS} characters`);
2442
2464
  query[field] = v;
2443
2465
  }
2466
+ if (b.parentRunId !== undefined) {
2467
+ const id = parseRunId(b.parentRunId);
2468
+ if (!id.ok) return invalid("parentRunId must match ^[A-Za-z0-9_-]{1,64}$");
2469
+ query.parentRunId = id.value;
2470
+ }
2444
2471
  if (b.visibleTo !== undefined) {
2445
2472
  // A malformed filter is a 400, never "all": the bot degrades to live rows
2446
2473
  // rather than the DO widening what an actor may see.
@@ -2453,8 +2480,8 @@ function parseRunList(body: unknown): Validated<{ storeKey: string; query: RunLi
2453
2480
  }
2454
2481
 
2455
2482
  /** Parameters the page query binds before any filter: the cursor pair (3) and the age floor (1),
2456
- * plus `agent`, `channel`, `threadKey`, and the LIMIT at most — the headroom `visibleTo` must fit under. */
2457
- const RUN_LIST_BASE_PARAMETERS = 8;
2483
+ * plus `agent`, `channel`, `threadKey`, `parentRunId`, and the LIMIT at most — the headroom `visibleTo` must fit under. */
2484
+ const RUN_LIST_BASE_PARAMETERS = 9;
2458
2485
 
2459
2486
  /** How many `?` a filter binds (one per id, one per user). */
2460
2487
  function boundParameters(f: RunVisibilityFilter): number {
@@ -3400,8 +3427,8 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
3400
3427
  )
3401
3428
  return json({ error: `query must be a non-empty string of at most ${MAX_SEARCH_QUERY_BYTES} bytes` }, 400);
3402
3429
  const limit = b.limit;
3403
- if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > MAX_SEARCH_HITS)
3404
- return json({ error: `limit must be an integer in 1..${MAX_SEARCH_HITS}` }, 400);
3430
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > SEARCH_MAX_HITS)
3431
+ return json({ error: `limit must be an integer in 1..${SEARCH_MAX_HITS}` }, 400);
3405
3432
  const hits = await stub.search(b.query, limit);
3406
3433
  const gaps =
3407
3434
  hits.length > 1
@@ -1110,7 +1110,9 @@ const NO_REF_HINT_REASON: RefHintReason = { ownPr: null, refByDefault: false };
1110
1110
  type RebindOutcome =
1111
1111
  | { kind: "none"; binding: ThreadBinding }
1112
1112
  | { kind: "refuse"; refused: RebindRefused }
1113
- | { kind: "rebound"; moved: ThreadBinding; rebound: Rebound };
1113
+ /** `note`: the move was the record alone — a dirty tree already on the
1114
+ * branch, no checkout run — in the verdict's words, for the log line. */
1115
+ | { kind: "rebound"; moved: ThreadBinding; rebound: Rebound; note?: string };
1114
1116
 
1115
1117
  /** Result of one /op test/build execution. `ok` is the command's
1116
1118
  * verdict — a failing test run is a RESULT with ok:false, never an error. */
@@ -4654,29 +4656,44 @@ export class ResidentDO extends Sandbox<Env> {
4654
4656
  );
4655
4657
  tree.readable = status.exitCode === 0;
4656
4658
  if (tree.readable) tree.dirty = status.stdout.trim() !== "";
4659
+ if (tree.dirty) {
4660
+ // A dirty tree is refused unless its HEAD is already the branch
4661
+ // (the run made it here and left an edit after pushing): the
4662
+ // one fact that tells the two apart, measured only when it
4663
+ // decides anything.
4664
+ const head = await this.threadRun(
4665
+ current.user,
4666
+ wt,
4667
+ "git rev-parse --abbrev-ref HEAD",
4668
+ DEFAULT_EXEC_TIMEOUT_MS,
4669
+ );
4670
+ if (head.exitCode === 0) tree.head = head.stdout.trim();
4671
+ }
4657
4672
  }
4658
4673
  // Judged on the re-read plan: it carries whether the branch is the
4659
4674
  // thread's own, which the pre-lock plan lacks when it was a recreate.
4660
4675
  const verdict = rebindVerdict(again, tree);
4661
4676
  if (verdict.kind === "recreate") return this.recreateAtOwnBranch(current, again, fetchToken);
4662
4677
  if (verdict.kind !== "rebind") return verdict;
4663
- const startedAt = systemClock();
4664
- const checkout = await this.threadRun(
4665
- current.user,
4666
- wt,
4667
- `git checkout --quiet ${shellQuote(again.to)}`,
4668
- DEFAULT_EXEC_TIMEOUT_MS,
4669
- );
4670
- this.stepTrace.getStore()?.record("rebind-checkout", {
4671
- startedAt,
4672
- endedAt: systemClock(),
4673
- exitCode: checkout.exitCode,
4674
- timedOut: checkout.timedOut,
4675
- });
4676
- if (checkout.exitCode !== 0) {
4677
- const detail =
4678
- checkout.stderr.trim().split("\n")[0]?.slice(0, 200) || `git checkout exited ${checkout.exitCode}`;
4679
- return { kind: "refuse", refused: rebindRefused(again, "checkout-failed", detail) };
4678
+ if (verdict.checkout) {
4679
+ const startedAt = systemClock();
4680
+ const checkout = await this.threadRun(
4681
+ current.user,
4682
+ wt,
4683
+ `git checkout --quiet ${shellQuote(again.to)}`,
4684
+ DEFAULT_EXEC_TIMEOUT_MS,
4685
+ );
4686
+ this.stepTrace.getStore()?.record("rebind-checkout", {
4687
+ startedAt,
4688
+ endedAt: systemClock(),
4689
+ exitCode: checkout.exitCode,
4690
+ timedOut: checkout.timedOut,
4691
+ });
4692
+ if (checkout.exitCode !== 0) {
4693
+ const detail =
4694
+ checkout.stderr.trim().split("\n")[0]?.slice(0, 200) || `git checkout exited ${checkout.exitCode}`;
4695
+ return { kind: "refuse", refused: rebindRefused(again, "checkout-failed", detail) };
4696
+ }
4680
4697
  }
4681
4698
  const rebound: Rebound = {
4682
4699
  from: current.ref,
@@ -4686,7 +4703,9 @@ export class ResidentDO extends Sandbox<Env> {
4686
4703
  };
4687
4704
  const moved: ThreadBinding = { ...current, ref: again.to, rebound };
4688
4705
  await this.ctx.storage.put(key, moved);
4689
- return { kind: "rebound", moved, rebound };
4706
+ return verdict.checkout
4707
+ ? { kind: "rebound", moved, rebound }
4708
+ : { kind: "rebound", moved, rebound, note: verdict.note };
4690
4709
  }, ATTACH_MUTEX_WAIT_MS)
4691
4710
  ).value;
4692
4711
  } catch (err) {
@@ -4705,7 +4724,7 @@ export class ResidentDO extends Sandbox<Env> {
4705
4724
  }
4706
4725
  const { moved, rebound } = outcome;
4707
4726
  console.log(
4708
- `attach ${prior.threadKey}: rebound ${rebound.from} → ${rebound.to} (the thread's own pull request #${rebound.pr})`,
4727
+ `attach ${prior.threadKey}: rebound ${rebound.from} → ${rebound.to} (the thread's own pull request #${rebound.pr})${outcome.note ? ` — ${outcome.note}` : ""}`,
4709
4728
  );
4710
4729
  return { binding: moved, rebound };
4711
4730
  }
@@ -5417,14 +5436,13 @@ export class ResidentDO extends Sandbox<Env> {
5417
5436
  "deps-seed",
5418
5437
  REFRESH_BUILD_TIMEOUT_MS,
5419
5438
  );
5420
- const paths = mutableCachePaths(`${scratch}/node_modules`, parsed.mutableListing);
5421
- if (paths.length > 0) {
5422
- await this.runDepScript(
5423
- mutableCacheSwapScript(seedNm, `${scratch}/node_modules`, BUILD_USER, paths),
5424
- "deps-seed-swap",
5425
- GIT_NETWORK_TIMEOUT_MS,
5426
- );
5427
- }
5439
+ await this.swapMutableCaches(
5440
+ seedNm,
5441
+ `${scratch}/node_modules`,
5442
+ BUILD_USER,
5443
+ parsed.mutableListing,
5444
+ "deps-seed-swap",
5445
+ );
5428
5446
  console.log(`deps: ${key.slice(0, 8)} seeded from ${seed.slice(0, 8)} (${parsed.mech})`);
5429
5447
  }
5430
5448
  // Unprivileged and token-free, in a tree only this attempt
@@ -5527,16 +5545,42 @@ export class ResidentDO extends Sandbox<Env> {
5527
5545
  "deps-materialize",
5528
5546
  REFRESH_BUILD_TIMEOUT_MS,
5529
5547
  );
5530
- const nmDst = `${tree}/node_modules`;
5531
- const paths = mutableCachePaths(nmDst, parsed.mutableListing);
5532
- if (paths.length > 0) {
5533
- await this.runDepScript(
5534
- mutableCacheSwapScript(entryNodeModules ?? `${CHECKOUT_DIR}/node_modules`, nmDst, user, paths),
5535
- "deps-mutable-swap",
5536
- GIT_NETWORK_TIMEOUT_MS,
5548
+ await this.swapMutableCaches(
5549
+ entryNodeModules ?? `${CHECKOUT_DIR}/node_modules`,
5550
+ `${tree}/node_modules`,
5551
+ user,
5552
+ parsed.mutableListing,
5553
+ "deps-mutable-swap",
5554
+ );
5555
+ return parsed.mech;
5556
+ }
5557
+
5558
+ /** The tool-cache swap for a freshly hardlinked node_modules: the listing
5559
+ * through `mutableCachePaths`, every swap in one fork
5560
+ * (`mutableCacheSwapScript`). A path the source has no counterpart for is
5561
+ * a tree-private entry the script leaves in place and names on a
5562
+ * `skipped=` line; the log carries those names so a refresh that read warm
5563
+ * still says what it left alone (the failure path already quotes the
5564
+ * script's stdout). */
5565
+ private async swapMutableCaches(
5566
+ srcNodeModules: string,
5567
+ dstNodeModules: string,
5568
+ user: string,
5569
+ mutableListing: string[],
5570
+ step: "deps-mutable-swap" | "deps-seed-swap",
5571
+ ): Promise<void> {
5572
+ const paths = mutableCachePaths(dstNodeModules, mutableListing);
5573
+ if (paths.length === 0) return;
5574
+ const swapped = await this.runDepScript(
5575
+ mutableCacheSwapScript(srcNodeModules, dstNodeModules, user, paths),
5576
+ step,
5577
+ GIT_NETWORK_TIMEOUT_MS,
5578
+ );
5579
+ if (swapped.skipped.length > 0) {
5580
+ console.log(
5581
+ `deps: ${step} left ${swapped.skipped.length} tree-private node_modules entr${swapped.skipped.length === 1 ? "y" : "ies"} with no store counterpart in place: ${swapped.skipped.join(", ")}`,
5537
5582
  );
5538
5583
  }
5539
- return parsed.mech;
5540
5584
  }
5541
5585
 
5542
5586
  /** Adopt a checkout that already holds node_modules but whose key has no