stim 1.12.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.
- package/README.md +3 -1
- package/dist/{activity-CCZJs__P.mjs → activity-DfVKZTEB.mjs} +42 -25
- package/dist/{android-BQiKJYK0.mjs → android-BHfAu2mf.mjs} +1 -1
- package/dist/{android-B7X-8H7h.mjs → android-CD8pr8Q2.mjs} +62 -47
- package/dist/{android-cas-DzLPXiEn.mjs → android-cas-B9Kv9aIy.mjs} +9 -7
- package/dist/{android-75Sk2rIR.mjs → android-qx_31wJo.mjs} +395 -116
- package/dist/{app-install-CsZi0SAT.mjs → app-install-CDFY2nUL.mjs} +1 -1
- package/dist/{budget-DbzW5VTD.mjs → budget-E5o91NR1.mjs} +154 -37
- package/dist/{build-plan-CqntbR7b.mjs → build-plan-BTa93o_r.mjs} +21 -88
- package/dist/{build-progress-h60GPV27.mjs → build-progress-l7d-X-ZP.mjs} +75 -7
- package/dist/cdp-Dq_ypAlJ.mjs +115 -0
- package/dist/chrome-B5hWgQJS.mjs +61 -0
- package/dist/cli.mjs +16 -15
- package/dist/collector-run.mjs +14 -8
- package/dist/{command-output-BWCj5Vh-.mjs → command-output-BIL7rlX7.mjs} +1 -1
- package/dist/created-devices-Cd558h31.mjs +37 -0
- package/dist/{deps-CO-_7UOm.mjs → deps-2Wo81np9.mjs} +1 -1
- package/dist/{device-9r9xRYDa.mjs → device-Cf7vl-so.mjs} +6 -6
- package/dist/{device-lease-B9LAXIUH.mjs → device-lease-CL9dZf73.mjs} +52 -3
- package/dist/{device-pool-BoezJd-c.mjs → device-pool-MuoiyRcu.mjs} +3 -3
- package/dist/{device-remote-C0_o-tJH.mjs → device-remote-DT9ngZiD.mjs} +10 -10
- package/dist/{doctor-LfDlbR-N.mjs → doctor-D2iysNNH.mjs} +47 -18
- package/dist/{error-diagnostics-CCxjn31V.mjs → error-diagnostics-BoiOhck8.mjs} +6 -6
- package/dist/{gc-C4nQFcTX.mjs → gc-eA-PRBX8.mjs} +853 -126
- package/dist/{guide-BPBlh9ML.mjs → guide-BfZ0OmiP.mjs} +736 -110
- package/dist/{guide-status-i9ogHt4v.mjs → guide-status-BaMYu55M.mjs} +1 -1
- package/dist/{idle-S3tFZ53_.mjs → idle-DxLi5OkN.mjs} +42 -9
- package/dist/{in-use-D9XYEgFD.mjs → in-use-wYEaKQM9.mjs} +9 -5
- package/dist/{ios-CDzWL1mj.mjs → ios-CcfCeZ19.mjs} +98 -54
- package/dist/{ios-CZWOHTTc.mjs → ios-CmEzZiOj.mjs} +10 -5
- package/dist/{ios-device-D9cLxVr5.mjs → ios-device-BW-t_Lkv.mjs} +1 -1
- package/dist/{launch-verify-B2oR_kFN.mjs → launch-verify-CuJaBpEa.mjs} +33 -20
- package/dist/{logs-SzuYudOj.mjs → logs-DcSRbC2t.mjs} +11 -10
- package/dist/{metro-Dzo30hvJ.mjs → metro-BPeY1YMh.mjs} +4 -4
- package/dist/{named-ports-Dn1o61D9.mjs → named-ports-n7tnJsI8.mjs} +78 -6
- package/dist/native-runtime-CiiFHt0e.mjs +74 -0
- package/dist/{ownership-Dqnrjrio.mjs → ownership-Bz2kETAS.mjs} +1 -1
- package/dist/{ownership-B8d4kXBx.mjs → ownership-PUP4Dwai.mjs} +134 -209
- package/dist/page-BTu7I60K.mjs +29 -0
- package/dist/{ports-Dp_QTwTC.mjs → ports-C5UutqpJ.mjs} +4 -4
- package/dist/{project-B8rbutVC.mjs → project-DGeclhvO.mjs} +53 -17
- package/dist/{reload-nocmx8KB.mjs → reload-C-cV5yy_.mjs} +79 -20
- package/dist/{remote-cache-BRsRZru5.mjs → remote-cache--aehr4Pr.mjs} +3 -3
- package/dist/{server-bare-Gn3ssSdK.mjs → server-bare-Bq8BpSIo.mjs} +1 -1
- package/dist/server-expo-BeYwP_Xy.mjs +2 -0
- package/dist/{server-expo-CeT1nIIk.mjs → server-expo-DUCg52-K.mjs} +2 -2
- package/dist/{settings-bE-Gu3Ti.mjs → settings-ssVBToDR.mjs} +10 -10
- package/dist/{settings-BlyDn2Oz.mjs → settings-xB0Frkg6.mjs} +11 -2
- package/dist/settings.schema.json +53 -1
- package/dist/{simslim-COtEEP7x.mjs → simslim-Bbg9UWrt.mjs} +12 -6
- package/dist/{slot-launch-DIACGEfH.mjs → slot-launch-BIWEW-wI.mjs} +3 -3
- package/dist/{start-BboE1HIS.mjs → start-BZhItkiL.mjs} +11 -10
- package/dist/{start-BFstVBlx.mjs → start-Rw2GD3nV.mjs} +1 -1
- package/dist/{state-DcDgzfu3.mjs → state-DwAU2_-D.mjs} +1 -1
- package/dist/state-l1pYwOre.mjs +148 -0
- package/dist/{stats-DiCtmluY.mjs → stats-CaSwmsSZ.mjs} +2 -2
- package/dist/{status-DI9Tjo7T.mjs → status-TJlL0HWo.mjs} +624 -103
- package/dist/{status-BUASYyy_.mjs → status-sszP4XKU.mjs} +59 -2
- package/dist/{stim-desktop-D0xhpZt8.mjs → stim-desktop-CXt8-Z3J.mjs} +13 -2
- package/dist/{stop-Bn6v-GvS.mjs → stop-BZ2qMiBP.mjs} +37 -12
- package/dist/{stop-BI7QY76M.mjs → stop-DskDKNTU.mjs} +2 -2
- package/dist/supervisor-run.mjs +10 -10
- package/dist/web-run.d.mts +22 -0
- package/dist/web-run.mjs +481 -0
- package/dist/web-sWXd5iek.mjs +394 -0
- package/dist/{worktree-B5sTRskL.mjs → worktree-3cBa-bQt.mjs} +1 -1
- package/dist/{worktree-ChDL5fO1.mjs → worktree-D4Do_XiQ.mjs} +226 -19
- package/dist/{xcode-BzplpQzc.mjs → xcode-LM6A8cNZ.mjs} +5 -5
- package/package.json +5 -5
- package/dist/server-expo-eWi9Coof.mjs +0 -2
- package/dist/workspace-process-lock-BvvJTfo_.mjs +0 -57
|
@@ -1,24 +1,28 @@
|
|
|
1
|
-
import { A as ownership_claim_exports, I as volumeRootFor, M as formatBytes, N as isOnMountedVolume, P as listMountedVolumes, U as projectDeviceSlots, _ as loadConfig$1, j as directorySize, p as getProject, x as removeProject } from "./config-0jCcMM0E.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 {
|
|
3
|
+
import { F as withDirLock, M as workspaceLogsDir, P as workspaceStateFile, k as workspaceDir, y as workspaceLastUsed } from "./metro-BPeY1YMh.mjs";
|
|
4
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 {
|
|
6
|
-
import { h as shortUdid, o as formatLongDuration, u as plural } from "./command-output-
|
|
7
|
-
import { d as removeExpiredLease, h as listBuildLocks, i as listLeaseFiles, r as leaseIsExpired } from "./device-lease-
|
|
8
|
-
import { A as
|
|
9
|
-
import {
|
|
10
|
-
import { l as listAllIosSims } from "./ios-
|
|
11
|
-
import { a as parseIdleDuration, o as shutDownIdleDevices, r as idleDeviceLines, t as collectIdleDevices } from "./idle-
|
|
12
|
-
import { i as canonicalPath, o as listBuildSlots, r as workspaceInUse } from "./in-use-
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { A as
|
|
16
|
-
import { a as
|
|
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";
|
|
17
18
|
import { n as mergeState, t as fetchDefaultBranch } from "./merge-state-ytFQCeh4.mjs";
|
|
19
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
18
20
|
import { InvalidArgumentError } from "commander";
|
|
19
|
-
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";
|
|
20
22
|
import { basename, dirname, isAbsolute, join } from "path";
|
|
23
|
+
import { LOG_ROTATE_BYTES, rotatedLogPath } from "@stim-cli/core";
|
|
21
24
|
import { settingDefinition } from "@stim-cli/core/state";
|
|
25
|
+
import { dirname as dirname$1, join as join$1 } from "node:path";
|
|
22
26
|
import chalk from "chalk";
|
|
23
27
|
//#region src/commands/gc/eas-sessions.ts
|
|
24
28
|
const EAS_OPERATION_TIMEOUT_MS = 3e4;
|
|
@@ -39,18 +43,18 @@ function readRecordedRemoteSessionIds(roots) {
|
|
|
39
43
|
let safe = true;
|
|
40
44
|
for (const root of new Set(roots)) {
|
|
41
45
|
try {
|
|
42
|
-
if (!statSync(root).isDirectory()) throw new Error("path is not a directory");
|
|
43
|
-
readdirSync(root);
|
|
46
|
+
if (!statSync$1(root).isDirectory()) throw new Error("path is not a directory");
|
|
47
|
+
readdirSync$1(root);
|
|
44
48
|
} catch (error) {
|
|
45
49
|
safe = false;
|
|
46
50
|
notices.push(`${root} is not available as a readable workspace directory: ${describeError(error)}`);
|
|
47
51
|
continue;
|
|
48
52
|
}
|
|
49
53
|
const path = workspaceStateFile(root);
|
|
50
|
-
if (!existsSync(path)) continue;
|
|
54
|
+
if (!existsSync$1(path)) continue;
|
|
51
55
|
let state;
|
|
52
56
|
try {
|
|
53
|
-
state = JSON.parse(readFileSync(path, "utf-8"));
|
|
57
|
+
state = JSON.parse(readFileSync$1(path, "utf-8"));
|
|
54
58
|
} catch (error) {
|
|
55
59
|
safe = false;
|
|
56
60
|
notices.push(`${path} could not be read as valid JSON: ${describeError(error)}`);
|
|
@@ -86,8 +90,8 @@ function claimStateAbsence(claim) {
|
|
|
86
90
|
const stateDir = dirname(claim.stateFile);
|
|
87
91
|
let entries;
|
|
88
92
|
try {
|
|
89
|
-
if (!statSync(stateDir).isDirectory()) throw new Error("path is not a directory");
|
|
90
|
-
entries = readdirSync(stateDir);
|
|
93
|
+
if (!statSync$1(stateDir).isDirectory()) throw new Error("path is not a directory");
|
|
94
|
+
entries = readdirSync$1(stateDir);
|
|
91
95
|
} catch (error) {
|
|
92
96
|
return {
|
|
93
97
|
absent: false,
|
|
@@ -100,7 +104,7 @@ function claimStateAbsence(claim) {
|
|
|
100
104
|
};
|
|
101
105
|
let state;
|
|
102
106
|
try {
|
|
103
|
-
state = JSON.parse(readFileSync(claim.stateFile, "utf-8"));
|
|
107
|
+
state = JSON.parse(readFileSync$1(claim.stateFile, "utf-8"));
|
|
104
108
|
} catch (error) {
|
|
105
109
|
return {
|
|
106
110
|
absent: false,
|
|
@@ -217,8 +221,8 @@ function easLockRoots(projectRoot, config) {
|
|
|
217
221
|
}
|
|
218
222
|
function unavailableLockRootNotice(roots) {
|
|
219
223
|
for (const root of roots) try {
|
|
220
|
-
if (!statSync(root).isDirectory()) throw new Error("path is not a directory");
|
|
221
|
-
readdirSync(root);
|
|
224
|
+
if (!statSync$1(root).isDirectory()) throw new Error("path is not a directory");
|
|
225
|
+
readdirSync$1(root);
|
|
222
226
|
} catch (error) {
|
|
223
227
|
return `EAS session sweep skipped: ${root} is not available for remote-session lock inspection: ${describeError(error)}`;
|
|
224
228
|
}
|
|
@@ -227,7 +231,7 @@ function unavailableLockRootNotice(roots) {
|
|
|
227
231
|
function malformedRemoteSessionLockNotice(roots) {
|
|
228
232
|
for (const root of roots) {
|
|
229
233
|
const lockPath = join(workspaceDir(root), "remote-session.lock");
|
|
230
|
-
if (!existsSync(lockPath)) continue;
|
|
234
|
+
if (!existsSync$1(lockPath)) continue;
|
|
231
235
|
if ((0, ownership_claim_exports.readClaimSet)(lockPath).unresolved.length === 0) continue;
|
|
232
236
|
return `EAS session sweep skipped: the remote-session lock at ${lockPath} has a malformed owner record.`;
|
|
233
237
|
}
|
|
@@ -281,6 +285,13 @@ async function withRemoteSessionGcLocks(projectRoot, deps, fn) {
|
|
|
281
285
|
}
|
|
282
286
|
async function deleteEasSessions(easSessionSweep, deps) {
|
|
283
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
|
+
};
|
|
284
295
|
if (easSessionSweep.orphaned.length && easSessionSweep.deletionSafe && easSessionSweep.projectScope) {
|
|
285
296
|
const projectScope = easSessionSweep.projectScope;
|
|
286
297
|
const withProjectLock = deps.withEasProjectLock ?? withEasProjectLock;
|
|
@@ -290,7 +301,7 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
290
301
|
finalSweepStarted = true;
|
|
291
302
|
return withRemoteSessionGcLocks(projectScope, deps, async (lockedDeps) => {
|
|
292
303
|
if (lockedDeps.easSweepBlockedNotice) {
|
|
293
|
-
|
|
304
|
+
refuseAll(`EAS session deletion refused: ${lockedDeps.easSweepBlockedNotice}`);
|
|
294
305
|
console.log(chalk.red(`EAS session deletion refused: ${lockedDeps.easSweepBlockedNotice}`));
|
|
295
306
|
return;
|
|
296
307
|
}
|
|
@@ -313,10 +324,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
313
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.`));
|
|
314
325
|
};
|
|
315
326
|
if (registryExpanded) {
|
|
316
|
-
|
|
327
|
+
refuseAll("EAS session deletion refused: registered workspace roots changed after classification.");
|
|
317
328
|
console.log(chalk.red("EAS session deletion refused: registered workspace roots changed after classification."));
|
|
318
329
|
} else if (!currentRecords?.safe || !ledger.safe) {
|
|
319
|
-
|
|
330
|
+
refuseAll("EAS session deletion refused: the session records could not be read safely.");
|
|
320
331
|
for (const notice of [...currentRecords?.notices ?? [], ...ledger.notice ? [ledger.notice] : []]) console.log(chalk.red(`EAS session deletion refused: ${notice}`));
|
|
321
332
|
} else {
|
|
322
333
|
const recorded = new Set(currentRecords.ids);
|
|
@@ -325,6 +336,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
325
336
|
for (const session of easSessionSweep.orphaned) {
|
|
326
337
|
if (recorded.has(session.id)) {
|
|
327
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
|
+
});
|
|
328
343
|
continue;
|
|
329
344
|
}
|
|
330
345
|
const claim = ledger.claims.get(session.id);
|
|
@@ -332,11 +347,19 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
332
347
|
if (!claim || claim.name !== session.name || claim.platform !== session.platform || !claimState?.absent || claimState.notice) {
|
|
333
348
|
deleteFailures++;
|
|
334
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
|
+
});
|
|
335
354
|
continue;
|
|
336
355
|
}
|
|
337
356
|
if (!eas) {
|
|
338
357
|
deleteFailures++;
|
|
339
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
|
+
});
|
|
340
363
|
continue;
|
|
341
364
|
}
|
|
342
365
|
const options = {
|
|
@@ -354,6 +377,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
354
377
|
} else {
|
|
355
378
|
deleteFailures++;
|
|
356
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
|
+
});
|
|
357
384
|
}
|
|
358
385
|
continue;
|
|
359
386
|
}
|
|
@@ -366,6 +393,10 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
366
393
|
if (inspection.action === "refused") {
|
|
367
394
|
deleteFailures++;
|
|
368
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
|
+
});
|
|
369
400
|
continue;
|
|
370
401
|
}
|
|
371
402
|
let stopped;
|
|
@@ -374,15 +405,24 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
374
405
|
} catch (error) {
|
|
375
406
|
deleteFailures++;
|
|
376
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
|
+
});
|
|
377
412
|
continue;
|
|
378
413
|
}
|
|
379
414
|
const verified = verifyStoppedSession(stopped, session.id);
|
|
380
415
|
if (!verified.ok) {
|
|
381
416
|
deleteFailures++;
|
|
382
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
|
+
});
|
|
383
422
|
continue;
|
|
384
423
|
}
|
|
385
424
|
console.log(chalk.green(`Stopped EAS session ${session.id} (${session.name})`));
|
|
425
|
+
recordGcResult("easSession", "done", session.name, { id: session.id });
|
|
386
426
|
reconcileClaim(session.id);
|
|
387
427
|
}
|
|
388
428
|
}
|
|
@@ -393,8 +433,8 @@ async function deleteEasSessions(easSessionSweep, deps) {
|
|
|
393
433
|
machineRoot: deps.easLedgerRoot
|
|
394
434
|
});
|
|
395
435
|
} catch (error) {
|
|
396
|
-
deleteFailures += easSessionSweep.orphaned.length;
|
|
397
436
|
const phase = finalSweepStarted ? "failed" : "lock acquisition failed";
|
|
437
|
+
refuseAll(`EAS session deletion ${phase}: ${describeError(error)}`);
|
|
398
438
|
console.log(chalk.red(`EAS session deletion ${phase}: ${describeError(error)}`));
|
|
399
439
|
}
|
|
400
440
|
}
|
|
@@ -418,6 +458,11 @@ function skip(code, text) {
|
|
|
418
458
|
text
|
|
419
459
|
};
|
|
420
460
|
}
|
|
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
|
+
}
|
|
421
466
|
/**
|
|
422
467
|
* Why gc keeps a worktree, or null when it removes it. A worktree that would be removed is still kept while its last
|
|
423
468
|
* activity, or its merge into the default branch, is less than `grace.ms` old, and kept when that activity is unknown.
|
|
@@ -432,10 +477,17 @@ function graceBlocker(facts, grace) {
|
|
|
432
477
|
if (grace.ms <= 0) return null;
|
|
433
478
|
if (!facts.activity) return skip("activity-unknown", "its last activity could not be read");
|
|
434
479
|
const merged = facts.merge?.merged ? facts.merge : null;
|
|
435
|
-
const
|
|
480
|
+
const ended = endedPullRequest(facts.pullRequest);
|
|
481
|
+
const events = [facts.activity];
|
|
482
|
+
if (merged) events.push({
|
|
436
483
|
at: merged.mergedAt,
|
|
437
484
|
basis: `merged into ${merged.into}`
|
|
438
|
-
}
|
|
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);
|
|
439
491
|
const eligibleAt = latest.at + grace.ms;
|
|
440
492
|
if (grace.now >= eligibleAt) return null;
|
|
441
493
|
const ago = formatLongDuration(Math.max(0, grace.now - latest.at));
|
|
@@ -452,22 +504,31 @@ function removalBlocker(facts, olderThan) {
|
|
|
452
504
|
if (facts.locked) return skip("locked", "locked with git worktree lock");
|
|
453
505
|
if (facts.inUse.length) return skip("in-use", `in use: ${facts.inUse.join("; ")}`);
|
|
454
506
|
if (facts.porcelain === null) return skip("status-unreadable", "git status could not be read");
|
|
455
|
-
|
|
507
|
+
const dirty = excludePodChurn(facts.porcelain).lines.length;
|
|
508
|
+
if (dirty) return skip("dirty", `dirty: ${plural(dirty, "uncommitted or untracked file")}`);
|
|
456
509
|
if (facts.unpushed === null) return skip("unpushed-unchecked", "unpushed commits could not be checked");
|
|
457
510
|
const merged = facts.merge?.merged ? facts.merge : null;
|
|
458
|
-
|
|
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`);
|
|
459
513
|
if (facts.submodules) return skip("submodules", "initialized submodules");
|
|
460
|
-
if (merged) return null;
|
|
514
|
+
if (merged || ended) return null;
|
|
461
515
|
const notMerged = facts.merge && !facts.merge.merged ? facts.merge : null;
|
|
462
|
-
|
|
463
|
-
|
|
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("");
|
|
464
519
|
if (facts.idleDays === null) return skip("last-use-unknown", `recently used: its last use is unknown${also}`);
|
|
465
520
|
if (facts.idleDays < olderThan) return skip("recently-used", `recently used ${facts.idleDays}d ago${also}`);
|
|
466
521
|
return null;
|
|
467
522
|
}
|
|
468
|
-
/** 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`. */
|
|
469
524
|
function worktreeRemovalReason(candidate) {
|
|
470
|
-
|
|
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);
|
|
471
532
|
}
|
|
472
533
|
function lastUsedOf(keys) {
|
|
473
534
|
const times = keys.map(workspaceLastUsed).filter(Number.isFinite);
|
|
@@ -477,13 +538,26 @@ function idleDaysOf(keys, now) {
|
|
|
477
538
|
const last = lastUsedOf(keys);
|
|
478
539
|
return Number.isFinite(last) ? Math.max(0, Math.floor((now - last) / DAY_MS)) : null;
|
|
479
540
|
}
|
|
480
|
-
function
|
|
481
|
-
|
|
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)])];
|
|
482
558
|
}
|
|
483
|
-
function deviceUseOf(keys) {
|
|
559
|
+
function deviceUseOf(keys, devices) {
|
|
484
560
|
const reasons = [];
|
|
485
|
-
let sims;
|
|
486
|
-
let avdSerial;
|
|
487
561
|
for (const key of keys) {
|
|
488
562
|
let slots;
|
|
489
563
|
try {
|
|
@@ -495,23 +569,16 @@ function deviceUseOf(keys) {
|
|
|
495
569
|
for (const { platforms } of slots) {
|
|
496
570
|
const ios = platforms.ios;
|
|
497
571
|
if (ios?.owned && ios.deviceUdid) {
|
|
498
|
-
|
|
499
|
-
sims = listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
|
|
500
|
-
} catch {
|
|
501
|
-
sims = null;
|
|
502
|
-
}
|
|
572
|
+
const sims = devices.sims();
|
|
503
573
|
const sim = sims?.find((entry) => entry.udid === ios.deviceUdid);
|
|
504
574
|
if (sims === null) reasons.push(`the state of its owned simulator ${ios.deviceUdid} cannot be read`);
|
|
505
575
|
else if (sim && sim.state !== "Shutdown") reasons.push(`its owned simulator ${sim.name} is ${sim.state}; \`stim stop\` in ${key} shuts it down`);
|
|
506
576
|
}
|
|
507
577
|
const android = platforms.android;
|
|
508
|
-
if (android?.owned && android.avdName) {
|
|
509
|
-
avdSerial
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
} catch {
|
|
513
|
-
reasons.push(`the state of its owned emulator ${android.avdName} cannot be read`);
|
|
514
|
-
}
|
|
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`);
|
|
515
582
|
}
|
|
516
583
|
}
|
|
517
584
|
}
|
|
@@ -525,7 +592,7 @@ function newestMtime(paths) {
|
|
|
525
592
|
for (const { path, basis } of paths) {
|
|
526
593
|
let mtime;
|
|
527
594
|
try {
|
|
528
|
-
mtime = statSync(path).mtimeMs;
|
|
595
|
+
mtime = statSync$1(path).mtimeMs;
|
|
529
596
|
} catch (error) {
|
|
530
597
|
if (error.code === "ENOENT") continue;
|
|
531
598
|
return "unreadable";
|
|
@@ -540,7 +607,7 @@ function newestMtime(paths) {
|
|
|
540
607
|
function logFiles$1(key) {
|
|
541
608
|
const dir = workspaceLogsDir(key);
|
|
542
609
|
try {
|
|
543
|
-
return readdirSync(dir).map((name) => ({
|
|
610
|
+
return readdirSync$1(dir).map((name) => ({
|
|
544
611
|
path: join(dir, name),
|
|
545
612
|
basis: "a Stim log write"
|
|
546
613
|
}));
|
|
@@ -601,7 +668,7 @@ function porcelainOf(path, gitAnswered) {
|
|
|
601
668
|
function candidateRoots() {
|
|
602
669
|
const registered = Object.keys(loadConfig$1()?.projects ?? {}).filter(isAbsolute);
|
|
603
670
|
const recorded = listWorkspaceDirs().flatMap((entry) => entry.projectRoot ? [entry.projectRoot] : []);
|
|
604
|
-
return [.../* @__PURE__ */ new Set([...registered, ...recorded])].filter((root) => existsSync(root)).toSorted();
|
|
671
|
+
return [.../* @__PURE__ */ new Set([...registered, ...recorded])].filter((root) => existsSync$1(root)).toSorted();
|
|
605
672
|
}
|
|
606
673
|
function checkMergeStates(pending, idle, grace) {
|
|
607
674
|
const repos = /* @__PURE__ */ new Map();
|
|
@@ -629,9 +696,11 @@ function checkMergeStates(pending, idle, grace) {
|
|
|
629
696
|
}
|
|
630
697
|
/**
|
|
631
698
|
* Classifies each Stim-managed linked worktree. A merged one is removable; with `idle`, so is one unused for
|
|
632
|
-
* `olderThan` days.
|
|
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.
|
|
633
702
|
*/
|
|
634
|
-
function collectWorktreeSweep({ idle, olderThan, now }) {
|
|
703
|
+
async function collectWorktreeSweep({ idle, olderThan, now }) {
|
|
635
704
|
const days = idle ? olderThan ?? DEFAULT_WORKTREE_IDLE_DAYS : null;
|
|
636
705
|
const grace = {
|
|
637
706
|
ms: graceMsSetting(),
|
|
@@ -647,6 +716,8 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
|
|
|
647
716
|
keys: [root],
|
|
648
717
|
idleDays: null,
|
|
649
718
|
merge: null,
|
|
719
|
+
head: null,
|
|
720
|
+
pullRequest: null,
|
|
650
721
|
skipCode: "not-a-worktree",
|
|
651
722
|
skipped: "not inside a git worktree",
|
|
652
723
|
eligibleAt: null
|
|
@@ -655,8 +726,7 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
|
|
|
655
726
|
}
|
|
656
727
|
groups.set(entry.path, [...groups.get(entry.path) ?? [], root]);
|
|
657
728
|
}
|
|
658
|
-
const
|
|
659
|
-
const pending = [];
|
|
729
|
+
const inspected = [];
|
|
660
730
|
for (const [path, roots] of groups) {
|
|
661
731
|
const entries = listWorktrees(path);
|
|
662
732
|
const entry = matchWorktreeEntry(entries, path);
|
|
@@ -666,6 +736,7 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
|
|
|
666
736
|
const linked = !("refusal" in source) && entry !== null && source.path !== entry.path;
|
|
667
737
|
const activity = linked && grace.ms > 0 ? worktreeActivityOf(path, keys) : null;
|
|
668
738
|
const gitAnswered = linked ? hasUncommittedWork(path) : null;
|
|
739
|
+
const head = linked ? resolveFullRef(path, "HEAD") : null;
|
|
669
740
|
const facts = {
|
|
670
741
|
source: "refusal" in source ? source : linked ? "linked" : "source",
|
|
671
742
|
bare: Boolean(entry?.bare),
|
|
@@ -673,25 +744,48 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
|
|
|
673
744
|
porcelain: porcelainOf(path, gitAnswered),
|
|
674
745
|
unpushed: linked ? unpushedCommits(path) : null,
|
|
675
746
|
submodules: linked && hasPopulatedSubmodules(path),
|
|
676
|
-
inUse:
|
|
747
|
+
inUse: [],
|
|
677
748
|
idleDays,
|
|
678
749
|
merge: null,
|
|
750
|
+
pullRequest: null,
|
|
679
751
|
activity
|
|
680
752
|
};
|
|
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) {
|
|
681
778
|
const verdict = worktreeSkipReason(facts, days, grace);
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
keys,
|
|
685
|
-
idleDays,
|
|
686
|
-
merge: null,
|
|
779
|
+
Object.assign(candidate, {
|
|
780
|
+
pullRequest: facts.pullRequest,
|
|
687
781
|
skipCode: verdict?.code ?? null,
|
|
688
782
|
skipped: verdict?.text ?? null,
|
|
689
783
|
eligibleAt: verdict?.eligibleAt ?? null
|
|
690
|
-
};
|
|
691
|
-
if (verdict && MERGE_DECIDES.has(verdict.code) &&
|
|
784
|
+
});
|
|
785
|
+
if (verdict && MERGE_DECIDES.has(verdict.code) && repo !== null) pending.push({
|
|
692
786
|
candidate,
|
|
693
787
|
facts,
|
|
694
|
-
repo
|
|
788
|
+
repo
|
|
695
789
|
});
|
|
696
790
|
worktrees.push(candidate);
|
|
697
791
|
}
|
|
@@ -706,6 +800,26 @@ function collectWorktreeSweep({ idle, olderThan, now }) {
|
|
|
706
800
|
worktrees: listed
|
|
707
801
|
};
|
|
708
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
|
+
}
|
|
709
823
|
async function removeWorktrees(sweep, { now = Date.now() } = {}) {
|
|
710
824
|
let failures = 0;
|
|
711
825
|
for (const candidate of sweep.worktrees) {
|
|
@@ -715,8 +829,9 @@ async function removeWorktrees(sweep, { now = Date.now() } = {}) {
|
|
|
715
829
|
const keys = [.../* @__PURE__ */ new Set([...candidate.keys, ...lockedKeys])];
|
|
716
830
|
const unlocked = keys.filter((key) => !lockedKeys.includes(key));
|
|
717
831
|
const reasons = [...inUseOf(lockedKeys, { managedLocks: false }), ...inUseOf(unlocked, { managedLocks: true })].map((r) => `in use: ${r}`);
|
|
718
|
-
|
|
719
|
-
|
|
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");
|
|
720
835
|
} else {
|
|
721
836
|
const idleDays = idleDaysOf(keys, now);
|
|
722
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`);
|
|
@@ -724,6 +839,7 @@ async function removeWorktrees(sweep, { now = Date.now() } = {}) {
|
|
|
724
839
|
if (sweep.graceMs > 0) {
|
|
725
840
|
const recent = graceBlocker({
|
|
726
841
|
merge: candidate.merge,
|
|
842
|
+
pullRequest: candidate.pullRequest,
|
|
727
843
|
activity: worktreeActivityOf(candidate.path, keys)
|
|
728
844
|
}, {
|
|
729
845
|
ms: sweep.graceMs,
|
|
@@ -735,21 +851,29 @@ async function removeWorktrees(sweep, { now = Date.now() } = {}) {
|
|
|
735
851
|
return reasons;
|
|
736
852
|
};
|
|
737
853
|
let removed = false;
|
|
854
|
+
let error = null;
|
|
738
855
|
try {
|
|
739
|
-
const
|
|
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;
|
|
740
858
|
removed = await removeWorktreeTarget(candidate.path, {
|
|
741
859
|
linkedOnly: true,
|
|
742
860
|
guard,
|
|
743
861
|
mergedHead
|
|
744
862
|
});
|
|
745
|
-
} catch (
|
|
746
|
-
|
|
863
|
+
} catch (thrown) {
|
|
864
|
+
error = thrown?.message || String(thrown);
|
|
865
|
+
console.error(chalk.red(`Could not remove ${candidate.path}: ${error}`));
|
|
747
866
|
}
|
|
748
|
-
if (removed)
|
|
749
|
-
|
|
750
|
-
|
|
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 {
|
|
751
874
|
failures++;
|
|
752
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" });
|
|
753
877
|
}
|
|
754
878
|
}
|
|
755
879
|
return failures;
|
|
@@ -804,7 +928,7 @@ function unresolvedLockLines(locks, slots) {
|
|
|
804
928
|
" run that yourself once you know nothing is building with it"
|
|
805
929
|
];
|
|
806
930
|
}
|
|
807
|
-
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 = {
|
|
808
932
|
stale: [],
|
|
809
933
|
live: [],
|
|
810
934
|
unresolved: []
|
|
@@ -820,7 +944,7 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
|
|
|
820
944
|
orphaned: [],
|
|
821
945
|
notices: [],
|
|
822
946
|
deletionSafe: true
|
|
823
|
-
}, 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() } = {}) {
|
|
824
948
|
const lines = [];
|
|
825
949
|
const staleLocks = buildLocks?.stale ?? [];
|
|
826
950
|
const liveLocks = buildLocks?.live ?? [];
|
|
@@ -836,11 +960,13 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
|
|
|
836
960
|
orphanedDevices,
|
|
837
961
|
staleDevices,
|
|
838
962
|
staleDeviceRecords,
|
|
963
|
+
staleLedgerEntries,
|
|
839
964
|
staleLocks,
|
|
840
965
|
staleSlots,
|
|
841
966
|
expiredLeases,
|
|
842
967
|
easSessionSweep.orphaned,
|
|
843
968
|
workspaceOutputs?.workspaces.filter((entry) => entry.willClear) ?? [],
|
|
969
|
+
workspaceLogs.filter((entry) => entry.willTrim),
|
|
844
970
|
worktreeSweep?.worktrees.filter((entry) => !entry.skipped) ?? []
|
|
845
971
|
].every((found) => found.length === 0)) {
|
|
846
972
|
const reasons = [];
|
|
@@ -862,7 +988,7 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
|
|
|
862
988
|
for (const d of orphanedDevices) lines.push(` ${d.kind} ${d.name} (${d.id})${deviceSizeSuffix(d)}`);
|
|
863
989
|
}
|
|
864
990
|
if (unverifiedDevices.length) {
|
|
865
|
-
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:`);
|
|
866
992
|
for (const d of unverifiedDevices) {
|
|
867
993
|
lines.push(` ${d.kind} ${d.name} (${d.id})`);
|
|
868
994
|
lines.push(` ${unverifiedDeviceCommand(d)}`);
|
|
@@ -885,6 +1011,11 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
|
|
|
885
1011
|
}
|
|
886
1012
|
lines.push(" --delete clears the RECORD only; there is no device left to touch.");
|
|
887
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
|
+
}
|
|
888
1019
|
if (easSessionSweep.orphaned.length) {
|
|
889
1020
|
lines.push(`Orphaned EAS sessions (${easSessionSweep.orphaned.length}) - current EAS project only:`);
|
|
890
1021
|
for (const session of easSessionSweep.orphaned) {
|
|
@@ -929,9 +1060,21 @@ function formatGcReport({ skipped = [], deadProjects = [], orphanedPorts, invali
|
|
|
929
1060
|
lines.push(`Skipped (${skipped.length}) - not classified as dead:`);
|
|
930
1061
|
for (const entry of skipped) lines.push(` ${entry.dir}: ${entry.reason}`);
|
|
931
1062
|
}
|
|
1063
|
+
lines.push(...workspaceLogLines(workspaceLogs));
|
|
932
1064
|
lines.push(...cacheLines(caches, workspaceOutputs));
|
|
933
1065
|
return lines;
|
|
934
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
|
+
}
|
|
935
1078
|
function leaseGarbageLines({ expired, kept }) {
|
|
936
1079
|
const lines = [];
|
|
937
1080
|
if (expired.length) {
|
|
@@ -950,7 +1093,7 @@ function leaseGarbageLines({ expired, kept }) {
|
|
|
950
1093
|
return lines;
|
|
951
1094
|
}
|
|
952
1095
|
function deviceSizeSuffix(device) {
|
|
953
|
-
return device.
|
|
1096
|
+
return device.bytes !== void 0 ? ` - ${formatBytes(device.bytes)} on disk` : "";
|
|
954
1097
|
}
|
|
955
1098
|
function namedPortLines(ports = []) {
|
|
956
1099
|
if (!ports.length) return [];
|
|
@@ -1018,11 +1161,14 @@ function worktreeSweepLines(sweep) {
|
|
|
1018
1161
|
if (!sweep || !sweep.idle && !sweep.worktrees.length) return [];
|
|
1019
1162
|
const removable = sweep.worktrees.filter((w) => !w.skipped);
|
|
1020
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`;
|
|
1021
|
-
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.`);
|
|
1022
1167
|
for (const w of sweep.worktrees) {
|
|
1023
1168
|
const age = w.idleDays === null ? "" : ` (idle ${w.idleDays}d)`;
|
|
1024
1169
|
lines.push(` ${w.path}${age}`);
|
|
1025
|
-
|
|
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)}`);
|
|
1026
1172
|
}
|
|
1027
1173
|
if (removable.length) lines.push(" --delete runs it without --force; use, idleness and HEAD are re-checked under its removal locks.");
|
|
1028
1174
|
return lines;
|
|
@@ -1036,7 +1182,16 @@ function jsonLock({ path, platform, key, pid, projectRoot }) {
|
|
|
1036
1182
|
projectRoot
|
|
1037
1183
|
};
|
|
1038
1184
|
}
|
|
1039
|
-
function
|
|
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 = {
|
|
1040
1195
|
stale: [],
|
|
1041
1196
|
live: [],
|
|
1042
1197
|
unresolved: []
|
|
@@ -1052,7 +1207,7 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
|
|
|
1052
1207
|
orphaned: [],
|
|
1053
1208
|
notices: [],
|
|
1054
1209
|
deletionSafe: true
|
|
1055
|
-
}, workspaceOutputs = null, caches = [] }) {
|
|
1210
|
+
}, workspaceOutputs = null, workspaceLogs = [], caches = [] }) {
|
|
1056
1211
|
return {
|
|
1057
1212
|
deadProjects: deadProjects.map((path) => ({ path })),
|
|
1058
1213
|
invalidProjects: invalidProjects.map((path) => ({ path })),
|
|
@@ -1070,6 +1225,8 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
|
|
|
1070
1225
|
path: w.path,
|
|
1071
1226
|
idleDays: w.idleDays,
|
|
1072
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,
|
|
1073
1230
|
willRemove: w.skipCode === null,
|
|
1074
1231
|
reason: w.skipCode,
|
|
1075
1232
|
detail: w.skipped ?? worktreeRemovalReason(w),
|
|
@@ -1120,6 +1277,10 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
|
|
|
1120
1277
|
project: r.project,
|
|
1121
1278
|
slot: r.slot ?? null
|
|
1122
1279
|
})),
|
|
1280
|
+
staleLedgerEntries: staleLedgerEntries.map(({ kind, id }) => ({
|
|
1281
|
+
kind,
|
|
1282
|
+
id
|
|
1283
|
+
})),
|
|
1123
1284
|
idleDevices: idleDevices.map(({ kind, id, name, project, slot, lastActivityAt, idleForMs, buildInProgress }) => ({
|
|
1124
1285
|
kind,
|
|
1125
1286
|
id,
|
|
@@ -1171,6 +1332,15 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
|
|
|
1171
1332
|
path: dir,
|
|
1172
1333
|
detail: reason
|
|
1173
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
|
+
})),
|
|
1174
1344
|
workspaceBuildOutputs: (workspaceOutputs?.workspaces ?? []).map((w) => ({
|
|
1175
1345
|
dir: w.dir,
|
|
1176
1346
|
projectRoot: w.projectRoot,
|
|
@@ -1192,11 +1362,505 @@ function gcReportSections({ skipped = [], deadProjects = [], orphanedPorts = [],
|
|
|
1192
1362
|
};
|
|
1193
1363
|
}
|
|
1194
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
|
|
1195
1858
|
//#region src/commands/gc.ts
|
|
1196
1859
|
function removeInvalidProjectEntries(invalidProjects) {
|
|
1197
1860
|
for (const path of invalidProjects) {
|
|
1198
1861
|
removeProject(path);
|
|
1199
1862
|
console.log(chalk.green(`Removed the invalid registry entry ${path}`));
|
|
1863
|
+
recordGcResult("project", "done", path);
|
|
1200
1864
|
}
|
|
1201
1865
|
}
|
|
1202
1866
|
async function collectGcReport({ olderThan = null, cache = null, worktrees = false, now = Date.now(), lastTouched = workspaceLastUsed } = {}, deps = {}) {
|
|
@@ -1214,6 +1878,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1214
1878
|
unverifiedDevices: [],
|
|
1215
1879
|
staleDevices: [],
|
|
1216
1880
|
staleDeviceRecords: [],
|
|
1881
|
+
staleLedgerEntries: [],
|
|
1217
1882
|
buildLocks: {
|
|
1218
1883
|
stale: [],
|
|
1219
1884
|
live: [],
|
|
@@ -1243,6 +1908,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1243
1908
|
olderThan,
|
|
1244
1909
|
now
|
|
1245
1910
|
}) : null,
|
|
1911
|
+
workspaceLogs: [],
|
|
1246
1912
|
worktreeSweep: null,
|
|
1247
1913
|
cacheScope: scope,
|
|
1248
1914
|
olderThan,
|
|
@@ -1282,7 +1948,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1282
1948
|
else invalidProjects.push(path);
|
|
1283
1949
|
continue;
|
|
1284
1950
|
}
|
|
1285
|
-
if (existsSync(path)) continue;
|
|
1951
|
+
if (existsSync$1(path)) continue;
|
|
1286
1952
|
if (!isOnMountedVolume(path, mountedVolumes)) {
|
|
1287
1953
|
const volume = volumeRootFor(path);
|
|
1288
1954
|
skipped.push({
|
|
@@ -1298,6 +1964,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1298
1964
|
let unverifiedDevices = [];
|
|
1299
1965
|
let staleDevices = [];
|
|
1300
1966
|
let staleDeviceRecords = [];
|
|
1967
|
+
let staleLedgerEntries = staleBrowserProfiles(readCreatedDevices$1());
|
|
1301
1968
|
let idleDevices = [];
|
|
1302
1969
|
const unsweepableReason = cfg === null ? "no Stim config found" : deviceSweepIsScoped() ? "STIM_HOME scopes this config, but simulators and AVDs are machine-global" : null;
|
|
1303
1970
|
if (unsweepableReason) {
|
|
@@ -1313,8 +1980,14 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1313
1980
|
} else {
|
|
1314
1981
|
let sims = [];
|
|
1315
1982
|
let simsChecked = true;
|
|
1983
|
+
const ledger = readCreatedDevices$1();
|
|
1316
1984
|
try {
|
|
1317
|
-
|
|
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);
|
|
1318
1991
|
} catch {
|
|
1319
1992
|
simsChecked = false;
|
|
1320
1993
|
deviceSweepNotices.push(`ios device sweep skipped: simulator tooling did not answer within ${DEVICE_LIST_TIMEOUT_MS / 1e3}s`);
|
|
@@ -1334,6 +2007,8 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1334
2007
|
avdsChecked = false;
|
|
1335
2008
|
deviceSweepNotices.push(`android data sweep skipped: ${error.message}`);
|
|
1336
2009
|
}
|
|
2010
|
+
if (avdsChecked) staleLedgerEntries = [...staleLedgerEntries, ...findStaleAndroidLedgerEntries(ledger, avds, loadConfig$1())];
|
|
2011
|
+
const registeredAvds = new Set(avds);
|
|
1337
2012
|
avds = [.../* @__PURE__ */ new Set([...avds, ...orphanedAvdDirectories.map((entry) => entry.name)])];
|
|
1338
2013
|
const isMounted = (path) => isOnMountedVolume(path, mountedVolumes);
|
|
1339
2014
|
const found = findOrphanedDevices({
|
|
@@ -1341,13 +2016,12 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1341
2016
|
avds,
|
|
1342
2017
|
config: cfg,
|
|
1343
2018
|
isMounted,
|
|
1344
|
-
deadProjects
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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];
|
|
1349
2024
|
});
|
|
1350
|
-
unverifiedDevices = found.unverified;
|
|
1351
2025
|
orphanedDevices = withAndroidAvdSizes(found.orphaned.flatMap((device) => {
|
|
1352
2026
|
const directories = device.kind === "android" ? orphanedAvdDirectories.filter((entry) => entry.name === device.name) : [];
|
|
1353
2027
|
return directories.length ? directories.map((orphanedDirectory) => Object.assign({}, device, { orphanedDirectory })) : [device];
|
|
@@ -1379,6 +2053,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1379
2053
|
}
|
|
1380
2054
|
const locks = listBuildLocks();
|
|
1381
2055
|
const slots = listBuildSlots();
|
|
2056
|
+
const goneWorkspaceDirs = [...workspaceDirs.orphaned.map((entry) => entry.dir), ...deadProjects.map(workspaceDir)];
|
|
1382
2057
|
const deviceLeases = collectDeviceLeases(now);
|
|
1383
2058
|
return {
|
|
1384
2059
|
skipped,
|
|
@@ -1396,6 +2071,7 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1396
2071
|
unverifiedDevices,
|
|
1397
2072
|
staleDevices,
|
|
1398
2073
|
staleDeviceRecords,
|
|
2074
|
+
staleLedgerEntries,
|
|
1399
2075
|
buildLocks: {
|
|
1400
2076
|
stale: locks.filter((l) => !l.alive && !l.unresolved),
|
|
1401
2077
|
live: locks.filter((l) => l.alive),
|
|
@@ -1414,9 +2090,10 @@ async function collectGcReport({ olderThan = null, cache = null, worktrees = fal
|
|
|
1414
2090
|
workspaceOutputs: collectWorkspaceOutputs({
|
|
1415
2091
|
olderThan,
|
|
1416
2092
|
now,
|
|
1417
|
-
exclude:
|
|
2093
|
+
exclude: goneWorkspaceDirs
|
|
1418
2094
|
}),
|
|
1419
|
-
|
|
2095
|
+
workspaceLogs: collectWorkspaceLogs({ exclude: goneWorkspaceDirs }),
|
|
2096
|
+
worktreeSweep: await collectWorktreeSweep({
|
|
1420
2097
|
idle: worktrees,
|
|
1421
2098
|
olderThan,
|
|
1422
2099
|
now
|
|
@@ -1524,7 +2201,51 @@ async function sweep(opts, deps) {
|
|
|
1524
2201
|
precollectedEasSessionSweep: easSessionSweep
|
|
1525
2202
|
});
|
|
1526
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
|
+
}
|
|
1527
2247
|
async function runGcCore(opts, deps) {
|
|
2248
|
+
takeGcResults();
|
|
1528
2249
|
const olderThan = typeof opts.olderThan === "number" ? opts.olderThan : null;
|
|
1529
2250
|
const cache = typeof opts.cache === "string" && opts.cache.trim() ? opts.cache : null;
|
|
1530
2251
|
const report = await collectGcReport({
|
|
@@ -1547,10 +2268,11 @@ async function runGcCore(opts, deps) {
|
|
|
1547
2268
|
const all = report.all;
|
|
1548
2269
|
for (const line of formatGcReport(report)) console.log(line);
|
|
1549
2270
|
const { deadProjects, invalidProjects, orphanedDevices, staleDevices, staleDeviceRecords, buildLocks, buildSlots, deviceLeases, easSessionSweep, caches } = report;
|
|
1550
|
-
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;
|
|
1551
2272
|
const idle = opts.idle ?? null;
|
|
1552
2273
|
const idleFailures = idle === null ? 0 : shutDownIdleDevices(report.idleDevices, idle, () => collectIdleDevices(loadConfig$1(), listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }), report.deadProjects)).failures;
|
|
1553
2274
|
if (idleFailures) process.exitCode = 1;
|
|
2275
|
+
const inventory = opts.json && !opts.delete && idle === null && report.cacheScope === null ? collectInventory(loadConfig$1(), deadProjects) : null;
|
|
1554
2276
|
const payload = (failures) => ({
|
|
1555
2277
|
mode: opts.delete ? "delete" : "dry-run",
|
|
1556
2278
|
idle,
|
|
@@ -1559,7 +2281,9 @@ async function runGcCore(opts, deps) {
|
|
|
1559
2281
|
worktreeSweep: report.worktreeSweep?.idle ?? null,
|
|
1560
2282
|
actionable,
|
|
1561
2283
|
failures,
|
|
1562
|
-
sections: gcReportSections(report)
|
|
2284
|
+
sections: gcReportSections(report),
|
|
2285
|
+
results: takeGcResults(),
|
|
2286
|
+
inventory
|
|
1563
2287
|
});
|
|
1564
2288
|
if (!opts.delete) {
|
|
1565
2289
|
if (all) console.log(chalk.dim("\nDry run. Re-run with --delete to empty the caches above."));
|
|
@@ -1569,34 +2293,13 @@ async function runGcCore(opts, deps) {
|
|
|
1569
2293
|
}
|
|
1570
2294
|
let deleteFailures = idleFailures;
|
|
1571
2295
|
deleteFailures += report.workspaceOutputs ? (await clearWorkspaceOutputs(report.workspaceOutputs, { olderThan })).failures : 0;
|
|
2296
|
+
deleteFailures += await trimWorkspaceLogs(report.workspaceLogs);
|
|
1572
2297
|
deleteFailures += deleteParkedSims(report.parkedSims, deps) + deleteParkedAvds(report.parkedAvds);
|
|
1573
2298
|
removeInvalidProjectEntries(invalidProjects);
|
|
1574
|
-
|
|
1575
|
-
if (existsSync(path) || !isOnMountedVolume(path)) {
|
|
1576
|
-
console.log(chalk.yellow(`Kept ${path}: its absence can no longer be confirmed.`));
|
|
1577
|
-
continue;
|
|
1578
|
-
}
|
|
1579
|
-
const result = await reclaimProject(path).catch((error) => {
|
|
1580
|
-
deleteFailures++;
|
|
1581
|
-
console.log(chalk.red(`Could not prune ${path}; its registry entry was kept: ${error.message}`));
|
|
1582
|
-
return null;
|
|
1583
|
-
});
|
|
1584
|
-
if (!result) continue;
|
|
1585
|
-
if (result.keptEntry) console.log(chalk.yellow(`Could not fully prune ${path}; its registry entry was kept.`));
|
|
1586
|
-
else console.log(chalk.green(`Pruned ${path}`));
|
|
1587
|
-
for (const dir of result.removedWorkspaceDirs) console.log(chalk.dim(` removed workspace output ${dir}`));
|
|
1588
|
-
for (const dir of result.failedWorkspaceDirs) {
|
|
1589
|
-
console.log(chalk.red(` could not remove workspace output ${dir}`));
|
|
1590
|
-
deleteFailures += 1;
|
|
1591
|
-
}
|
|
1592
|
-
if (result.killedPid) console.log(chalk.dim(` killed orphaned Metro pid ${result.killedPid}`));
|
|
1593
|
-
if (result.stoppedSession) console.log(chalk.dim(` stopped remote session ${result.stoppedSession}`));
|
|
1594
|
-
if (result.stoppedTunnel) console.log(chalk.dim(` stopped ${result.stoppedTunnel} tunnel`));
|
|
1595
|
-
for (const s of result.skippedDevices) console.log(chalk.yellow(` ${s.name}: ${s.reason}`));
|
|
1596
|
-
deleteFailures += result.failedDevices.length;
|
|
1597
|
-
}
|
|
2299
|
+
deleteFailures += await pruneDeadProjects(deadProjects);
|
|
1598
2300
|
deleteFailures += await deleteOrphanedWorkspaces(report.orphanedWorkspaces);
|
|
1599
2301
|
deleteFailures += deleteProjectDevices(orphanedDevices, staleDevices, staleDeviceRecords);
|
|
2302
|
+
deleteFailures += forgetStaleLedgerEntries(report.staleLedgerEntries);
|
|
1600
2303
|
for (const lock of buildLocks.stale) {
|
|
1601
2304
|
const cleared = (0, ownership_claim_exports.clearFreeClaimSet)({
|
|
1602
2305
|
root: lock.path,
|
|
@@ -1605,13 +2308,22 @@ async function runGcCore(opts, deps) {
|
|
|
1605
2308
|
if (cleared.status === "held") continue;
|
|
1606
2309
|
if (cleared.status === "refused") {
|
|
1607
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
|
+
});
|
|
1608
2315
|
continue;
|
|
1609
2316
|
}
|
|
1610
2317
|
if (cleared.status === "failed") {
|
|
1611
2318
|
deleteFailures++;
|
|
1612
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
|
+
});
|
|
1613
2324
|
continue;
|
|
1614
2325
|
}
|
|
2326
|
+
recordGcResult("buildLock", "done", `${lock.platform} build lock`, { id: lock.path });
|
|
1615
2327
|
console.log(chalk.green(`Cleared the ${lock.platform} build lock left by pid ${lock.pid ?? "?"} (${lock.projectRoot || "unrecorded workspace"})`));
|
|
1616
2328
|
}
|
|
1617
2329
|
for (const slot of buildSlots.stale) {
|
|
@@ -1622,21 +2334,36 @@ async function runGcCore(opts, deps) {
|
|
|
1622
2334
|
if (cleared.status === "held") continue;
|
|
1623
2335
|
if (cleared.status === "refused") {
|
|
1624
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
|
+
});
|
|
1625
2341
|
continue;
|
|
1626
2342
|
}
|
|
1627
2343
|
if (cleared.status === "failed") {
|
|
1628
2344
|
deleteFailures++;
|
|
1629
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
|
+
});
|
|
1630
2350
|
continue;
|
|
1631
2351
|
}
|
|
2352
|
+
recordGcResult("buildSlot", "done", `build slot ${slot.index ?? "?"}`, { id: slot.path });
|
|
1632
2353
|
console.log(chalk.green(`Cleared build slot ${slot.index ?? "?"} left by pid ${slot.pid ?? "?"} (${slot.projectRoot || "unrecorded workspace"})`));
|
|
1633
2354
|
}
|
|
1634
2355
|
for (const entry of deviceLeases.expired) try {
|
|
1635
|
-
if (removeExpiredLease(entry))
|
|
1636
|
-
|
|
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.`));
|
|
1637
2360
|
} catch (err) {
|
|
1638
2361
|
deleteFailures++;
|
|
1639
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
|
+
});
|
|
1640
2367
|
}
|
|
1641
2368
|
deleteFailures += await deleteEasSessions(easSessionSweep, deps);
|
|
1642
2369
|
if (report.worktreeSweep) deleteFailures += await removeWorktrees(report.worktreeSweep);
|
|
@@ -1651,7 +2378,7 @@ async function runGcCore(opts, deps) {
|
|
|
1651
2378
|
return payload(deleteFailures);
|
|
1652
2379
|
}
|
|
1653
2380
|
function gcCommand(program) {
|
|
1654
|
-
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,
|
|
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) => {
|
|
1655
2382
|
const n = parseInt(v, 10);
|
|
1656
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");
|
|
1657
2384
|
return n;
|