stim 1.11.0 → 1.13.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 (82) hide show
  1. package/README.md +3 -1
  2. package/dist/{activity-BtG4Va9l.mjs → activity-DfVKZTEB.mjs} +44 -26
  3. package/dist/{android-BA2i_KAV.mjs → android-BHfAu2mf.mjs} +1 -1
  4. package/dist/{android-C1q4l523.mjs → android-CD8pr8Q2.mjs} +112 -54
  5. package/dist/{android-cas-DfJkwCXW.mjs → android-cas-B9Kv9aIy.mjs} +12 -10
  6. package/dist/{android-C4zG2Tuc.mjs → android-qx_31wJo.mjs} +414 -118
  7. package/dist/{app-install-DHut6JnV.mjs → app-install-CDFY2nUL.mjs} +1 -1
  8. package/dist/budget-E5o91NR1.mjs +1739 -0
  9. package/dist/{build-plan-CeAYgKAx.mjs → build-plan-BTa93o_r.mjs} +125 -107
  10. package/dist/{build-progress-CDwogz6R.mjs → build-progress-l7d-X-ZP.mjs} +76 -8
  11. package/dist/{cache-manifest-Pubfp8wV.mjs → cache-manifest-DWPF4RKJ.mjs} +1 -1
  12. package/dist/cache-manifest.mjs +1 -1
  13. package/dist/cdp-Dq_ypAlJ.mjs +115 -0
  14. package/dist/chrome-B5hWgQJS.mjs +61 -0
  15. package/dist/cli.mjs +17 -16
  16. package/dist/collector-run.mjs +15 -9
  17. package/dist/{command-output-D5z2_bcF.mjs → command-output-BIL7rlX7.mjs} +1 -1
  18. package/dist/{config-BEq0Qw_a.mjs → config-0jCcMM0E.mjs} +10 -1
  19. package/dist/created-devices-Cd558h31.mjs +37 -0
  20. package/dist/{dependency-state-r9UesA9a.mjs → dependency-state-DNN0yfx6.mjs} +1 -1
  21. package/dist/{deps-OF7CD5Dx.mjs → deps-2Wo81np9.mjs} +2 -2
  22. package/dist/{device-BwH2iI3O.mjs → device-Cf7vl-so.mjs} +7 -7
  23. package/dist/{device-lease-D83nqeMX.mjs → device-lease-CL9dZf73.mjs} +55 -6
  24. package/dist/{device-pool-DHj7cdPy.mjs → device-pool-MuoiyRcu.mjs} +4 -4
  25. package/dist/{device-remote-BQZSnnmE.mjs → device-remote-DT9ngZiD.mjs} +254 -579
  26. package/dist/{doctor-D8QdMRMf.mjs → doctor-D2iysNNH.mjs} +50 -20
  27. package/dist/{error-diagnostics-UYFSQwRy.mjs → error-diagnostics-BoiOhck8.mjs} +7 -7
  28. package/dist/{gc-COZteyiq.mjs → gc-eA-PRBX8.mjs} +1022 -131
  29. package/dist/{guide-NDvTmMi-.mjs → guide-BfZ0OmiP.mjs} +909 -132
  30. package/dist/{guide-status-CWvti2Lg.mjs → guide-status-BaMYu55M.mjs} +2 -2
  31. package/dist/{idle-NETbeNiQ.mjs → idle-DxLi5OkN.mjs} +43 -10
  32. package/dist/{in-use-DVhlSoyn.mjs → in-use-wYEaKQM9.mjs} +11 -7
  33. package/dist/{ios-CQLKpNfr.mjs → ios-CcfCeZ19.mjs} +103 -58
  34. package/dist/{ios-DArbmwer.mjs → ios-CmEzZiOj.mjs} +31 -22
  35. package/dist/{ios-device-CwDZo0X4.mjs → ios-device-BW-t_Lkv.mjs} +1 -1
  36. package/dist/{launch-verify-CuPDGXdr.mjs → launch-verify-CuJaBpEa.mjs} +33 -20
  37. package/dist/{logs-B44a1UeU.mjs → logs-DcSRbC2t.mjs} +12 -11
  38. package/dist/{merge-state-DDxS-XIZ.mjs → merge-state-ytFQCeh4.mjs} +38 -8
  39. package/dist/{metro-BWLOJ_Wr.mjs → metro-BPeY1YMh.mjs} +5 -5
  40. package/dist/{named-ports-Dc7neAlc.mjs → named-ports-n7tnJsI8.mjs} +78 -6
  41. package/dist/native-runtime-CiiFHt0e.mjs +74 -0
  42. package/dist/{ownership-D2xbLVoz.mjs → ownership-Bz2kETAS.mjs} +2 -2
  43. package/dist/{ownership-BiojdtAp.mjs → ownership-PUP4Dwai.mjs} +175 -217
  44. package/dist/page-BTu7I60K.mjs +29 -0
  45. package/dist/{ports-DwBaRFJs.mjs → ports-C5UutqpJ.mjs} +5 -5
  46. package/dist/{project-BXZzOABt.mjs → project-DGeclhvO.mjs} +55 -87
  47. package/dist/{reload-By9Dwsrt.mjs → reload-C-cV5yy_.mjs} +80 -21
  48. package/dist/remote-cache--aehr4Pr.mjs +526 -0
  49. package/dist/{server-bare-D_RK1RCj.mjs → server-bare-Bq8BpSIo.mjs} +1 -1
  50. package/dist/server-expo-BeYwP_Xy.mjs +2 -0
  51. package/dist/{server-expo-o6H5gcBc.mjs → server-expo-DUCg52-K.mjs} +2 -2
  52. package/dist/{settings-C0ugALSC.mjs → settings-ssVBToDR.mjs} +29 -17
  53. package/dist/{settings-B5oFZEri.mjs → settings-xB0Frkg6.mjs} +13 -4
  54. package/dist/settings.schema.json +74 -2
  55. package/dist/{simslim-CIR3IYJO.mjs → simslim-Bbg9UWrt.mjs} +13 -6
  56. package/dist/{slot-launch-rggO1Esw.mjs → slot-launch-BIWEW-wI.mjs} +4 -4
  57. package/dist/{spawn-claims-BJwXTMPt.mjs → spawn-claims-PT8aQpCa.mjs} +1 -1
  58. package/dist/{start-BSsf2tUs.mjs → start-BZhItkiL.mjs} +110 -18
  59. package/dist/{start-i8zc9VmS.mjs → start-Rw2GD3nV.mjs} +1 -1
  60. package/dist/{state-WGdIbi_d.mjs → state-DwAU2_-D.mjs} +15 -3
  61. package/dist/state-l1pYwOre.mjs +148 -0
  62. package/dist/{stats-DBYJ5bxL.mjs → stats-CaSwmsSZ.mjs} +4 -4
  63. package/dist/{stats-BAgJaLA4.mjs → stats-D8rUTPHd.mjs} +1 -1
  64. package/dist/{status-CitcBRsC.mjs → status-TJlL0HWo.mjs} +649 -116
  65. package/dist/{status-Q-WDEGCB.mjs → status-sszP4XKU.mjs} +130 -28
  66. package/dist/stim-desktop-CXt8-Z3J.mjs +40 -0
  67. package/dist/{stop-DIhrvu0B.mjs → stop-BZ2qMiBP.mjs} +50 -18
  68. package/dist/{stop-rst5mL14.mjs → stop-DskDKNTU.mjs} +2 -2
  69. package/dist/supervisor-run.mjs +11 -11
  70. package/dist/web-run.d.mts +22 -0
  71. package/dist/web-run.mjs +481 -0
  72. package/dist/web-sWXd5iek.mjs +394 -0
  73. package/dist/{worktree-DhxDTK44.mjs → worktree-3cBa-bQt.mjs} +1 -1
  74. package/dist/{worktree-KOjMX3zA.mjs → worktree-D4Do_XiQ.mjs} +229 -21
  75. package/dist/{worktree-BlizKDpZ.mjs → worktree-DeBQSVtR.mjs} +3 -1
  76. package/dist/{xcode-B5SPD--o.mjs → xcode-LM6A8cNZ.mjs} +7 -7
  77. package/package.json +5 -5
  78. package/dist/budget-De3P2rq_.mjs +0 -620
  79. package/dist/native-run-D7yB_AsB.mjs +0 -143
  80. package/dist/server-expo-B-CCUJZj.mjs +0 -2
  81. package/dist/workspace-process-lock-Cx74RSDZ.mjs +0 -57
  82. package/dist/workspaces-COD2xCz-.mjs +0 -1013
@@ -1,22 +1,28 @@
1
- import { A as directorySize, F as volumeRootFor, M as isOnMountedVolume, N as listMountedVolumes, b as removeProject, g as loadConfig, j as formatBytes, k as ownership_claim_exports } from "./config-BEq0Qw_a.mjs";
1
+ import { A as ownership_claim_exports, E as withConfigLock$1, I as volumeRootFor, M as formatBytes, N as isOnMountedVolume, P as listMountedVolumes, U as projectDeviceSlots, _ as loadConfig$1, j as directorySize, k as acquireAvdClaim, p as getProject, x as removeProject } from "./config-0jCcMM0E.mjs";
2
2
  import { t as getExecutor } from "./exec-DK8KaVHK.mjs";
3
- import { N as workspaceStateFile, O as workspaceDir, v as workspaceLastUsed } from "./metro-BWLOJ_Wr.mjs";
4
- import { C as sourceCheckoutOf, b as resolveFullRef, d as hasUncommittedWork, l as hasPopulatedSubmodules, m as listWorktrees, o as dirtyPaths, w as unpushedCommits } from "./worktree-BlizKDpZ.mjs";
5
- import { a as detectIsExpo, o as findProjectRoot } from "./project-BXZzOABt.mjs";
6
- import { h as shortUdid, o as formatLongDuration, u as plural } from "./command-output-D5z2_bcF.mjs";
7
- import { d as removeExpiredLease, h as listBuildLocks } from "./device-lease-D83nqeMX.mjs";
8
- import { D as listAvds, N as ownedAvdDirectory, Y as isStimOwnedSim, k as listOrphanedAvdDirectories, w as isStimOwnedAvdName } from "./android-C4zG2Tuc.mjs";
9
- import { h as POOL_SETTING_REMEDY, y as parkedMaxSetting } from "./ownership-BiojdtAp.mjs";
10
- import { l as listAllIosSims } from "./ios-DArbmwer.mjs";
11
- import { a as parseIdleDuration, o as shutDownIdleDevices, r as idleDeviceLines, t as collectIdleDevices } from "./idle-NETbeNiQ.mjs";
12
- import { i as canonicalPath, o as listBuildSlots, r as workspaceInUse } from "./in-use-DVhlSoyn.mjs";
13
- import { C as listOwnedSessionsArgs, D as verifyStoppedSession, E as stopSessionArgs, S as isDefinitiveMissingSessionError, T as parseSessionListPage, b as getSessionArgs, d as withRemoteSessionLock, f as withEasProjectLock, h as removeEasSessionClaim, m as readEasSessionLedger, p as easMachineStateRoot, x as inspectSessionForTeardown, y as findOrphanedOwnedSessions, z as resolveEasCliBin } from "./device-remote-BQZSnnmE.mjs";
14
- import { A as discoverCaches, C as unverifiedDeviceCommand, D as planCacheEmptying, E as includesWorkspaceOutputs, O as selectCaches, S as findStaleProjectDevices, T as emptyCaches, _ as describeParkedSims, a as collectWorkspaceOutputs, b as findOrphanedDevices, c as listWorkspaceDirs, d as collectDeviceLeases, f as collectParkedAvds, g as deleteProjectDevices, h as deleteParkedSims, i as collectOrphanedWorkspaces, j as sizeCaches, k as trimCaches, l as sizeText, m as deleteParkedAvds, n as WORKSPACE_OUTPUT_DIRS, o as deleteOrphanedWorkspaces, p as collectParkedSims, r as clearWorkspaceOutputs, s as isInsideWorkspaces, t as REBUILD_COST, u as DEVICE_LIST_TIMEOUT_MS, v as describeUnverifiableDevices, w as withAndroidAvdSizes, x as findStaleDeviceRecords, y as deviceSweepIsScoped } from "./workspaces-COD2xCz-.mjs";
15
- import { a as reclaimKeys, d as removeWorktreeTarget, m as reclaimProject, n as excludePodChurn, p as describeDereferenced, r as matchWorktreeEntry } from "./worktree-KOjMX3zA.mjs";
16
- import { n as mergeState, t as fetchDefaultBranch } from "./merge-state-DDxS-XIZ.mjs";
3
+ import { F as withDirLock, M as workspaceLogsDir, P as workspaceStateFile, k as workspaceDir, y as workspaceLastUsed } from "./metro-BPeY1YMh.mjs";
4
+ import { C as sourceCheckoutOf, b as resolveFullRef, d as hasUncommittedWork, l as hasPopulatedSubmodules, m as listWorktrees, o as dirtyPaths, w as unpushedCommits } from "./worktree-DeBQSVtR.mjs";
5
+ import { a as findProjectRoot, r as detectIsExpo } from "./project-DGeclhvO.mjs";
6
+ import { h as shortUdid, o as formatLongDuration, u as plural } from "./command-output-BIL7rlX7.mjs";
7
+ import { d as removeExpiredLease, h as listBuildLocks, i as listLeaseFiles, r as leaseIsExpired } from "./device-lease-CL9dZf73.mjs";
8
+ import { A as listInstalledSystemImages, B as parseAvdDeviceProfile, F as ownedAvdDirectory, R as ownedAvdSerialResolver, V as parseAvdSystemImage, a as androidHome, ct as parkedMaxSetting, f as avdIniPaths, g as avdStorageRoots, j as listOrphanedAvdDirectories, k as listAvds, lt as readParked, nt as isStimOwnedSim, p as avdNameAbsent, rt as POOL_SETTING_REMEDY, tt as isStimOwnedAvd, u as androidToolPath } from "./android-qx_31wJo.mjs";
9
+ import { n as readCreatedDevices$1, t as forgetCreatedDevice } from "./created-devices-Cd558h31.mjs";
10
+ import { l as listAllIosSims } from "./ios-CmEzZiOj.mjs";
11
+ import { a as parseIdleDuration, c as recordGcResult, l as takeGcResults, o as shutDownIdleDevices, r as idleDeviceLines, t as collectIdleDevices } from "./idle-DxLi5OkN.mjs";
12
+ import { i as canonicalPath, n as withIdleWorkspace, o as listBuildSlots, r as workspaceInUse } from "./in-use-wYEaKQM9.mjs";
13
+ import { r as readCollectors } from "./ownership-Bz2kETAS.mjs";
14
+ import { f as resolveEasCliBin } from "./remote-cache--aehr4Pr.mjs";
15
+ import { A as findOrphanedOwnedSessions, C as withEasProjectLock, E as removeEasSessionClaim, I as parseSessionListPage, L as stopSessionArgs, M as inspectSessionForTeardown, N as isDefinitiveMissingSessionError, P as listOwnedSessionsArgs, R as verifyStoppedSession, T as readEasSessionLedger$1, d as withRemoteSessionLock, j as getSessionArgs, w as easMachineStateRoot$1 } from "./device-remote-DT9ngZiD.mjs";
16
+ import { A as includesWorkspaceOutputs, C as deviceSweepIsScoped, D as unverifiedDeviceCommand, E as findStaleProjectDevices, F as sizeCaches, M as selectCaches, N as trimCaches, O as withAndroidAvdSizes, P as discoverCaches, S as describeUnverifiableDevices, T as findStaleDeviceRecords, _ as collectParkedSims, a as REBUILD_COST, b as deleteProjectDevices, c as collectOrphanedWorkspaces, d as isInsideWorkspaces, f as listWorkspaceDirs, g as collectParkedAvds, h as collectDeviceLeases, i as machineNumber, j as planCacheEmptying, k as emptyCaches, l as collectWorkspaceOutputs, m as DEVICE_LIST_TIMEOUT_MS, o as WORKSPACE_OUTPUT_DIRS, p as sizeText, s as clearWorkspaceOutputs, u as deleteOrphanedWorkspaces, v as deleteParkedAvds, w as findOrphanedDevices, x as describeParkedSims, y as deleteParkedSims } from "./budget-E5o91NR1.mjs";
17
+ import { _ as reclaimProject, a as reclaimKeys, d as removeWorktreeTarget, g as describeDereferenced, h as pullRequestLookups, m as endedPullRequest, n as excludePodChurn, p as describePullRequest, r as matchWorktreeEntry } from "./worktree-D4Do_XiQ.mjs";
18
+ import { n as mergeState, t as fetchDefaultBranch } from "./merge-state-ytFQCeh4.mjs";
19
+ import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from "node:fs";
17
20
  import { InvalidArgumentError } from "commander";
18
- import { existsSync, readFileSync, readdirSync, statSync } from "fs";
21
+ import { closeSync as closeSync$1, existsSync as existsSync$1, openSync as openSync$1, readFileSync as readFileSync$1, readSync as readSync$1, readdirSync as readdirSync$1, renameSync as renameSync$1, rmSync as rmSync$1, statSync as statSync$1, utimesSync, writeFileSync as writeFileSync$1 } from "fs";
19
22
  import { basename, dirname, isAbsolute, join } from "path";
23
+ import { LOG_ROTATE_BYTES, rotatedLogPath } from "@stim-cli/core";
24
+ import { settingDefinition } from "@stim-cli/core/state";
25
+ import { dirname as dirname$1, join as join$1 } from "node:path";
20
26
  import chalk from "chalk";
21
27
  //#region src/commands/gc/eas-sessions.ts
22
28
  const EAS_OPERATION_TIMEOUT_MS = 3e4;
@@ -37,18 +43,18 @@ function readRecordedRemoteSessionIds(roots) {
37
43
  let safe = true;
38
44
  for (const root of new Set(roots)) {
39
45
  try {
40
- if (!statSync(root).isDirectory()) throw new Error("path is not a directory");
41
- readdirSync(root);
46
+ if (!statSync$1(root).isDirectory()) throw new Error("path is not a directory");
47
+ readdirSync$1(root);
42
48
  } catch (error) {
43
49
  safe = false;
44
50
  notices.push(`${root} is not available as a readable workspace directory: ${describeError(error)}`);
45
51
  continue;
46
52
  }
47
53
  const path = workspaceStateFile(root);
48
- if (!existsSync(path)) continue;
54
+ if (!existsSync$1(path)) continue;
49
55
  let state;
50
56
  try {
51
- state = JSON.parse(readFileSync(path, "utf-8"));
57
+ state = JSON.parse(readFileSync$1(path, "utf-8"));
52
58
  } catch (error) {
53
59
  safe = false;
54
60
  notices.push(`${path} could not be read as valid JSON: ${describeError(error)}`);
@@ -84,8 +90,8 @@ function claimStateAbsence(claim) {
84
90
  const stateDir = dirname(claim.stateFile);
85
91
  let entries;
86
92
  try {
87
- if (!statSync(stateDir).isDirectory()) throw new Error("path is not a directory");
88
- entries = readdirSync(stateDir);
93
+ if (!statSync$1(stateDir).isDirectory()) throw new Error("path is not a directory");
94
+ entries = readdirSync$1(stateDir);
89
95
  } catch (error) {
90
96
  return {
91
97
  absent: false,
@@ -98,7 +104,7 @@ function claimStateAbsence(claim) {
98
104
  };
99
105
  let state;
100
106
  try {
101
- state = JSON.parse(readFileSync(claim.stateFile, "utf-8"));
107
+ state = JSON.parse(readFileSync$1(claim.stateFile, "utf-8"));
102
108
  } catch (error) {
103
109
  return {
104
110
  absent: false,
@@ -141,8 +147,8 @@ function collectEasSessionSweep(config, deps) {
141
147
  const eas = (deps.resolveEasCliBin ?? resolveEasCliBin)(projectRoot);
142
148
  if (!eas) return empty(["EAS session sweep skipped: eas-cli is not available for the current Expo project."]);
143
149
  const recorded = readRecordedRemoteSessionIds([...Object.keys(config?.projects ?? {}), projectRoot]);
144
- const ledgerRoot = deps.easLedgerRoot ?? easMachineStateRoot();
145
- const ledger = readEasSessionLedger(ledgerRoot);
150
+ const ledgerRoot = deps.easLedgerRoot ?? easMachineStateRoot$1();
151
+ const ledger = readEasSessionLedger$1(ledgerRoot);
146
152
  const run = deps.runEasFile ?? ((file, args, options) => getExecutor().runFile(file, args, options));
147
153
  const sessions = [];
148
154
  const seenCursors = /* @__PURE__ */ new Set();
@@ -180,7 +186,7 @@ function collectEasSessionSweep(config, deps) {
180
186
  }
181
187
  const lockedRoots = deps.easLockedRoots;
182
188
  if (lockedRoots) {
183
- if (easLockRoots(projectRoot, loadConfig()).some((root) => !lockedRoots.includes(root))) return empty([...recorded.notices, "EAS session sweep skipped: registered workspace roots changed after remote-session locks were acquired."]);
189
+ if (easLockRoots(projectRoot, loadConfig$1()).some((root) => !lockedRoots.includes(root))) return empty([...recorded.notices, "EAS session sweep skipped: registered workspace roots changed after remote-session locks were acquired."]);
184
190
  }
185
191
  const compared = findOrphanedOwnedSessions({
186
192
  sessions,
@@ -215,8 +221,8 @@ function easLockRoots(projectRoot, config) {
215
221
  }
216
222
  function unavailableLockRootNotice(roots) {
217
223
  for (const root of roots) try {
218
- if (!statSync(root).isDirectory()) throw new Error("path is not a directory");
219
- readdirSync(root);
224
+ if (!statSync$1(root).isDirectory()) throw new Error("path is not a directory");
225
+ readdirSync$1(root);
220
226
  } catch (error) {
221
227
  return `EAS session sweep skipped: ${root} is not available for remote-session lock inspection: ${describeError(error)}`;
222
228
  }
@@ -225,7 +231,7 @@ function unavailableLockRootNotice(roots) {
225
231
  function malformedRemoteSessionLockNotice(roots) {
226
232
  for (const root of roots) {
227
233
  const lockPath = join(workspaceDir(root), "remote-session.lock");
228
- if (!existsSync(lockPath)) continue;
234
+ if (!existsSync$1(lockPath)) continue;
229
235
  if ((0, ownership_claim_exports.readClaimSet)(lockPath).unresolved.length === 0) continue;
230
236
  return `EAS session sweep skipped: the remote-session lock at ${lockPath} has a malformed owner record.`;
231
237
  }
@@ -236,7 +242,7 @@ async function withRemoteSessionGcLocks(projectRoot, deps, fn) {
236
242
  const waitMs = deps.easLockWaitMs ?? EAS_LOCK_WAIT_MS;
237
243
  const maxSnapshots = deps.easLockSnapshotRetries ?? EAS_LOCK_SNAPSHOT_RETRIES;
238
244
  for (let attempt = 0; attempt < maxSnapshots; attempt++) {
239
- const roots = easLockRoots(projectRoot, loadConfig());
245
+ const roots = easLockRoots(projectRoot, loadConfig$1());
240
246
  const unavailable = unavailableLockRootNotice(roots);
241
247
  if (unavailable) return fn({
242
248
  ...deps,
@@ -250,7 +256,7 @@ async function withRemoteSessionGcLocks(projectRoot, deps, fn) {
250
256
  let expanded = false;
251
257
  let coreStarted = false;
252
258
  const acquire = (index) => index === roots.length ? (async () => {
253
- if (easLockRoots(projectRoot, loadConfig()).some((root) => !roots.includes(root))) {
259
+ if (easLockRoots(projectRoot, loadConfig$1()).some((root) => !roots.includes(root))) {
254
260
  expanded = true;
255
261
  return null;
256
262
  }
@@ -279,6 +285,13 @@ async function withRemoteSessionGcLocks(projectRoot, deps, fn) {
279
285
  }
280
286
  async function deleteEasSessions(easSessionSweep, deps) {
281
287
  let deleteFailures = 0;
288
+ const refuseAll = (detail) => {
289
+ deleteFailures += easSessionSweep.orphaned.length;
290
+ for (const session of easSessionSweep.orphaned) recordGcResult("easSession", "failed", session.name, {
291
+ id: session.id,
292
+ detail
293
+ });
294
+ };
282
295
  if (easSessionSweep.orphaned.length && easSessionSweep.deletionSafe && easSessionSweep.projectScope) {
283
296
  const projectScope = easSessionSweep.projectScope;
284
297
  const withProjectLock = deps.withEasProjectLock ?? withEasProjectLock;
@@ -288,17 +301,17 @@ async function deleteEasSessions(easSessionSweep, deps) {
288
301
  finalSweepStarted = true;
289
302
  return withRemoteSessionGcLocks(projectScope, deps, async (lockedDeps) => {
290
303
  if (lockedDeps.easSweepBlockedNotice) {
291
- deleteFailures += easSessionSweep.orphaned.length;
304
+ refuseAll(`EAS session deletion refused: ${lockedDeps.easSweepBlockedNotice}`);
292
305
  console.log(chalk.red(`EAS session deletion refused: ${lockedDeps.easSweepBlockedNotice}`));
293
306
  return;
294
307
  }
295
308
  const coordinatedDeps = lockedDeps;
296
- const currentConfig = loadConfig();
309
+ const currentConfig = loadConfig$1();
297
310
  const currentRoots = easLockRoots(projectScope, currentConfig);
298
311
  const registryExpanded = coordinatedDeps.easLockedRoots && currentRoots.some((root) => !coordinatedDeps.easLockedRoots.includes(root));
299
312
  const currentRecords = registryExpanded ? null : readRecordedRemoteSessionIds([...Object.keys(currentConfig?.projects ?? {}), projectScope]);
300
- const ledgerRoot = coordinatedDeps.easLedgerRoot ?? easMachineStateRoot();
301
- const ledger = readEasSessionLedger(ledgerRoot);
313
+ const ledgerRoot = coordinatedDeps.easLedgerRoot ?? easMachineStateRoot$1();
314
+ const ledger = readEasSessionLedger$1(ledgerRoot);
302
315
  const removeClaim = coordinatedDeps.removeEasSessionClaim ?? removeEasSessionClaim;
303
316
  const reconcileClaim = (sessionId) => {
304
317
  let detail = "the claim store did not remove it";
@@ -311,10 +324,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
311
324
  console.log(chalk.red(`EAS session ${sessionId} is resolved, but its ownership claim could not be removed: ${detail}. The claim was retained for reconciliation.`));
312
325
  };
313
326
  if (registryExpanded) {
314
- deleteFailures += easSessionSweep.orphaned.length;
327
+ refuseAll("EAS session deletion refused: registered workspace roots changed after classification.");
315
328
  console.log(chalk.red("EAS session deletion refused: registered workspace roots changed after classification."));
316
329
  } else if (!currentRecords?.safe || !ledger.safe) {
317
- deleteFailures += easSessionSweep.orphaned.length;
330
+ refuseAll("EAS session deletion refused: the session records could not be read safely.");
318
331
  for (const notice of [...currentRecords?.notices ?? [], ...ledger.notice ? [ledger.notice] : []]) console.log(chalk.red(`EAS session deletion refused: ${notice}`));
319
332
  } else {
320
333
  const recorded = new Set(currentRecords.ids);
@@ -323,6 +336,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
323
336
  for (const session of easSessionSweep.orphaned) {
324
337
  if (recorded.has(session.id)) {
325
338
  console.log(chalk.dim(`EAS session ${session.id} has a workspace record and was left running.`));
339
+ recordGcResult("easSession", "kept", session.name, {
340
+ id: session.id,
341
+ detail: "it has a workspace record and was left running"
342
+ });
326
343
  continue;
327
344
  }
328
345
  const claim = ledger.claims.get(session.id);
@@ -330,11 +347,19 @@ async function deleteEasSessions(easSessionSweep, deps) {
330
347
  if (!claim || claim.name !== session.name || claim.platform !== session.platform || !claimState?.absent || claimState.notice) {
331
348
  deleteFailures++;
332
349
  console.log(chalk.red(`Could not verify fixed ownership for EAS session ${session.id}: ${claimState?.notice ?? "the claim is missing, mismatched, or still recorded in workspace state"}.`));
350
+ recordGcResult("easSession", "failed", session.name, {
351
+ id: session.id,
352
+ detail: `Could not verify fixed ownership for EAS session ${session.id}: ${claimState?.notice ?? "the claim is missing, mismatched, or still recorded in workspace state"}.`
353
+ });
333
354
  continue;
334
355
  }
335
356
  if (!eas) {
336
357
  deleteFailures++;
337
358
  console.log(chalk.red(`Could not verify EAS session ${session.id}: eas-cli is not available.`));
359
+ recordGcResult("easSession", "failed", session.name, {
360
+ id: session.id,
361
+ detail: `Could not verify EAS session ${session.id}: eas-cli is not available.`
362
+ });
338
363
  continue;
339
364
  }
340
365
  const options = {
@@ -352,6 +377,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
352
377
  } else {
353
378
  deleteFailures++;
354
379
  console.log(chalk.red(`Could not verify EAS session ${session.id}: ${describeError(error)}`));
380
+ recordGcResult("easSession", "failed", session.name, {
381
+ id: session.id,
382
+ detail: `Could not verify EAS session ${session.id}: ${describeError(error)}`
383
+ });
355
384
  }
356
385
  continue;
357
386
  }
@@ -364,6 +393,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
364
393
  if (inspection.action === "refused") {
365
394
  deleteFailures++;
366
395
  console.log(chalk.red(`Could not stop EAS session ${session.id}: ${inspection.reason}`));
396
+ recordGcResult("easSession", "failed", session.name, {
397
+ id: session.id,
398
+ detail: `Could not stop EAS session ${session.id}: ${inspection.reason}`
399
+ });
367
400
  continue;
368
401
  }
369
402
  let stopped;
@@ -372,15 +405,24 @@ async function deleteEasSessions(easSessionSweep, deps) {
372
405
  } catch (error) {
373
406
  deleteFailures++;
374
407
  console.log(chalk.red(`Could not stop EAS session ${session.id}: ${describeError(error)}`));
408
+ recordGcResult("easSession", "failed", session.name, {
409
+ id: session.id,
410
+ detail: `Could not stop EAS session ${session.id}: ${describeError(error)}`
411
+ });
375
412
  continue;
376
413
  }
377
414
  const verified = verifyStoppedSession(stopped, session.id);
378
415
  if (!verified.ok) {
379
416
  deleteFailures++;
380
417
  console.log(chalk.red(`Could not verify EAS session ${session.id} stopped: ${verified.reason}`));
418
+ recordGcResult("easSession", "failed", session.name, {
419
+ id: session.id,
420
+ detail: `Could not verify EAS session ${session.id} stopped: ${verified.reason}`
421
+ });
381
422
  continue;
382
423
  }
383
424
  console.log(chalk.green(`Stopped EAS session ${session.id} (${session.name})`));
425
+ recordGcResult("easSession", "done", session.name, { id: session.id });
384
426
  reconcileClaim(session.id);
385
427
  }
386
428
  }
@@ -391,8 +433,8 @@ async function deleteEasSessions(easSessionSweep, deps) {
391
433
  machineRoot: deps.easLedgerRoot
392
434
  });
393
435
  } catch (error) {
394
- deleteFailures += easSessionSweep.orphaned.length;
395
436
  const phase = finalSweepStarted ? "failed" : "lock acquisition failed";
437
+ refuseAll(`EAS session deletion ${phase}: ${describeError(error)}`);
396
438
  console.log(chalk.red(`EAS session deletion ${phase}: ${describeError(error)}`));
397
439
  }
398
440
  }
@@ -402,6 +444,8 @@ async function deleteEasSessions(easSessionSweep, deps) {
402
444
  //#region src/commands/gc/worktrees.ts
403
445
  const DEFAULT_WORKTREE_IDLE_DAYS = 7;
404
446
  const DAY_MS = 864e5;
447
+ const MINUTE_MS = 6e4;
448
+ const GRACE_SETTING = "gc.worktreeGraceMinutes";
405
449
  const MERGE_DECIDES = /* @__PURE__ */ new Set([
406
450
  "unpushed",
407
451
  "not-merged",
@@ -414,29 +458,77 @@ function skip(code, text) {
414
458
  text
415
459
  };
416
460
  }
417
- function worktreeSkipReason(facts, olderThan) {
461
+ function pullRequestNote(lookup) {
462
+ const pr = lookup && "pullRequest" in lookup ? lookup.pullRequest : null;
463
+ if (!pr) return null;
464
+ return pr.containsHead ? describePullRequest(pr) : `${describePullRequest(pr)}, and HEAD has commits it does not`;
465
+ }
466
+ /**
467
+ * Why gc keeps a worktree, or null when it removes it. A worktree that would be removed is still kept while its last
468
+ * activity, or its merge into the default branch, is less than `grace.ms` old, and kept when that activity is unknown.
469
+ */
470
+ function worktreeSkipReason(facts, olderThan, grace = {
471
+ ms: 0,
472
+ now: 0
473
+ }) {
474
+ return removalBlocker(facts, olderThan) ?? graceBlocker(facts, grace);
475
+ }
476
+ function graceBlocker(facts, grace) {
477
+ if (grace.ms <= 0) return null;
478
+ if (!facts.activity) return skip("activity-unknown", "its last activity could not be read");
479
+ const merged = facts.merge?.merged ? facts.merge : null;
480
+ const ended = endedPullRequest(facts.pullRequest);
481
+ const events = [facts.activity];
482
+ if (merged) events.push({
483
+ at: merged.mergedAt,
484
+ basis: `merged into ${merged.into}`
485
+ });
486
+ if (ended?.endedAt) events.push({
487
+ at: ended.endedAt,
488
+ basis: describePullRequest(ended)
489
+ });
490
+ const latest = events.reduce((a, b) => b.at > a.at ? b : a);
491
+ const eligibleAt = latest.at + grace.ms;
492
+ if (grace.now >= eligibleAt) return null;
493
+ const ago = formatLongDuration(Math.max(0, grace.now - latest.at));
494
+ return {
495
+ code: "recent-activity",
496
+ text: `recent activity: ${latest.basis} ${ago} ago; removable after ${new Date(eligibleAt).toISOString()}`,
497
+ eligibleAt
498
+ };
499
+ }
500
+ function removalBlocker(facts, olderThan) {
418
501
  if (facts.bare) return skip("bare-repository", "bare repository");
419
502
  if (typeof facts.source === "object") return skip("source-checkout-unknown", `source checkout unknown: ${facts.source.refusal}`);
420
503
  if (facts.source === "source") return skip("source-checkout", "source checkout");
421
504
  if (facts.locked) return skip("locked", "locked with git worktree lock");
422
505
  if (facts.inUse.length) return skip("in-use", `in use: ${facts.inUse.join("; ")}`);
423
506
  if (facts.porcelain === null) return skip("status-unreadable", "git status could not be read");
424
- if (excludePodChurn(facts.porcelain).lines.length) return skip("dirty", "dirty: uncommitted changes or untracked files");
507
+ const dirty = excludePodChurn(facts.porcelain).lines.length;
508
+ if (dirty) return skip("dirty", `dirty: ${plural(dirty, "uncommitted or untracked file")}`);
425
509
  if (facts.unpushed === null) return skip("unpushed-unchecked", "unpushed commits could not be checked");
426
510
  const merged = facts.merge?.merged ? facts.merge : null;
427
- if (facts.unpushed.length && !merged?.coversUnpushed) return skip("unpushed", `unpushed: ${plural(facts.unpushed.length, "commit")} on no remote or other branch`);
511
+ const ended = endedPullRequest(facts.pullRequest);
512
+ if (facts.unpushed.length && !merged?.coversUnpushed && ended?.state !== "merged") return skip("unpushed", `unpushed: ${plural(facts.unpushed.length, "commit")} on no remote or other branch`);
428
513
  if (facts.submodules) return skip("submodules", "initialized submodules");
429
- if (merged) return null;
514
+ if (merged || ended) return null;
430
515
  const notMerged = facts.merge && !facts.merge.merged ? facts.merge : null;
431
- if (olderThan === null) return skip(notMerged?.unknown ? "merge-unknown" : "not-merged", notMerged?.detail ?? "not merged");
432
- const also = notMerged ? `; ${notMerged.detail}` : "";
516
+ const details = [notMerged?.detail, pullRequestNote(facts.pullRequest)].filter(Boolean);
517
+ if (olderThan === null) return skip(notMerged?.unknown ? "merge-unknown" : "not-merged", details.join("; ") || "not merged");
518
+ const also = details.map((detail) => `; ${detail}`).join("");
433
519
  if (facts.idleDays === null) return skip("last-use-unknown", `recently used: its last use is unknown${also}`);
434
520
  if (facts.idleDays < olderThan) return skip("recently-used", `recently used ${facts.idleDays}d ago${also}`);
435
521
  return null;
436
522
  }
437
- /** Why gc removes a worktree it did not skip: `merged into origin/main` or `idle 9d`. */
523
+ /** Why gc removes a worktree it did not skip: `merged into origin/main`, `PR #12 closed` or `idle 9d`. */
438
524
  function worktreeRemovalReason(candidate) {
439
- return candidate.merge?.merged ? `merged into ${candidate.merge.into}` : `idle ${candidate.idleDays ?? 0}d`;
525
+ if (candidate.merge?.merged) return `merged into ${candidate.merge.into}`;
526
+ const ended = endedPullRequest(candidate.pullRequest);
527
+ return ended ? describePullRequest(ended) : `idle ${candidate.idleDays ?? 0}d`;
528
+ }
529
+ /** The pull request note a kept worktree's report line carries, such as `PR #12 merged`, or null. */
530
+ function worktreePullRequestNote(candidate) {
531
+ return pullRequestNote(candidate.pullRequest);
440
532
  }
441
533
  function lastUsedOf(keys) {
442
534
  const times = keys.map(workspaceLastUsed).filter(Number.isFinite);
@@ -446,8 +538,126 @@ function idleDaysOf(keys, now) {
446
538
  const last = lastUsedOf(keys);
447
539
  return Number.isFinite(last) ? Math.max(0, Math.floor((now - last) / DAY_MS)) : null;
448
540
  }
449
- function inUseOf(keys, checks) {
450
- return [...new Set(keys.flatMap((key) => workspaceInUse(key, checks)))];
541
+ function deviceReads() {
542
+ let sims;
543
+ let resolver;
544
+ return {
545
+ sims: () => {
546
+ if (sims === void 0) try {
547
+ sims = listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
548
+ } catch {
549
+ sims = null;
550
+ }
551
+ return sims;
552
+ },
553
+ avdSerial: (avdName) => (resolver ??= ownedAvdSerialResolver({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }))(avdName)
554
+ };
555
+ }
556
+ function inUseOf(keys, checks, devices = deviceReads()) {
557
+ return [.../* @__PURE__ */ new Set([...keys.flatMap((key) => workspaceInUse(key, checks)), ...deviceUseOf(keys, devices)])];
558
+ }
559
+ function deviceUseOf(keys, devices) {
560
+ const reasons = [];
561
+ for (const key of keys) {
562
+ let slots;
563
+ try {
564
+ slots = projectDeviceSlots(getProject(key));
565
+ } catch (error) {
566
+ reasons.push(`its device records cannot be read: ${error.message}`);
567
+ continue;
568
+ }
569
+ for (const { platforms } of slots) {
570
+ const ios = platforms.ios;
571
+ if (ios?.owned && ios.deviceUdid) {
572
+ const sims = devices.sims();
573
+ const sim = sims?.find((entry) => entry.udid === ios.deviceUdid);
574
+ if (sims === null) reasons.push(`the state of its owned simulator ${ios.deviceUdid} cannot be read`);
575
+ else if (sim && sim.state !== "Shutdown") reasons.push(`its owned simulator ${sim.name} is ${sim.state}; \`stim stop\` in ${key} shuts it down`);
576
+ }
577
+ const android = platforms.android;
578
+ if (android?.owned && android.avdName) try {
579
+ if (devices.avdSerial(android.avdName).serial) reasons.push(`its owned emulator ${android.avdName} is running; \`stim stop\` in ${key} shuts it down`);
580
+ } catch {
581
+ reasons.push(`the state of its owned emulator ${android.avdName} cannot be read`);
582
+ }
583
+ }
584
+ }
585
+ const holders = new Set(keys.map(canonicalPath));
586
+ const now = Date.now();
587
+ for (const { lease } of listLeaseFiles()) if (lease && !leaseIsExpired(lease, now) && holders.has(canonicalPath(lease.holder))) reasons.push(`it holds the ${lease.platform} device lease on ${lease.deviceName ?? lease.id}`);
588
+ return reasons;
589
+ }
590
+ function newestMtime(paths) {
591
+ let newest = null;
592
+ for (const { path, basis } of paths) {
593
+ let mtime;
594
+ try {
595
+ mtime = statSync$1(path).mtimeMs;
596
+ } catch (error) {
597
+ if (error.code === "ENOENT") continue;
598
+ return "unreadable";
599
+ }
600
+ if (!newest || mtime > newest.at) newest = {
601
+ at: mtime,
602
+ basis
603
+ };
604
+ }
605
+ return newest;
606
+ }
607
+ function logFiles$1(key) {
608
+ const dir = workspaceLogsDir(key);
609
+ try {
610
+ return readdirSync$1(dir).map((name) => ({
611
+ path: join(dir, name),
612
+ basis: "a Stim log write"
613
+ }));
614
+ } catch (error) {
615
+ return error.code === "ENOENT" ? [] : null;
616
+ }
617
+ }
618
+ function worktreeActivityOf(path, keys) {
619
+ const gitDir = getExecutor().runFileQuiet("git", [
620
+ "--no-optional-locks",
621
+ "-C",
622
+ path,
623
+ "rev-parse",
624
+ "--path-format=absolute",
625
+ "--git-dir"
626
+ ])?.trim();
627
+ if (!gitDir) return null;
628
+ const paths = [
629
+ {
630
+ path: join(gitDir, "index"),
631
+ basis: "a git index write"
632
+ },
633
+ {
634
+ path: join(gitDir, "HEAD"),
635
+ basis: "a git HEAD change"
636
+ },
637
+ {
638
+ path: join(gitDir, "logs", "HEAD"),
639
+ basis: "a git reflog entry"
640
+ }
641
+ ];
642
+ for (const key of keys) {
643
+ const logs = logFiles$1(key);
644
+ if (logs === null) return null;
645
+ paths.push({
646
+ path: workspaceStateFile(key),
647
+ basis: "a Stim workspace state write"
648
+ }, ...logs);
649
+ }
650
+ const newest = newestMtime(paths);
651
+ return newest === "unreadable" ? null : newest;
652
+ }
653
+ function graceMsSetting() {
654
+ const { value, error } = machineNumber(GRACE_SETTING, loadConfig$1(), process.env);
655
+ if (error) {
656
+ const fallback = Number(settingDefinition(GRACE_SETTING)?.default);
657
+ console.error(chalk.yellow(`${error} gc uses the default of ${fallback} minutes.`));
658
+ return fallback * MINUTE_MS;
659
+ }
660
+ return (value ?? 0) * MINUTE_MS;
451
661
  }
452
662
  function porcelainOf(path, gitAnswered) {
453
663
  if (gitAnswered === null) return null;
@@ -456,15 +666,15 @@ function porcelainOf(path, gitAnswered) {
456
666
  return lines.length ? lines : null;
457
667
  }
458
668
  function candidateRoots() {
459
- const registered = Object.keys(loadConfig()?.projects ?? {}).filter(isAbsolute);
669
+ const registered = Object.keys(loadConfig$1()?.projects ?? {}).filter(isAbsolute);
460
670
  const recorded = listWorkspaceDirs().flatMap((entry) => entry.projectRoot ? [entry.projectRoot] : []);
461
- return [.../* @__PURE__ */ new Set([...registered, ...recorded])].filter((root) => existsSync(root)).toSorted();
671
+ return [.../* @__PURE__ */ new Set([...registered, ...recorded])].filter((root) => existsSync$1(root)).toSorted();
462
672
  }
463
- function checkMergeStates(pending, idle, now) {
673
+ function checkMergeStates(pending, idle, grace) {
464
674
  const repos = /* @__PURE__ */ new Map();
465
675
  for (const entry of pending) repos.set(entry.repo, [...repos.get(entry.repo) ?? [], entry]);
466
676
  for (const [repo, entries] of repos) {
467
- const target = fetchDefaultBranch(repo, now);
677
+ const target = fetchDefaultBranch(repo, grace.now);
468
678
  for (const { candidate, facts } of entries) {
469
679
  const merge = "error" in target ? {
470
680
  merged: false,
@@ -474,21 +684,28 @@ function checkMergeStates(pending, idle, now) {
474
684
  const verdict = worktreeSkipReason({
475
685
  ...facts,
476
686
  merge
477
- }, idle);
687
+ }, idle, grace);
478
688
  Object.assign(candidate, {
479
689
  merge,
480
690
  skipCode: verdict?.code ?? null,
481
- skipped: verdict?.text ?? null
691
+ skipped: verdict?.text ?? null,
692
+ eligibleAt: verdict?.eligibleAt ?? null
482
693
  });
483
694
  }
484
695
  }
485
696
  }
486
697
  /**
487
698
  * Classifies each Stim-managed linked worktree. A merged one is removable; with `idle`, so is one unused for
488
- * `olderThan` days. Merge state is checked only where it decides the verdict, after one fetch per repository.
699
+ * `olderThan` days. Device use is read last, in one pass against one simulator list and one AVD resolver, so
700
+ * the resolver's time budget covers only device reads. Pull requests are asked once per repository, all
701
+ * repositories at once. Merge state is checked only where it decides the verdict, after one fetch per repository.
489
702
  */
490
- function collectWorktreeSweep({ idle, olderThan, now }) {
703
+ async function collectWorktreeSweep({ idle, olderThan, now }) {
491
704
  const days = idle ? olderThan ?? DEFAULT_WORKTREE_IDLE_DAYS : null;
705
+ const grace = {
706
+ ms: graceMsSetting(),
707
+ now
708
+ };
492
709
  const groups = /* @__PURE__ */ new Map();
493
710
  const outside = [];
494
711
  for (const root of candidateRoots()) {
@@ -499,15 +716,17 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
499
716
  keys: [root],
500
717
  idleDays: null,
501
718
  merge: null,
719
+ head: null,
720
+ pullRequest: null,
502
721
  skipCode: "not-a-worktree",
503
- skipped: "not inside a git worktree"
722
+ skipped: "not inside a git worktree",
723
+ eligibleAt: null
504
724
  });
505
725
  continue;
506
726
  }
507
727
  groups.set(entry.path, [...groups.get(entry.path) ?? [], root]);
508
728
  }
509
- const worktrees = [];
510
- const pending = [];
729
+ const inspected = [];
511
730
  for (const [path, roots] of groups) {
512
731
  const entries = listWorktrees(path);
513
732
  const entry = matchWorktreeEntry(entries, path);
@@ -515,7 +734,9 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
515
734
  const keys = [.../* @__PURE__ */ new Set([...roots, ...reclaimKeys(path)])];
516
735
  const idleDays = idleDaysOf(keys, now);
517
736
  const linked = !("refusal" in source) && entry !== null && source.path !== entry.path;
737
+ const activity = linked && grace.ms > 0 ? worktreeActivityOf(path, keys) : null;
518
738
  const gitAnswered = linked ? hasUncommittedWork(path) : null;
739
+ const head = linked ? resolveFullRef(path, "HEAD") : null;
519
740
  const facts = {
520
741
  source: "refusal" in source ? source : linked ? "linked" : "source",
521
742
  bare: Boolean(entry?.bare),
@@ -523,36 +744,82 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
523
744
  porcelain: porcelainOf(path, gitAnswered),
524
745
  unpushed: linked ? unpushedCommits(path) : null,
525
746
  submodules: linked && hasPopulatedSubmodules(path),
526
- inUse: linked ? inUseOf(keys, { managedLocks: true }) : [],
527
- idleDays,
528
- merge: null
529
- };
530
- const verdict = worktreeSkipReason(facts, days);
531
- const candidate = {
532
- path,
533
- keys,
747
+ inUse: [],
534
748
  idleDays,
535
749
  merge: null,
536
- skipCode: verdict?.code ?? null,
537
- skipped: verdict?.text ?? null
750
+ pullRequest: null,
751
+ activity
538
752
  };
539
- if (verdict && MERGE_DECIDES.has(verdict.code) && !("refusal" in source)) pending.push({
753
+ inspected.push({
754
+ candidate: {
755
+ path,
756
+ keys,
757
+ idleDays,
758
+ merge: null,
759
+ head,
760
+ pullRequest: null,
761
+ skipCode: null,
762
+ skipped: null,
763
+ eligibleAt: null
764
+ },
765
+ facts,
766
+ repo: "refusal" in source ? null : source.path,
767
+ branch: head && entry?.branch ? entry.branch : null
768
+ });
769
+ }
770
+ const linked = inspected.filter(({ facts }) => facts.source === "linked");
771
+ for (const { candidate, facts } of linked) facts.inUse = candidate.keys.flatMap((key) => workspaceInUse(key, { managedLocks: true }));
772
+ const devices = deviceReads();
773
+ for (const { candidate, facts } of linked) facts.inUse = [.../* @__PURE__ */ new Set([...facts.inUse, ...deviceUseOf(candidate.keys, devices)])];
774
+ await lookUpPullRequests(inspected);
775
+ const worktrees = [];
776
+ const pending = [];
777
+ for (const { candidate, facts, repo } of inspected) {
778
+ const verdict = worktreeSkipReason(facts, days, grace);
779
+ Object.assign(candidate, {
780
+ pullRequest: facts.pullRequest,
781
+ skipCode: verdict?.code ?? null,
782
+ skipped: verdict?.text ?? null,
783
+ eligibleAt: verdict?.eligibleAt ?? null
784
+ });
785
+ if (verdict && MERGE_DECIDES.has(verdict.code) && repo !== null) pending.push({
540
786
  candidate,
541
787
  facts,
542
- repo: source.path
788
+ repo
543
789
  });
544
790
  worktrees.push(candidate);
545
791
  }
546
- checkMergeStates(pending, days, now);
792
+ checkMergeStates(pending, days, grace);
547
793
  const listed = [...worktrees, ...outside].filter((w) => idle || w.skipCode !== "not-a-worktree" && w.skipCode !== "source-checkout");
548
794
  return {
549
795
  idle: days === null ? null : {
550
796
  olderThan: days,
551
797
  defaulted: olderThan === null
552
798
  },
799
+ graceMs: grace.ms,
553
800
  worktrees: listed
554
801
  };
555
802
  }
803
+ async function lookUpPullRequests(inspected) {
804
+ const repos = /* @__PURE__ */ new Map();
805
+ for (const { candidate, facts, repo, branch } of inspected) {
806
+ if (repo === null || branch === null || candidate.head === null) continue;
807
+ const query = {
808
+ cwd: candidate.path,
809
+ branch,
810
+ head: candidate.head
811
+ };
812
+ repos.set(repo, [...repos.get(repo) ?? [], {
813
+ facts,
814
+ query
815
+ }]);
816
+ }
817
+ const lookup = pullRequestLookups();
818
+ await Promise.all([...repos].map(async ([repo, entries]) => {
819
+ const answers = await lookup(repo, entries.map(({ query }) => query));
820
+ entries.forEach(({ facts }, i) => facts.pullRequest = answers[i] ?? null);
821
+ }));
822
+ }
556
823
  async function removeWorktrees(sweep, { now = Date.now() } = {}) {
557
824
  let failures = 0;
558
825
  for (const candidate of sweep.worktrees) {
@@ -562,31 +829,51 @@ async function removeWorktrees(sweep, { now = Date.now() } = {}) {
562
829
  const keys = [.../* @__PURE__ */ new Set([...candidate.keys, ...lockedKeys])];
563
830
  const unlocked = keys.filter((key) => !lockedKeys.includes(key));
564
831
  const reasons = [...inUseOf(lockedKeys, { managedLocks: false }), ...inUseOf(unlocked, { managedLocks: true })].map((r) => `in use: ${r}`);
565
- if (candidate.merge?.merged) {
566
- if (resolveFullRef(candidate.path, "HEAD") !== candidate.merge.head) reasons.push("its HEAD moved since gc checked it");
832
+ const mergedHead = candidate.merge?.merged ? candidate.merge.head : endedPullRequest(candidate.pullRequest) ? candidate.head : null;
833
+ if (mergedHead) {
834
+ if (resolveFullRef(candidate.path, "HEAD") !== mergedHead) reasons.push("its HEAD moved since gc checked it");
567
835
  } else {
568
836
  const idleDays = idleDaysOf(keys, now);
569
837
  if (idleDays === null || !sweep.idle || idleDays < sweep.idle.olderThan) reasons.push(`used ${idleDays === null ? "at an unknown time" : `${idleDays}d ago`} since gc checked it`);
570
838
  }
839
+ if (sweep.graceMs > 0) {
840
+ const recent = graceBlocker({
841
+ merge: candidate.merge,
842
+ pullRequest: candidate.pullRequest,
843
+ activity: worktreeActivityOf(candidate.path, keys)
844
+ }, {
845
+ ms: sweep.graceMs,
846
+ now: Date.now()
847
+ });
848
+ if (recent) reasons.push(recent.text);
849
+ }
571
850
  kept = reasons;
572
851
  return reasons;
573
852
  };
574
853
  let removed = false;
854
+ let error = null;
575
855
  try {
576
- const mergedHead = candidate.merge?.merged && candidate.merge.coversUnpushed ? candidate.merge.head : void 0;
856
+ const ended = endedPullRequest(candidate.pullRequest);
857
+ const mergedHead = candidate.merge?.merged && candidate.merge.coversUnpushed ? candidate.merge.head : ended?.state === "merged" ? candidate.head ?? void 0 : void 0;
577
858
  removed = await removeWorktreeTarget(candidate.path, {
578
859
  linkedOnly: true,
579
860
  guard,
580
861
  mergedHead
581
862
  });
582
- } catch (error) {
583
- console.error(chalk.red(`Could not remove ${candidate.path}: ${error?.message || String(error)}`));
863
+ } catch (thrown) {
864
+ error = thrown?.message || String(thrown);
865
+ console.error(chalk.red(`Could not remove ${candidate.path}: ${error}`));
584
866
  }
585
- if (removed) console.log(chalk.green(`Removed the worktree ${candidate.path} (${worktreeRemovalReason(candidate)})`));
586
- else if (kept.length) console.log(chalk.yellow(`Kept the worktree ${candidate.path}: ${kept.join("; ")}`));
587
- else {
867
+ if (removed) {
868
+ console.log(chalk.green(`Removed the worktree ${candidate.path} (${worktreeRemovalReason(candidate)})`));
869
+ recordGcResult("worktree", "done", candidate.path, { detail: worktreeRemovalReason(candidate) });
870
+ } else if (kept.length) {
871
+ console.log(chalk.yellow(`Kept the worktree ${candidate.path}: ${kept.join("; ")}`));
872
+ recordGcResult("worktree", "kept", candidate.path, { detail: kept.join("; ") });
873
+ } else {
588
874
  failures++;
589
875
  console.error(chalk.yellow(`Kept the worktree ${candidate.path}; see the lines above for why.`));
876
+ recordGcResult("worktree", "failed", candidate.path, { detail: error ?? "stim worktree remove did not remove it; see the details" });
590
877
  }
591
878
  }
592
879
  return failures;
@@ -641,7 +928,7 @@ function unresolvedLockLines(locks, slots) {
641
928
  " run that yourself once you know nothing is building with it"
642
929
  ];
643
930
  }
644
- function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invalidProjects = [], orphanedWorkspaces = [], parkedSims = [], parkedAvds = [], orphanedDevices = [], unverifiedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
931
+ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invalidProjects = [], orphanedWorkspaces = [], parkedSims = [], parkedAvds = [], orphanedDevices = [], unverifiedDevices = [], staleDevices = [], staleDeviceRecords = [], staleLedgerEntries = [], buildLocks = {
645
932
  stale: [],
646
933
  live: [],
647
934
  unresolved: []
@@ -657,7 +944,7 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
657
944
  orphaned: [],
658
945
  notices: [],
659
946
  deletionSafe: true
660
- }, caches = [], workspaceOutputs = null, worktreeSweep = null, cacheScope = null, olderThan = null }, { now = Date.now() } = {}) {
947
+ }, caches = [], workspaceOutputs = null, workspaceLogs = [], worktreeSweep = null, cacheScope = null, olderThan = null }, { now = Date.now() } = {}) {
661
948
  const lines = [];
662
949
  const staleLocks = buildLocks?.stale ?? [];
663
950
  const liveLocks = buildLocks?.live ?? [];
@@ -673,11 +960,13 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
673
960
  orphanedDevices,
674
961
  staleDevices,
675
962
  staleDeviceRecords,
963
+ staleLedgerEntries,
676
964
  staleLocks,
677
965
  staleSlots,
678
966
  expiredLeases,
679
967
  easSessionSweep.orphaned,
680
968
  workspaceOutputs?.workspaces.filter((entry) => entry.willClear) ?? [],
969
+ workspaceLogs.filter((entry) => entry.willTrim),
681
970
  worktreeSweep?.worktrees.filter((entry) => !entry.skipped) ?? []
682
971
  ].every((found) => found.length === 0)) {
683
972
  const reasons = [];
@@ -699,7 +988,7 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
699
988
  for (const d of orphanedDevices) lines.push(` ${d.kind} ${d.name} (${d.id})${deviceSizeSuffix(d)}`);
700
989
  }
701
990
  if (unverifiedDevices.length) {
702
- lines.push(`Unrecognized stim-* devices (${unverifiedDevices.length}) - NOT deleted, because Stim has no record of creating them:`);
991
+ lines.push(`Unrecognized stim-* devices (${unverifiedDevices.length}) - NOT deleted, because this Stim home has no record of creating them:`);
703
992
  for (const d of unverifiedDevices) {
704
993
  lines.push(` ${d.kind} ${d.name} (${d.id})`);
705
994
  lines.push(` ${unverifiedDeviceCommand(d)}`);
@@ -722,6 +1011,11 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
722
1011
  }
723
1012
  lines.push(" --delete clears the RECORD only; there is no device left to touch.");
724
1013
  }
1014
+ if (staleLedgerEntries.length) {
1015
+ lines.push(`Stale device ledger entries (${staleLedgerEntries.length}) - Stim created these devices or browser profiles; they are gone:`);
1016
+ for (const entry of staleLedgerEntries) lines.push(` ${entry.kind} ${entry.id} is not on this machine`);
1017
+ lines.push(" --delete forgets the LEDGER ENTRY only; there is no device left to touch.");
1018
+ }
725
1019
  if (easSessionSweep.orphaned.length) {
726
1020
  lines.push(`Orphaned EAS sessions (${easSessionSweep.orphaned.length}) - current EAS project only:`);
727
1021
  for (const session of easSessionSweep.orphaned) {
@@ -766,9 +1060,21 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
766
1060
  lines.push(`Skipped (${skipped.length}) - not classified as dead:`);
767
1061
  for (const entry of skipped) lines.push(` ${entry.dir}: ${entry.reason}`);
768
1062
  }
1063
+ lines.push(...workspaceLogLines(workspaceLogs));
769
1064
  lines.push(...cacheLines(caches, workspaceOutputs));
770
1065
  return lines;
771
1066
  }
1067
+ function workspaceLogLines(workspaceLogs) {
1068
+ const over = workspaceLogs.filter((w) => w.trimBytes > 0);
1069
+ if (!over.length) return [];
1070
+ const total = workspaceLogs.reduce((n, w) => n + w.bytes, 0);
1071
+ const lines = [`Workspace logs (${formatBytes(total)} in ${workspaceLogs.length} workspace${workspaceLogs.length === 1 ? "" : "s"}) - ${over.length} with a log over twice the 8 MiB cap:`];
1072
+ for (const w of over) {
1073
+ lines.push(` ${formatBytes(w.bytes).padStart(10)} ${w.projectRoot ?? w.dir}`);
1074
+ lines.push(w.willTrim ? ` would TRIM ${formatBytes(w.trimBytes)}: Metro, client and device logs keep their newest 8 MiB` : ` kept: ${w.keptReason}`);
1075
+ }
1076
+ return lines;
1077
+ }
772
1078
  function leaseGarbageLines({ expired, kept }) {
773
1079
  const lines = [];
774
1080
  if (expired.length) {
@@ -787,7 +1093,7 @@ function leaseGarbageLines({ expired, kept }) {
787
1093
  return lines;
788
1094
  }
789
1095
  function deviceSizeSuffix(device) {
790
- return device.kind === "android" && device.bytes !== void 0 ? ` - ${formatBytes(device.bytes)} on disk` : "";
1096
+ return device.bytes !== void 0 ? ` - ${formatBytes(device.bytes)} on disk` : "";
791
1097
  }
792
1098
  function namedPortLines(ports = []) {
793
1099
  if (!ports.length) return [];
@@ -855,11 +1161,14 @@ function worktreeSweepLines(sweep) {
855
1161
  if (!sweep || !sweep.idle && !sweep.worktrees.length) return [];
856
1162
  const removable = sweep.worktrees.filter((w) => !w.skipped);
857
1163
  const idle = !sweep.idle ? "" : sweep.idle.defaulted ? `, or clean, pushed and idle ${sweep.idle.olderThan}d or more (the default without --older-than)` : `, or clean, pushed and idle ${sweep.idle.olderThan}d or more`;
858
- const lines = [`Linked worktrees (${removable.length} removable, ${sweep.worktrees.length - removable.length} kept) - clean and merged into the default branch${idle}:`];
1164
+ const lines = [`Linked worktrees (${removable.length} removable, ${sweep.worktrees.length - removable.length} kept) - clean and merged into the default branch, or with a merged or closed pull request${idle}:`];
1165
+ const unknown = new Set(sweep.worktrees.flatMap((w) => w.pullRequest && "unavailable" in w.pullRequest ? [w.pullRequest.unavailable] : []));
1166
+ for (const reason of unknown) lines.push(` Pull request state unknown (${reason}); merges are judged from git alone.`);
859
1167
  for (const w of sweep.worktrees) {
860
1168
  const age = w.idleDays === null ? "" : ` (idle ${w.idleDays}d)`;
861
1169
  lines.push(` ${w.path}${age}`);
862
- lines.push(w.skipped ? ` kept: ${w.skipped}` : ` would be REMOVED by \`stim worktree remove\`: ${worktreeRemovalReason(w)}`);
1170
+ const note = worktreePullRequestNote(w);
1171
+ lines.push(w.skipped ? ` kept: ${w.skipped}${note && !w.skipped.includes(note) ? ` (${note})` : ""}` : ` would be REMOVED by \`stim worktree remove\`: ${worktreeRemovalReason(w)}`);
863
1172
  }
864
1173
  if (removable.length) lines.push(" --delete runs it without --force; use, idleness and HEAD are re-checked under its removal locks.");
865
1174
  return lines;
@@ -873,7 +1182,16 @@ function jsonLock({ path, platform, key, pid, projectRoot }) {
873
1182
  projectRoot
874
1183
  };
875
1184
  }
876
- function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [], invalidProjects = [], orphanedWorkspaces = [], worktreeSweep = null, parkedSims = [], parkedAvds = [], orphanedDevices = [], unverifiedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
1185
+ function jsonPullRequest(lookup) {
1186
+ const pr = lookup && "pullRequest" in lookup ? lookup.pullRequest : null;
1187
+ return pr ? {
1188
+ number: pr.number,
1189
+ state: pr.state,
1190
+ url: pr.url,
1191
+ containsHead: pr.containsHead
1192
+ } : null;
1193
+ }
1194
+ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [], invalidProjects = [], orphanedWorkspaces = [], worktreeSweep = null, parkedSims = [], parkedAvds = [], orphanedDevices = [], unverifiedDevices = [], staleDevices = [], staleDeviceRecords = [], staleLedgerEntries = [], buildLocks = {
877
1195
  stale: [],
878
1196
  live: [],
879
1197
  unresolved: []
@@ -889,7 +1207,7 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
889
1207
  orphaned: [],
890
1208
  notices: [],
891
1209
  deletionSafe: true
892
- }, workspaceOutputs = null, caches = [] }) {
1210
+ }, workspaceOutputs = null, workspaceLogs = [], caches = [] }) {
893
1211
  return {
894
1212
  deadProjects: deadProjects.map((path) => ({ path })),
895
1213
  invalidProjects: invalidProjects.map((path) => ({ path })),
@@ -907,9 +1225,12 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
907
1225
  path: w.path,
908
1226
  idleDays: w.idleDays,
909
1227
  mergedInto: w.merge?.merged ? w.merge.into : null,
1228
+ pullRequest: jsonPullRequest(w.pullRequest),
1229
+ pullRequestUnknown: w.pullRequest && "unavailable" in w.pullRequest ? w.pullRequest.unavailable : null,
910
1230
  willRemove: w.skipCode === null,
911
1231
  reason: w.skipCode,
912
- detail: w.skipped ?? worktreeRemovalReason(w)
1232
+ detail: w.skipped ?? worktreeRemovalReason(w),
1233
+ eligibleAt: w.eligibleAt === null ? null : new Date(w.eligibleAt).toISOString()
913
1234
  })),
914
1235
  parkedSimulators: parkedSims.map(({ udid, name, model, runtime, parkedAt, bytes, listed }) => ({
915
1236
  udid,
@@ -956,6 +1277,10 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
956
1277
  project: r.project,
957
1278
  slot: r.slot ?? null
958
1279
  })),
1280
+ staleLedgerEntries: staleLedgerEntries.map(({ kind, id }) => ({
1281
+ kind,
1282
+ id
1283
+ })),
959
1284
  idleDevices: idleDevices.map(({ kind, id, name, project, slot, lastActivityAt, idleForMs, buildInProgress }) => ({
960
1285
  kind,
961
1286
  id,
@@ -1007,6 +1332,15 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
1007
1332
  path: dir,
1008
1333
  detail: reason
1009
1334
  })),
1335
+ workspaceLogs: workspaceLogs.map((w) => ({
1336
+ dir: w.dir,
1337
+ projectRoot: w.projectRoot,
1338
+ bytes: w.bytes,
1339
+ trimBytes: w.trimBytes,
1340
+ willTrim: w.willTrim,
1341
+ reason: w.keptCode,
1342
+ detail: w.keptReason
1343
+ })),
1010
1344
  workspaceBuildOutputs: (workspaceOutputs?.workspaces ?? []).map((w) => ({
1011
1345
  dir: w.dir,
1012
1346
  projectRoot: w.projectRoot,
@@ -1028,11 +1362,505 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
1028
1362
  };
1029
1363
  }
1030
1364
  //#endregion
1365
+ //#region src/commands/gc/ledger.ts
1366
+ /** `sims` must be a complete listing, unavailable simulators included, taken after `ledger` was read. */
1367
+ function findStaleLedgerEntries(ledger, sims) {
1368
+ const listed = new Set(sims.map((sim) => sim.udid));
1369
+ return [...ledger.ios].filter((udid) => !listed.has(udid)).map((id) => ({
1370
+ kind: "ios",
1371
+ id
1372
+ }));
1373
+ }
1374
+ /**
1375
+ * `registered` must come from an emulator listing that succeeded after `ledger` was read. A name stays while an
1376
+ * unfinished setup reserves it, or while any AVD root could still hold it.
1377
+ */
1378
+ function findStaleAndroidLedgerEntries(ledger, registered, config) {
1379
+ const listed = new Set(registered);
1380
+ return [...ledger.android].filter((name) => !listed.has(name) && isStaleAvdName(name, config)).map((id) => ({
1381
+ kind: "android",
1382
+ id
1383
+ }));
1384
+ }
1385
+ function isStaleAvdName(name, config) {
1386
+ try {
1387
+ return !reservedForSetup(name, config) && avdNameAbsent(name);
1388
+ } catch {
1389
+ return false;
1390
+ }
1391
+ }
1392
+ function reservedForSetup(name, config) {
1393
+ return Object.values(config?.projects ?? {}).some((project) => projectDeviceSlots(project).some(({ platforms }) => platforms.android?.setupIncomplete && platforms.android.avdName === name));
1394
+ }
1395
+ function forgetStaleAvdName(name) {
1396
+ let claim = null;
1397
+ try {
1398
+ claim = acquireAvdClaim(name);
1399
+ return withConfigLock$1(() => {
1400
+ if (!isStaleAvdName(name, loadConfig$1())) return false;
1401
+ forgetCreatedDevice("android", name);
1402
+ return true;
1403
+ });
1404
+ } finally {
1405
+ (0, ownership_claim_exports.releaseClaim)(claim);
1406
+ }
1407
+ }
1408
+ /**
1409
+ * Browser profiles the ledger lists whose directory is gone while the workspaces directory that held it is
1410
+ * present and the workspace is not a link to a missing volume, so an unmounted volume never reads as a deleted
1411
+ * profile.
1412
+ */
1413
+ function staleBrowserProfiles(ledger, exists = existsSync) {
1414
+ return [...ledger.web].filter((path) => isStaleProfile(path, exists)).map((id) => ({
1415
+ kind: "web",
1416
+ id
1417
+ }));
1418
+ }
1419
+ function isStaleProfile(path, exists) {
1420
+ const workspace = dirname$1(dirname$1(path));
1421
+ return !exists(path) && exists(dirname$1(workspace)) && !isDanglingLink(workspace);
1422
+ }
1423
+ function isDanglingLink(path) {
1424
+ try {
1425
+ return lstatSync(path).isSymbolicLink() && !existsSync(path);
1426
+ } catch {
1427
+ return false;
1428
+ }
1429
+ }
1430
+ function forgetStaleLedgerEntries(entries) {
1431
+ let failures = 0;
1432
+ for (const entry of entries) {
1433
+ if (entry.kind === "web" && !isStaleProfile(entry.id, existsSync)) continue;
1434
+ try {
1435
+ if (entry.kind === "android") {
1436
+ if (!forgetStaleAvdName(entry.id)) {
1437
+ console.log(chalk.dim(`Kept the ledger entry for android ${entry.id}: the AVD or its setup reappeared`));
1438
+ continue;
1439
+ }
1440
+ } else forgetCreatedDevice(entry.kind, entry.id);
1441
+ console.log(chalk.green(`Forgot the ledger entry for ${entry.kind} ${entry.id} (not on this machine)`));
1442
+ } catch (error) {
1443
+ failures++;
1444
+ console.log(chalk.red(`Could not forget the ledger entry for ${entry.kind} ${entry.id}: ${error?.message || error}`));
1445
+ }
1446
+ }
1447
+ return failures;
1448
+ }
1449
+ //#endregion
1450
+ //#region src/commands/gc/logs.ts
1451
+ const CAPPED_LOGS = [
1452
+ "metro.ndjson",
1453
+ "client.ndjson",
1454
+ "device.ndjson"
1455
+ ];
1456
+ const TRIM_ABOVE_BYTES = 2 * LOG_ROTATE_BYTES;
1457
+ function fileSize(path) {
1458
+ try {
1459
+ const stat = statSync$1(path);
1460
+ return stat.isFile() ? stat.size : 0;
1461
+ } catch {
1462
+ return 0;
1463
+ }
1464
+ }
1465
+ function cappedFiles(logsDir) {
1466
+ return CAPPED_LOGS.flatMap((name) => {
1467
+ const log = join(logsDir, name);
1468
+ return [{
1469
+ file: log,
1470
+ log
1471
+ }, {
1472
+ file: rotatedLogPath(log),
1473
+ log
1474
+ }];
1475
+ });
1476
+ }
1477
+ function trimmable(size) {
1478
+ return size > TRIM_ABOVE_BYTES ? size - LOG_ROTATE_BYTES : 0;
1479
+ }
1480
+ function excess(logsDir) {
1481
+ return cappedFiles(logsDir).reduce((sum, { file }) => sum + trimmable(fileSize(file)), 0);
1482
+ }
1483
+ function collectorReason(root) {
1484
+ const platforms = Object.keys(readCollectors(root));
1485
+ return platforms.length ? `a device log collector is recorded for ${platforms.join(", ")}` : null;
1486
+ }
1487
+ function collectWorkspaceLogs({ exclude = [] } = {}) {
1488
+ const mountedVolumes = listMountedVolumes();
1489
+ const entries = [];
1490
+ for (const { dir, projectRoot, problem } of listWorkspaceDirs()) {
1491
+ if (exclude.includes(dir)) continue;
1492
+ if (projectRoot !== null && !isOnMountedVolume(projectRoot, mountedVolumes)) continue;
1493
+ const logsDir = join(dir, "logs");
1494
+ let names;
1495
+ try {
1496
+ names = readdirSync$1(logsDir);
1497
+ } catch {
1498
+ continue;
1499
+ }
1500
+ const bytes = names.reduce((sum, name) => sum + fileSize(join(logsDir, name)), 0);
1501
+ if (bytes === 0) continue;
1502
+ const trimBytes = excess(logsDir);
1503
+ let keptCode = null;
1504
+ let keptReason = null;
1505
+ if (trimBytes > 0) {
1506
+ if (projectRoot === null) {
1507
+ keptCode = "unresolved";
1508
+ keptReason = `workspace directory not resolved: ${problem ?? "unknown project root"}`;
1509
+ } else {
1510
+ const inUse = workspaceInUse(projectRoot);
1511
+ const collector = collectorReason(projectRoot);
1512
+ if (inUse.length) {
1513
+ keptCode = "in-use";
1514
+ keptReason = `in use: ${inUse.join("; ")}`;
1515
+ } else if (collector) {
1516
+ keptCode = "collector";
1517
+ keptReason = collector;
1518
+ }
1519
+ }
1520
+ }
1521
+ entries.push({
1522
+ dir,
1523
+ projectRoot,
1524
+ bytes,
1525
+ trimBytes,
1526
+ willTrim: trimBytes > 0 && keptCode === null,
1527
+ keptCode,
1528
+ keptReason
1529
+ });
1530
+ }
1531
+ return entries;
1532
+ }
1533
+ function trimToNewest({ file, log }, maxBytes) {
1534
+ if (!trimmable(fileSize(file))) return 0;
1535
+ return withDirLock(`${log}.lock`, () => {
1536
+ const { size, atime, mtime } = statSync$1(file);
1537
+ if (!trimmable(size)) return 0;
1538
+ const tail = Buffer.alloc(maxBytes);
1539
+ const fd = openSync$1(file, "r");
1540
+ try {
1541
+ readSync$1(fd, tail, 0, maxBytes, size - maxBytes);
1542
+ } finally {
1543
+ closeSync$1(fd);
1544
+ }
1545
+ const newline = tail.indexOf(10);
1546
+ const kept = newline === -1 ? Buffer.alloc(0) : tail.subarray(newline + 1);
1547
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
1548
+ try {
1549
+ writeFileSync$1(tmp, kept);
1550
+ utimesSync(tmp, atime, mtime);
1551
+ renameSync$1(tmp, file);
1552
+ } catch (error) {
1553
+ rmSync$1(tmp, { force: true });
1554
+ throw error;
1555
+ }
1556
+ return size - kept.length;
1557
+ });
1558
+ }
1559
+ async function trimWorkspaceLogs(entries) {
1560
+ let failures = 0;
1561
+ let trimmed = 0;
1562
+ for (const entry of entries) {
1563
+ const root = entry.projectRoot;
1564
+ if (!entry.willTrim || root === null) {
1565
+ if (entry.keptReason) console.log(chalk.dim(`Kept the logs of ${root ?? entry.dir}: ${entry.keptReason}`));
1566
+ continue;
1567
+ }
1568
+ let run;
1569
+ try {
1570
+ run = await withIdleWorkspace(root, () => {
1571
+ const collector = collectorReason(root);
1572
+ if (collector) return {
1573
+ kept: collector,
1574
+ bytes: 0
1575
+ };
1576
+ return {
1577
+ kept: null,
1578
+ bytes: cappedFiles(join(entry.dir, "logs")).reduce((sum, capped) => sum + trimToNewest(capped, LOG_ROTATE_BYTES), 0)
1579
+ };
1580
+ }, { purpose: "gc" });
1581
+ } catch (error) {
1582
+ failures++;
1583
+ console.log(chalk.red(`Could not trim the logs of ${root}: ${error?.message || String(error)}`));
1584
+ continue;
1585
+ }
1586
+ if (!run.ran || run.value.kept) {
1587
+ const kept = run.ran ? run.value.kept : `in use: ${run.reasons.join("; ")}`;
1588
+ console.log(chalk.yellow(`Kept the logs of ${root}: ${kept}`));
1589
+ continue;
1590
+ }
1591
+ trimmed += run.value.bytes;
1592
+ console.log(chalk.green(`Trimmed the logs of ${root} (${formatBytes(run.value.bytes)})`));
1593
+ }
1594
+ if (trimmed) console.log(chalk.dim(`Trimmed ${formatBytes(trimmed)} of workspace logs.`));
1595
+ return failures;
1596
+ }
1597
+ //#endregion
1598
+ //#region src/commands/gc/inventory.ts
1599
+ const IOS_SIMULATOR_PLATFORM = "com.apple.platform.iphonesimulator";
1600
+ function text(value) {
1601
+ return typeof value === "string" && value.length > 0 ? value : null;
1602
+ }
1603
+ function parseSimRuntimeList(jsonOutput) {
1604
+ const parsed = JSON.parse(jsonOutput);
1605
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Expected simctl runtime list to return a JSON object.");
1606
+ const runtimes = [];
1607
+ for (const [key, value] of Object.entries(parsed)) {
1608
+ if (value === null || typeof value !== "object" || Array.isArray(value)) continue;
1609
+ const entry = value;
1610
+ if (entry.platformIdentifier !== IOS_SIMULATOR_PLATFORM) continue;
1611
+ runtimes.push({
1612
+ identifier: text(entry.identifier) ?? key,
1613
+ runtimeIdentifier: text(entry.runtimeIdentifier),
1614
+ version: text(entry.version),
1615
+ build: text(entry.build),
1616
+ sizeBytes: typeof entry.sizeBytes === "number" && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null,
1617
+ deletable: entry.deletable === true,
1618
+ lastUsedAt: text(entry.lastUsedAt)
1619
+ });
1620
+ }
1621
+ return runtimes;
1622
+ }
1623
+ function unlistedRuntimes(jsonOutput, images) {
1624
+ const known = new Set(images.map((image) => image.runtimeIdentifier));
1625
+ const parsed = JSON.parse(jsonOutput);
1626
+ if (!Array.isArray(parsed.runtimes)) throw new Error("Expected simctl list runtimes to return a runtimes array.");
1627
+ return parsed.runtimes.flatMap((value) => {
1628
+ if (value === null || typeof value !== "object") return [];
1629
+ const entry = value;
1630
+ const identifier = text(entry.identifier);
1631
+ if (!identifier || !/\.iOS-/.test(identifier) || known.has(identifier)) return [];
1632
+ return [{
1633
+ identifier,
1634
+ runtimeIdentifier: identifier,
1635
+ version: text(entry.version),
1636
+ build: text(entry.buildversion),
1637
+ sizeBytes: null,
1638
+ deletable: false,
1639
+ lastUsedAt: null
1640
+ }];
1641
+ });
1642
+ }
1643
+ function modelName(deviceTypeIdentifier) {
1644
+ return deviceTypeIdentifier.replace(/^com\.apple\.CoreSimulator\.SimDeviceType\./, "").replaceAll("-", " ");
1645
+ }
1646
+ function sdkmanagerCommand(pkg) {
1647
+ const tool = androidToolPath("sdkmanager");
1648
+ return `${tool === "sdkmanager" ? tool : JSON.stringify(tool)} --uninstall ${JSON.stringify(pkg)}`;
1649
+ }
1650
+ function buildInventory({ sims, avds, runtimes, systemImages, config, deadProjects = [] }) {
1651
+ const dead = new Set(deadProjects);
1652
+ const assigned = /* @__PURE__ */ new Map();
1653
+ for (const [project, record] of Object.entries(config?.projects ?? {})) {
1654
+ if (dead.has(project)) continue;
1655
+ let slots;
1656
+ try {
1657
+ slots = projectDeviceSlots(record);
1658
+ } catch {
1659
+ continue;
1660
+ }
1661
+ for (const { slot, platforms } of slots) {
1662
+ if (platforms.ios?.deviceUdid) assigned.set(`ios:${platforms.ios.deviceUdid}`, {
1663
+ project,
1664
+ slot
1665
+ });
1666
+ if (platforms.android?.avdName) assigned.set(`android:${platforms.android.avdName}`, {
1667
+ project,
1668
+ slot
1669
+ });
1670
+ }
1671
+ }
1672
+ const parked = /* @__PURE__ */ new Set([...readParked("ios", { config }).map((sim) => `ios:${sim.udid}`), ...readParked("android", { config }).map((avd) => `android:${avd.name}`)]);
1673
+ const owner = (key, name, created) => {
1674
+ const workspace = assigned.get(key);
1675
+ if (workspace && created()) return {
1676
+ owner: "workspace",
1677
+ project: workspace.project,
1678
+ slot: workspace.slot
1679
+ };
1680
+ if (parked.has(key)) return {
1681
+ owner: "parked",
1682
+ project: null,
1683
+ slot: null
1684
+ };
1685
+ if (!name.startsWith("stim-")) return {
1686
+ owner: "user",
1687
+ project: null,
1688
+ slot: null
1689
+ };
1690
+ return {
1691
+ owner: created() ? "orphaned" : "otherStimHome",
1692
+ project: null,
1693
+ slot: null
1694
+ };
1695
+ };
1696
+ const devices = [...sims.map((sim) => ({
1697
+ kind: "ios",
1698
+ id: sim.udid,
1699
+ name: sim.name,
1700
+ model: modelName(sim.deviceTypeIdentifier),
1701
+ runtime: sim.runtime,
1702
+ state: sim.state,
1703
+ lastUsedAt: sim.lastUsedAt ?? null,
1704
+ bytes: sim.dataPathSize ?? null,
1705
+ directory: sim.dataPath ? sim.dataPath.replace(/\/data\/?$/, "") : null,
1706
+ ...owner(`ios:${sim.udid}`, sim.name, () => isStimOwnedSim({
1707
+ udid: sim.udid,
1708
+ name: sim.name
1709
+ }))
1710
+ })), ...avds.map((avd) => ({
1711
+ kind: "android",
1712
+ id: avd.name,
1713
+ name: avd.name,
1714
+ model: avd.deviceProfile,
1715
+ runtime: avd.systemImage,
1716
+ state: null,
1717
+ lastUsedAt: avd.lastUsedAt,
1718
+ bytes: null,
1719
+ directory: avd.directory,
1720
+ ...owner(`android:${avd.name}`, avd.name, () => isStimOwnedAvd(avd.name))
1721
+ }))];
1722
+ const simsByRuntime = /* @__PURE__ */ new Map();
1723
+ for (const sim of sims) simsByRuntime.set(sim.runtime, (simsByRuntime.get(sim.runtime) ?? 0) + 1);
1724
+ const avdsByImage = /* @__PURE__ */ new Map();
1725
+ for (const avd of avds) if (avd.systemImage) avdsByImage.set(avd.systemImage, (avdsByImage.get(avd.systemImage) ?? 0) + 1);
1726
+ return {
1727
+ devices,
1728
+ runtimes: runtimes.map((runtime) => ({
1729
+ identifier: runtime.identifier,
1730
+ runtimeIdentifier: runtime.runtimeIdentifier,
1731
+ version: runtime.version,
1732
+ build: runtime.build,
1733
+ bytes: runtime.sizeBytes,
1734
+ lastUsedAt: runtime.lastUsedAt,
1735
+ deviceCount: runtime.runtimeIdentifier ? simsByRuntime.get(runtime.runtimeIdentifier) ?? 0 : 0,
1736
+ command: runtime.deletable ? `xcrun simctl runtime delete ${runtime.identifier}` : null
1737
+ })),
1738
+ systemImages: systemImages.map((image) => ({
1739
+ package: image.pkg,
1740
+ directory: image.directory,
1741
+ avdCount: avdsByImage.get(image.pkg) ?? 0,
1742
+ command: sdkmanagerCommand(image.pkg)
1743
+ }))
1744
+ };
1745
+ }
1746
+ function modifiedAt(path) {
1747
+ try {
1748
+ return statSync(path).mtime.toISOString();
1749
+ } catch {
1750
+ return null;
1751
+ }
1752
+ }
1753
+ function isMissing(error) {
1754
+ const code = error.code;
1755
+ return code === "ENOENT" || code === "ENOTDIR";
1756
+ }
1757
+ function unreadableNotice(what, error) {
1758
+ const notice = `${what} not listed: ${describeError(error)}`;
1759
+ if (process.platform !== "darwin" || error.code !== "EPERM") return notice;
1760
+ return `${notice}. macOS privacy protection blocked the read; allow ${process.env.STIM_DESKTOP_APP ? "Stim Desktop" : "the app that runs stim"} under System Settings > Privacy & Security > Files and Folders (Removable Volumes for an external disk) or Full Disk Access`;
1761
+ }
1762
+ function listAvdRecords(notices, roots = avdStorageRoots()) {
1763
+ const records = /* @__PURE__ */ new Map();
1764
+ for (const root of roots) {
1765
+ let names;
1766
+ try {
1767
+ names = readdirSync(root);
1768
+ } catch (error) {
1769
+ if (!isMissing(error)) notices.push(unreadableNotice(`AVDs in ${root}`, error));
1770
+ continue;
1771
+ }
1772
+ for (const entry of names) {
1773
+ if (!entry.endsWith(".ini")) continue;
1774
+ const name = entry.slice(0, -4);
1775
+ if (records.has(name)) continue;
1776
+ let ini;
1777
+ try {
1778
+ ini = readFileSync(join$1(root, entry), "utf8");
1779
+ } catch (error) {
1780
+ if (!isMissing(error)) notices.push(unreadableNotice(`AVD ${name}`, error));
1781
+ continue;
1782
+ }
1783
+ let directory = null;
1784
+ let config = "";
1785
+ let unreadable = null;
1786
+ for (const candidate of avdIniPaths(root, ini)) try {
1787
+ config = readFileSync(join$1(candidate, "config.ini"), "utf8");
1788
+ directory = candidate;
1789
+ break;
1790
+ } catch (error) {
1791
+ if (!isMissing(error)) unreadable ??= error;
1792
+ }
1793
+ if (!directory && unreadable) notices.push(unreadableNotice(`details of AVD ${name}`, unreadable));
1794
+ records.set(name, {
1795
+ name,
1796
+ directory,
1797
+ systemImage: parseAvdSystemImage(config),
1798
+ deviceProfile: parseAvdDeviceProfile(config),
1799
+ lastUsedAt: directory ? modifiedAt(join$1(directory, "hardware-qemu.ini")) : null
1800
+ });
1801
+ }
1802
+ }
1803
+ return [...records.values()];
1804
+ }
1805
+ function simctl(args) {
1806
+ return getExecutor().runFile("xcrun", ["simctl", ...args], {
1807
+ timeoutMs: DEVICE_LIST_TIMEOUT_MS,
1808
+ killSignal: "SIGKILL"
1809
+ });
1810
+ }
1811
+ function collectInventory(config, deadProjects) {
1812
+ const notices = [];
1813
+ let sims = [];
1814
+ let runtimes = [];
1815
+ if (process.platform === "darwin") {
1816
+ try {
1817
+ sims = listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
1818
+ } catch (error) {
1819
+ notices.push(`simulators not listed: ${describeError(error)}`);
1820
+ }
1821
+ try {
1822
+ runtimes = parseSimRuntimeList(simctl([
1823
+ "runtime",
1824
+ "list",
1825
+ "-j"
1826
+ ]));
1827
+ } catch (error) {
1828
+ notices.push(`simulator runtime images not listed: ${describeError(error)}`);
1829
+ }
1830
+ try {
1831
+ runtimes.push(...unlistedRuntimes(simctl([
1832
+ "list",
1833
+ "runtimes",
1834
+ "-j"
1835
+ ]), runtimes));
1836
+ } catch (error) {
1837
+ notices.push(`simulator runtimes not listed: ${describeError(error)}`);
1838
+ }
1839
+ }
1840
+ const imagesRoot = join$1(androidHome(), "system-images");
1841
+ const images = listInstalledSystemImages((error) => notices.push(unreadableNotice("Android system images", error))).map((image) => ({
1842
+ pkg: image.pkg,
1843
+ directory: join$1(imagesRoot, `android-${image.api}`, image.tag, image.arch)
1844
+ }));
1845
+ return {
1846
+ ...buildInventory({
1847
+ sims,
1848
+ avds: listAvdRecords(notices),
1849
+ runtimes,
1850
+ systemImages: images,
1851
+ config,
1852
+ deadProjects
1853
+ }),
1854
+ notices
1855
+ };
1856
+ }
1857
+ //#endregion
1031
1858
  //#region src/commands/gc.ts
1032
1859
  function removeInvalidProjectEntries(invalidProjects) {
1033
1860
  for (const path of invalidProjects) {
1034
1861
  removeProject(path);
1035
1862
  console.log(chalk.green(`Removed the invalid registry entry ${path}`));
1863
+ recordGcResult("project", "done", path);
1036
1864
  }
1037
1865
  }
1038
1866
  async function collectGcReport({ olderThan = null, cache = null, worktrees = false, now = Date.now(), lastTouched = workspaceLastUsed } = {}, deps = {}) {
@@ -1050,6 +1878,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1050
1878
  unverifiedDevices: [],
1051
1879
  staleDevices: [],
1052
1880
  staleDeviceRecords: [],
1881
+ staleLedgerEntries: [],
1053
1882
  buildLocks: {
1054
1883
  stale: [],
1055
1884
  live: [],
@@ -1079,13 +1908,14 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1079
1908
  olderThan,
1080
1909
  now
1081
1910
  }) : null,
1911
+ workspaceLogs: [],
1082
1912
  worktreeSweep: null,
1083
1913
  cacheScope: scope,
1084
1914
  olderThan,
1085
1915
  all
1086
1916
  };
1087
1917
  const mountedVolumes = listMountedVolumes();
1088
- const cfg = loadConfig();
1918
+ const cfg = loadConfig$1();
1089
1919
  let easSessionSweep = deps.precollectedEasSessionSweep;
1090
1920
  if (!easSessionSweep) try {
1091
1921
  easSessionSweep = collectEasSessionSweep(cfg, deps);
@@ -1118,7 +1948,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1118
1948
  else invalidProjects.push(path);
1119
1949
  continue;
1120
1950
  }
1121
- if (existsSync(path)) continue;
1951
+ if (existsSync$1(path)) continue;
1122
1952
  if (!isOnMountedVolume(path, mountedVolumes)) {
1123
1953
  const volume = volumeRootFor(path);
1124
1954
  skipped.push({
@@ -1134,6 +1964,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1134
1964
  let unverifiedDevices = [];
1135
1965
  let staleDevices = [];
1136
1966
  let staleDeviceRecords = [];
1967
+ let staleLedgerEntries = staleBrowserProfiles(readCreatedDevices$1());
1137
1968
  let idleDevices = [];
1138
1969
  const unsweepableReason = cfg === null ? "no Stim config found" : deviceSweepIsScoped() ? "STIM_HOME scopes this config, but simulators and AVDs are machine-global" : null;
1139
1970
  if (unsweepableReason) {
@@ -1149,8 +1980,14 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1149
1980
  } else {
1150
1981
  let sims = [];
1151
1982
  let simsChecked = true;
1983
+ const ledger = readCreatedDevices$1();
1152
1984
  try {
1153
- sims = listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
1985
+ const allSims = listAllIosSims({
1986
+ timeoutMs: DEVICE_LIST_TIMEOUT_MS,
1987
+ includeUnavailable: true
1988
+ });
1989
+ staleLedgerEntries = [...staleLedgerEntries, ...findStaleLedgerEntries(ledger, allSims)];
1990
+ sims = allSims.filter((sim) => sim.available);
1154
1991
  } catch {
1155
1992
  simsChecked = false;
1156
1993
  deviceSweepNotices.push(`ios device sweep skipped: simulator tooling did not answer within ${DEVICE_LIST_TIMEOUT_MS / 1e3}s`);
@@ -1170,6 +2007,8 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1170
2007
  avdsChecked = false;
1171
2008
  deviceSweepNotices.push(`android data sweep skipped: ${error.message}`);
1172
2009
  }
2010
+ if (avdsChecked) staleLedgerEntries = [...staleLedgerEntries, ...findStaleAndroidLedgerEntries(ledger, avds, loadConfig$1())];
2011
+ const registeredAvds = new Set(avds);
1173
2012
  avds = [.../* @__PURE__ */ new Set([...avds, ...orphanedAvdDirectories.map((entry) => entry.name)])];
1174
2013
  const isMounted = (path) => isOnMountedVolume(path, mountedVolumes);
1175
2014
  const found = findOrphanedDevices({
@@ -1177,13 +2016,12 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1177
2016
  avds,
1178
2017
  config: cfg,
1179
2018
  isMounted,
1180
- deadProjects,
1181
- isOwned: (device) => device.kind === "ios" ? isStimOwnedSim({
1182
- udid: device.id,
1183
- name: device.name
1184
- }) : isStimOwnedAvdName(device.id, ownedAvdDirectory(device.id) ?? orphanedAvdDirectories.find((entry) => entry.name === device.id)?.directory ?? null)
2019
+ deadProjects
2020
+ });
2021
+ unverifiedDevices = found.unverified.flatMap((device) => {
2022
+ const directories = device.kind === "android" && !registeredAvds.has(device.id) ? orphanedAvdDirectories.filter((entry) => entry.name === device.id) : [];
2023
+ return directories.length ? directories.map(({ directory }) => Object.assign({}, device, { directory })) : [device];
1185
2024
  });
1186
- unverifiedDevices = found.unverified;
1187
2025
  orphanedDevices = withAndroidAvdSizes(found.orphaned.flatMap((device) => {
1188
2026
  const directories = device.kind === "android" ? orphanedAvdDirectories.filter((entry) => entry.name === device.name) : [];
1189
2027
  return directories.length ? directories.map((orphanedDirectory) => Object.assign({}, device, { orphanedDirectory })) : [device];
@@ -1215,6 +2053,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1215
2053
  }
1216
2054
  const locks = listBuildLocks();
1217
2055
  const slots = listBuildSlots();
2056
+ const goneWorkspaceDirs = [...workspaceDirs.orphaned.map((entry) => entry.dir), ...deadProjects.map(workspaceDir)];
1218
2057
  const deviceLeases = collectDeviceLeases(now);
1219
2058
  return {
1220
2059
  skipped,
@@ -1232,6 +2071,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1232
2071
  unverifiedDevices,
1233
2072
  staleDevices,
1234
2073
  staleDeviceRecords,
2074
+ staleLedgerEntries,
1235
2075
  buildLocks: {
1236
2076
  stale: locks.filter((l) => !l.alive && !l.unresolved),
1237
2077
  live: locks.filter((l) => l.alive),
@@ -1250,9 +2090,10 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
1250
2090
  workspaceOutputs: collectWorkspaceOutputs({
1251
2091
  olderThan,
1252
2092
  now,
1253
- exclude: [...workspaceDirs.orphaned.map((entry) => entry.dir), ...deadProjects.map(workspaceDir)]
2093
+ exclude: goneWorkspaceDirs
1254
2094
  }),
1255
- worktreeSweep: collectWorktreeSweep({
2095
+ workspaceLogs: collectWorkspaceLogs({ exclude: goneWorkspaceDirs }),
2096
+ worktreeSweep: await collectWorktreeSweep({
1256
2097
  idle: worktrees,
1257
2098
  olderThan,
1258
2099
  now
@@ -1341,7 +2182,7 @@ async function sweep(opts, deps) {
1341
2182
  try {
1342
2183
  easSessionSweep = await withProjectLock(projectRoot, () => {
1343
2184
  sweepStarted = true;
1344
- return withRemoteSessionGcLocks(projectRoot, deps, (coordinatedDeps) => Promise.resolve(collectEasSessionSweep(loadConfig(), coordinatedDeps)));
2185
+ return withRemoteSessionGcLocks(projectRoot, deps, (coordinatedDeps) => Promise.resolve(collectEasSessionSweep(loadConfig$1(), coordinatedDeps)));
1345
2186
  }, {
1346
2187
  waitMs: 0,
1347
2188
  ownerPurpose: "EAS orphan sweep",
@@ -1360,7 +2201,51 @@ async function sweep(opts, deps) {
1360
2201
  precollectedEasSessionSweep: easSessionSweep
1361
2202
  });
1362
2203
  }
2204
+ async function pruneDeadProjects(deadProjects) {
2205
+ let deleteFailures = 0;
2206
+ for (const path of deadProjects) {
2207
+ if (existsSync$1(path) || !isOnMountedVolume(path)) {
2208
+ console.log(chalk.yellow(`Kept ${path}: its absence can no longer be confirmed.`));
2209
+ recordGcResult("project", "kept", path, { detail: "its absence can no longer be confirmed" });
2210
+ continue;
2211
+ }
2212
+ const result = await reclaimProject(path).catch((error) => {
2213
+ deleteFailures++;
2214
+ console.log(chalk.red(`Could not prune ${path}; its registry entry was kept: ${error.message}`));
2215
+ recordGcResult("project", "failed", path, { detail: `its registry entry was kept: ${error.message}` });
2216
+ return null;
2217
+ });
2218
+ if (!result) continue;
2219
+ if (result.keptEntry) {
2220
+ console.log(chalk.yellow(`Could not fully prune ${path}; its registry entry was kept.`));
2221
+ recordGcResult("project", "kept", path, { detail: "it could not be fully pruned; its registry entry was kept" });
2222
+ } else {
2223
+ console.log(chalk.green(`Pruned ${path}`));
2224
+ recordGcResult("project", "done", path);
2225
+ }
2226
+ for (const dir of result.removedWorkspaceDirs) console.log(chalk.dim(` removed workspace output ${dir}`));
2227
+ for (const dir of result.failedWorkspaceDirs) {
2228
+ console.log(chalk.red(` could not remove workspace output ${dir}`));
2229
+ recordGcResult("workspaceDirectory", "failed", dir, { detail: `could not remove it while pruning ${path}` });
2230
+ deleteFailures += 1;
2231
+ }
2232
+ if (result.killedPid) console.log(chalk.dim(` killed orphaned Metro pid ${result.killedPid}`));
2233
+ if (result.stoppedSession) console.log(chalk.dim(` stopped remote session ${result.stoppedSession}`));
2234
+ if (result.stoppedTunnel) console.log(chalk.dim(` stopped ${result.stoppedTunnel} tunnel`));
2235
+ for (const name of result.deletedDevices) recordGcResult("device", "done", name);
2236
+ for (const s of result.skippedDevices) {
2237
+ console.log(chalk.yellow(` ${s.name}: ${s.reason}`));
2238
+ recordGcResult("device", result.failedDevices.includes(s) ? "failed" : "kept", s.name, {
2239
+ id: s.udid ?? null,
2240
+ detail: s.reason
2241
+ });
2242
+ }
2243
+ deleteFailures += result.failedDevices.length;
2244
+ }
2245
+ return deleteFailures;
2246
+ }
1363
2247
  async function runGcCore(opts, deps) {
2248
+ takeGcResults();
1364
2249
  const olderThan = typeof opts.olderThan === "number" ? opts.olderThan : null;
1365
2250
  const cache = typeof opts.cache === "string" && opts.cache.trim() ? opts.cache : null;
1366
2251
  const report = await collectGcReport({
@@ -1383,10 +2268,11 @@ async function runGcCore(opts, deps) {
1383
2268
  const all = report.all;
1384
2269
  for (const line of formatGcReport(report)) console.log(line);
1385
2270
  const { deadProjects, invalidProjects, orphanedDevices, staleDevices, staleDeviceRecords, buildLocks, buildSlots, deviceLeases, easSessionSweep, caches } = report;
1386
- const actionable = deadProjects.length + invalidProjects.length > 0 || report.orphanedWorkspaces.length > 0 || Boolean(report.workspaceOutputs?.workspaces.some((entry) => entry.willClear)) || Boolean(report.worktreeSweep?.worktrees.some((entry) => !entry.skipped)) || report.parkedSims.length > 0 || report.parkedAvds.length > 0 || orphanedDevices.length > 0 || staleDevices.length > 0 || staleDeviceRecords.length > 0 || buildLocks.stale.length > 0 || buildSlots.stale.length > 0 || deviceLeases.expired.length > 0 || easSessionSweep.orphaned.length > 0 || (olderThan !== null || all) && caches.length > 0;
2271
+ const actionable = deadProjects.length + invalidProjects.length > 0 || report.orphanedWorkspaces.length > 0 || Boolean(report.workspaceOutputs?.workspaces.some((entry) => entry.willClear)) || report.workspaceLogs.some((entry) => entry.willTrim) || Boolean(report.worktreeSweep?.worktrees.some((entry) => !entry.skipped)) || report.parkedSims.length > 0 || report.parkedAvds.length > 0 || orphanedDevices.length > 0 || staleDevices.length > 0 || staleDeviceRecords.length > 0 || report.staleLedgerEntries.length > 0 || buildLocks.stale.length > 0 || buildSlots.stale.length > 0 || deviceLeases.expired.length > 0 || easSessionSweep.orphaned.length > 0 || (olderThan !== null || all) && caches.length > 0;
1387
2272
  const idle = opts.idle ?? null;
1388
- const idleFailures = idle === null ? 0 : shutDownIdleDevices(report.idleDevices, idle, () => collectIdleDevices(loadConfig(), listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }), report.deadProjects)).failures;
2273
+ const idleFailures = idle === null ? 0 : shutDownIdleDevices(report.idleDevices, idle, () => collectIdleDevices(loadConfig$1(), listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }), report.deadProjects)).failures;
1389
2274
  if (idleFailures) process.exitCode = 1;
2275
+ const inventory = opts.json && !opts.delete && idle === null && report.cacheScope === null ? collectInventory(loadConfig$1(), deadProjects) : null;
1390
2276
  const payload = (failures) => ({
1391
2277
  mode: opts.delete ? "delete" : "dry-run",
1392
2278
  idle,
@@ -1395,7 +2281,9 @@ async function runGcCore(opts, deps) {
1395
2281
  worktreeSweep: report.worktreeSweep?.idle ?? null,
1396
2282
  actionable,
1397
2283
  failures,
1398
- sections: gcReportSections(report)
2284
+ sections: gcReportSections(report),
2285
+ results: takeGcResults(),
2286
+ inventory
1399
2287
  });
1400
2288
  if (!opts.delete) {
1401
2289
  if (all) console.log(chalk.dim("\nDry run. Re-run with --delete to empty the caches above."));
@@ -1405,34 +2293,13 @@ async function runGcCore(opts, deps) {
1405
2293
  }
1406
2294
  let deleteFailures = idleFailures;
1407
2295
  deleteFailures += report.workspaceOutputs ? (await clearWorkspaceOutputs(report.workspaceOutputs, { olderThan })).failures : 0;
2296
+ deleteFailures += await trimWorkspaceLogs(report.workspaceLogs);
1408
2297
  deleteFailures += deleteParkedSims(report.parkedSims, deps) + deleteParkedAvds(report.parkedAvds);
1409
2298
  removeInvalidProjectEntries(invalidProjects);
1410
- for (const path of deadProjects) {
1411
- if (existsSync(path) || !isOnMountedVolume(path)) {
1412
- console.log(chalk.yellow(`Kept ${path}: its absence can no longer be confirmed.`));
1413
- continue;
1414
- }
1415
- const result = await reclaimProject(path).catch((error) => {
1416
- deleteFailures++;
1417
- console.log(chalk.red(`Could not prune ${path}; its registry entry was kept: ${error.message}`));
1418
- return null;
1419
- });
1420
- if (!result) continue;
1421
- if (result.keptEntry) console.log(chalk.yellow(`Could not fully prune ${path}; its registry entry was kept.`));
1422
- else console.log(chalk.green(`Pruned ${path}`));
1423
- for (const dir of result.removedWorkspaceDirs) console.log(chalk.dim(` removed workspace output ${dir}`));
1424
- for (const dir of result.failedWorkspaceDirs) {
1425
- console.log(chalk.red(` could not remove workspace output ${dir}`));
1426
- deleteFailures += 1;
1427
- }
1428
- if (result.killedPid) console.log(chalk.dim(` killed orphaned Metro pid ${result.killedPid}`));
1429
- if (result.stoppedSession) console.log(chalk.dim(` stopped remote session ${result.stoppedSession}`));
1430
- if (result.stoppedTunnel) console.log(chalk.dim(` stopped ${result.stoppedTunnel} tunnel`));
1431
- for (const s of result.skippedDevices) console.log(chalk.yellow(` ${s.name}: ${s.reason}`));
1432
- deleteFailures += result.failedDevices.length;
1433
- }
2299
+ deleteFailures += await pruneDeadProjects(deadProjects);
1434
2300
  deleteFailures += await deleteOrphanedWorkspaces(report.orphanedWorkspaces);
1435
2301
  deleteFailures += deleteProjectDevices(orphanedDevices, staleDevices, staleDeviceRecords);
2302
+ deleteFailures += forgetStaleLedgerEntries(report.staleLedgerEntries);
1436
2303
  for (const lock of buildLocks.stale) {
1437
2304
  const cleared = (0, ownership_claim_exports.clearFreeClaimSet)({
1438
2305
  root: lock.path,
@@ -1441,13 +2308,22 @@ async function runGcCore(opts, deps) {
1441
2308
  if (cleared.status === "held") continue;
1442
2309
  if (cleared.status === "refused") {
1443
2310
  console.log(chalk.yellow(`Left the build lock at ${lock.path} alone: ${cleared.reason}`));
2311
+ recordGcResult("buildLock", "kept", `${lock.platform} build lock`, {
2312
+ id: lock.path,
2313
+ detail: cleared.reason
2314
+ });
1444
2315
  continue;
1445
2316
  }
1446
2317
  if (cleared.status === "failed") {
1447
2318
  deleteFailures++;
1448
2319
  console.log(chalk.red(`Failed to clear the build lock at ${lock.path}: ${cleared.reason}`));
2320
+ recordGcResult("buildLock", "failed", `${lock.platform} build lock`, {
2321
+ id: lock.path,
2322
+ detail: cleared.reason
2323
+ });
1449
2324
  continue;
1450
2325
  }
2326
+ recordGcResult("buildLock", "done", `${lock.platform} build lock`, { id: lock.path });
1451
2327
  console.log(chalk.green(`Cleared the ${lock.platform} build lock left by pid ${lock.pid ?? "?"} (${lock.projectRoot || "unrecorded workspace"})`));
1452
2328
  }
1453
2329
  for (const slot of buildSlots.stale) {
@@ -1458,21 +2334,36 @@ async function runGcCore(opts, deps) {
1458
2334
  if (cleared.status === "held") continue;
1459
2335
  if (cleared.status === "refused") {
1460
2336
  console.log(chalk.yellow(`Left the build slot at ${slot.path} alone: ${cleared.reason}`));
2337
+ recordGcResult("buildSlot", "kept", `build slot ${slot.index ?? "?"}`, {
2338
+ id: slot.path,
2339
+ detail: cleared.reason
2340
+ });
1461
2341
  continue;
1462
2342
  }
1463
2343
  if (cleared.status === "failed") {
1464
2344
  deleteFailures++;
1465
2345
  console.log(chalk.red(`Failed to clear the build slot at ${slot.path}: ${cleared.reason}`));
2346
+ recordGcResult("buildSlot", "failed", `build slot ${slot.index ?? "?"}`, {
2347
+ id: slot.path,
2348
+ detail: cleared.reason
2349
+ });
1466
2350
  continue;
1467
2351
  }
2352
+ recordGcResult("buildSlot", "done", `build slot ${slot.index ?? "?"}`, { id: slot.path });
1468
2353
  console.log(chalk.green(`Cleared build slot ${slot.index ?? "?"} left by pid ${slot.pid ?? "?"} (${slot.projectRoot || "unrecorded workspace"})`));
1469
2354
  }
1470
2355
  for (const entry of deviceLeases.expired) try {
1471
- if (removeExpiredLease(entry)) console.log(chalk.green(`Cleared the expired ${entry.platform} device lease on ${entry.id ?? entry.name} (held by ${entry.lease?.holder ?? "an unrecorded workspace"})`));
1472
- else console.log(chalk.dim(`${entry.name} is no longer an expired lease; left alone.`));
2356
+ if (removeExpiredLease(entry)) {
2357
+ recordGcResult("deviceLease", "done", `${entry.platform} lease on ${entry.id ?? entry.name}`, { id: entry.path });
2358
+ console.log(chalk.green(`Cleared the expired ${entry.platform} device lease on ${entry.id ?? entry.name} (held by ${entry.lease?.holder ?? "an unrecorded workspace"})`));
2359
+ } else console.log(chalk.dim(`${entry.name} is no longer an expired lease; left alone.`));
1473
2360
  } catch (err) {
1474
2361
  deleteFailures++;
1475
2362
  console.log(chalk.red(`Failed to clear the device lease at ${entry.path}: ${err?.message || err}`));
2363
+ recordGcResult("deviceLease", "failed", `${entry.platform} lease on ${entry.id ?? entry.name}`, {
2364
+ id: entry.path,
2365
+ detail: err?.message || String(err)
2366
+ });
1476
2367
  }
1477
2368
  deleteFailures += await deleteEasSessions(easSessionSweep, deps);
1478
2369
  if (report.worktreeSweep) deleteFailures += await removeWorktrees(report.worktreeSweep);
@@ -1487,7 +2378,7 @@ async function runGcCore(opts, deps) {
1487
2378
  return payload(deleteFailures);
1488
2379
  }
1489
2380
  function gcCommand(program) {
1490
- program.command("gc").description("Report what Stim has left behind: dead project entries, orphaned workspace directories, clean Stim-managed linked worktrees whose branch is merged, orphaned owned devices and EAS sessions, records of devices that no longer exist, build locks whose builder is gone, expired physical-device leases, the shared build caches, and the build outputs of each workspace. Reports by default; pass --delete to act.").option("--worktrees", "also remove clean, idle Stim-managed linked worktrees, not only merged ones: with --delete run `stim worktree remove` (never --force) on each; idle means unused for --older-than days, 7 without it").option("--delete", "actually prune the reported entries, reap the reported devices, and clear the build outputs of every workspace not in use").option("--older-than <days>", "also reap owned devices whose workspace has not been used this long, trim shared cache entries nothing has used in that time, and clear workspace build outputs only for workspaces idle this long and parked devices only once parked this long", (v) => {
2381
+ program.command("gc").description("Report what Stim has left behind: dead project entries, orphaned workspace directories, clean Stim-managed linked worktrees whose branch is merged or whose pull request was merged or closed, orphaned owned devices and EAS sessions, records of devices that no longer exist, build locks whose builder is gone, expired physical-device leases, the shared build caches, and the build outputs and logs of each workspace. Reports by default; pass --delete to act.").option("--worktrees", "also remove clean, idle Stim-managed linked worktrees, not only merged ones: with --delete run `stim worktree remove` (never --force) on each; idle means unused for --older-than days, 7 without it").option("--delete", "actually prune the reported entries, reap the reported devices, clear the build outputs of every workspace not in use, and trim its Metro, client and device logs to their newest 8 MiB").option("--older-than <days>", "also reap owned devices whose workspace has not been used this long, trim shared cache entries nothing has used in that time, and clear workspace build outputs only for workspaces idle this long and parked devices only once parked this long", (v) => {
1491
2382
  const n = parseInt(v, 10);
1492
2383
  if (!Number.isFinite(n) || String(n) !== String(v).trim()) throw new InvalidArgumentError("must be a whole number of days, e.g. --older-than 30");
1493
2384
  return n;