forgedock 1.26.0 → 1.28.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 +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +41 -6
- package/bin/engine/telemetry-seam.test.mjs +35 -0
- package/bin/engine-cli.mjs +616 -54
- package/bin/engine.mjs +13 -5
- package/bin/forgedock.mjs +169 -39
- package/bin/gc.mjs +580 -0
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +24 -5
- package/bin/lean/batch.test.mjs +54 -4
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +235 -37
- package/bin/lean/conformance.test.mjs +143 -5
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +178 -16
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +877 -151
- package/bin/lean/lean.test.mjs +1798 -28
- package/bin/lean/ledger.mjs +92 -0
- package/bin/lean/ledger.test.mjs +100 -0
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +321 -19
- package/bin/lean/prompts.mjs +125 -17
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +25 -6
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +124 -0
- package/bin/lean/verify.test.mjs +118 -0
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +5 -8
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +125 -6
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/review-pr.md +2 -2
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +227 -3
- package/docs/OUTCOME-CODES.md +11 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +321 -0
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +125 -0
- package/scripts/check-devdocs-freshness.test.sh +130 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +16 -5
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +14 -2
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +155 -0
- package/scripts/structure-delta.test.sh +136 -0
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
package/bin/gc.mjs
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* `forgedock gc` reaper (#4180). Dry-run by default. It only reclaims what ForgeDock provably owns:
|
|
5
|
+
* - resources listed in a stale lane ledger (#4179: owner pid dead on this host, or expired on another host), sandboxes
|
|
6
|
+
* included (#4253: torn down through their provider; `remote` via forge.yaml `execution`, #4254),
|
|
7
|
+
* - docker containers/volumes labelled `forgedock.owner=1`,
|
|
8
|
+
* - `lean-*` / `agent-*` dirs under the worktree root and `feat/issue-<n>-lean` branches,
|
|
9
|
+
* - ForgeDock's own scratch under `~/.forge/tmp` (run dirs with the owner marker, runner scratch `<repoKey>/<issue>`),
|
|
10
|
+
* - dependency cache snapshots unused for 7 days, dead publishers' tmp dirs and dead locks (#4220, `lean/depcache.mjs`).
|
|
11
|
+
* Everything else is reported, never touched. Unpushed or uncommitted work, protected branches, branches with an open
|
|
12
|
+
* PR and live lanes are always reported instead of reclaimed. Any probe failure degrades to "report", never to a reclaim.
|
|
13
|
+
* All effects go through injected probes (docker, git, gh, /proc, fs, kill) so the planner is testable without a host.
|
|
14
|
+
*/
|
|
15
|
+
import * as nodeFs from "node:fs";
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
import { homedir, hostname } from "node:os";
|
|
19
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
20
|
+
import { makeLedger, ledgerRoot as defaultLedgerRoot, LEDGER_TTL_MS } from "./lean/ledger.mjs";
|
|
21
|
+
import { makeRunTmp, procsInDir, killLedgerPid } from "./lean/effects.mjs";
|
|
22
|
+
import { readBranches } from "./lean/lane.mjs";
|
|
23
|
+
import { makeDepCache } from "./lean/depcache.mjs";
|
|
24
|
+
import { parseExecution, remoteTransportFor } from "./lean/execution.mjs";
|
|
25
|
+
import { parseForgeYaml } from "./forge-utils.mjs";
|
|
26
|
+
|
|
27
|
+
const pexec = promisify(execFile);
|
|
28
|
+
export const GC_TYPES = Object.freeze(["container", "volume", "worktree", "branch", "tmp", "process", "sandbox", "depcache"]);
|
|
29
|
+
const SCOPE_OF = { container: "docker", volume: "docker", worktree: "worktrees", branch: "branches", tmp: "tmp", process: "processes", sandbox: "sandboxes", depcache: "deps" };
|
|
30
|
+
const TYPE_OF_KIND = { worktree: "worktree", branch: "branch", tmp: "tmp", pid: "process", container: "container", volume: "volume", sandbox: "sandbox" };
|
|
31
|
+
export const GC_SCOPES = Object.freeze(["docker", "worktrees", "branches", "tmp", "processes", "sandboxes", "deps"]);
|
|
32
|
+
const LANE_BRANCH_RE = /^feat\/issue-(\d+)-lean$/;
|
|
33
|
+
const WORKTREE_DIR_RE = /^(lean|agent)-/;
|
|
34
|
+
const RUN_OWNER = ".forge-run-owner";
|
|
35
|
+
|
|
36
|
+
/** "30m", "6h", "2d" (or bare minutes) -> ms. Throws on anything else. */
|
|
37
|
+
export function parseDuration(s) {
|
|
38
|
+
const m = /^(\d+(?:\.\d+)?)([mhd]?)$/.exec(String(s ?? "").trim());
|
|
39
|
+
if (!m) throw new Error(`invalid --older-than value: ${s} (use e.g. 30m, 6h, 2d)`);
|
|
40
|
+
return Math.round(Number(m[1]) * { "": 60e3, m: 60e3, h: 3600e3, d: 86400e3 }[m[2]]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseGcArgs(argv = []) {
|
|
44
|
+
let apply = false, dry = false, olderThanMs = LEDGER_TTL_MS, olderThanGiven = false;
|
|
45
|
+
const scopes = new Set();
|
|
46
|
+
for (let i = 0; i < argv.length; i++) {
|
|
47
|
+
const a = String(argv[i]);
|
|
48
|
+
if (a === "--apply") apply = true;
|
|
49
|
+
else if (a === "--dry-run") dry = true;
|
|
50
|
+
else if (a === "--older-than") { if (i + 1 >= argv.length) throw new Error("--older-than needs a value"); olderThanMs = parseDuration(argv[++i]); olderThanGiven = true; }
|
|
51
|
+
else if (a.startsWith("--older-than=")) { olderThanMs = parseDuration(a.slice("--older-than=".length)); olderThanGiven = true; }
|
|
52
|
+
else if (GC_SCOPES.includes(a.slice(2)) && a.startsWith("--")) scopes.add(a.slice(2));
|
|
53
|
+
else throw new Error(`unknown gc argument: ${a}`);
|
|
54
|
+
}
|
|
55
|
+
if (apply && dry) throw new Error("--apply and --dry-run are mutually exclusive");
|
|
56
|
+
return { apply, olderThanMs, olderThanGiven, scopes: scopes.size ? scopes : new Set(GC_SCOPES) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** forge.yaml `hygiene:` defaults (#4183). TTLs in hours; `max_host_load_ratio` is the near-ceiling trigger for orchestrate GC. */
|
|
60
|
+
export const HYGIENE_DEFAULTS = Object.freeze({ container_ttl_hours: 6, worktree_ttl_hours: 24, tmp_ttl_hours: 6, delete_merged_branches: true, max_host_load_ratio: 0.8, auto_gc: true });
|
|
61
|
+
|
|
62
|
+
/** Top-level forge.yaml `hygiene:` block (block form only); each missing or invalid value falls back to its default. */
|
|
63
|
+
export function readHygiene(cwd = process.cwd(), { fs = nodeFs } = {}) {
|
|
64
|
+
let h = {};
|
|
65
|
+
try { h = parseForgeYaml(fs.readFileSync(join(cwd, "forge.yaml"), "utf8"))?.hygiene ?? {}; } catch { h = {}; }
|
|
66
|
+
if (!h || typeof h !== "object") h = {};
|
|
67
|
+
const hours = (k) => { const v = h[k], n = Number(v); return v !== undefined && v !== "" && Number.isFinite(n) && n >= 0 ? n : HYGIENE_DEFAULTS[k]; };
|
|
68
|
+
const bool = (k) => { const v = String(h[k] ?? "").trim().toLowerCase(); return v === "true" ? true : v === "false" ? false : HYGIENE_DEFAULTS[k]; };
|
|
69
|
+
const ratio = Number(h.max_host_load_ratio);
|
|
70
|
+
return {
|
|
71
|
+
container_ttl_hours: hours("container_ttl_hours"), worktree_ttl_hours: hours("worktree_ttl_hours"), tmp_ttl_hours: hours("tmp_ttl_hours"),
|
|
72
|
+
delete_merged_branches: bool("delete_merged_branches"), auto_gc: bool("auto_gc"),
|
|
73
|
+
max_host_load_ratio: h.max_host_load_ratio !== undefined && h.max_host_load_ratio !== "" && ratio > 0 && ratio <= 1 ? ratio : HYGIENE_DEFAULTS.max_host_load_ratio,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Per-type TTL in ms from hygiene settings: containers/volumes, worktrees/branches, tmp/processes. */
|
|
78
|
+
export function hygieneTtls(h = HYGIENE_DEFAULTS) {
|
|
79
|
+
const ms = (x) => Math.round(Number(x) * 3600e3);
|
|
80
|
+
const c = ms(h.container_ttl_hours ?? HYGIENE_DEFAULTS.container_ttl_hours), w = ms(h.worktree_ttl_hours ?? HYGIENE_DEFAULTS.worktree_ttl_hours), t = ms(h.tmp_ttl_hours ?? HYGIENE_DEFAULTS.tmp_ttl_hours);
|
|
81
|
+
return { container: c, volume: c, worktree: w, branch: w, tmp: t, process: t };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e?.code !== "ESRCH"; } };
|
|
85
|
+
const ppidFromProc = (pid, procRoot = "/proc") => {
|
|
86
|
+
try { const st = nodeFs.readFileSync(join(procRoot, String(pid), "stat"), "utf8"); return Number(st.slice(st.lastIndexOf(")") + 2).split(" ")[1]) || 0; } catch { return 0; }
|
|
87
|
+
};
|
|
88
|
+
/** Bytes under `p` (lstat, no symlink following), capped so a huge tree cannot stall the report. null when unreadable. */
|
|
89
|
+
export function dirSize(p, { fs = nodeFs, maxEntries = 200000 } = {}) {
|
|
90
|
+
let total = 0, n = 0;
|
|
91
|
+
const walk = (d) => {
|
|
92
|
+
let st; try { st = fs.lstatSync(d); } catch { return; }
|
|
93
|
+
total += st.size; if (++n > maxEntries || !st.isDirectory()) return;
|
|
94
|
+
let names = []; try { names = fs.readdirSync(d); } catch { return; }
|
|
95
|
+
for (const x of names) walk(join(d, x));
|
|
96
|
+
};
|
|
97
|
+
try { fs.lstatSync(p); } catch { return null; }
|
|
98
|
+
walk(p);
|
|
99
|
+
return total;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Real probes. Every one may be replaced in tests. */
|
|
103
|
+
export function makeGcProbes({ cwd = process.cwd(), home = homedir() } = {}) {
|
|
104
|
+
const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd ?? cwd, maxBuffer: 64 * 1024 * 1024, timeout: 120000 })).stdout;
|
|
105
|
+
const b = readBranches(cwd);
|
|
106
|
+
return {
|
|
107
|
+
cwd, host: hostname(), fs: nodeFs,
|
|
108
|
+
ledgerRoot: home === homedir() ? defaultLedgerRoot() : join(home, ".forge", "ledger"),
|
|
109
|
+
forgeTmpRoot: join(home, ".forge", "tmp"),
|
|
110
|
+
worktreeRoot: join(cwd, ".claude", "worktrees"),
|
|
111
|
+
protectedBranches: ["main", "master", "staging", b.default, b.staging].filter(Boolean),
|
|
112
|
+
alive: pidAlive, ppid: (pid) => ppidFromProc(pid), procsInDir: (d) => procsInDir(d),
|
|
113
|
+
killPid: async (pid, marker) => killLedgerPid(pid, marker),
|
|
114
|
+
docker: run("docker"), git: run("git"), gh: run("gh"),
|
|
115
|
+
dirSize: (p) => dirSize(p),
|
|
116
|
+
depCache: makeDepCache({ exec: async () => "" }), // scan/remove only: never installs
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const parseLabels = (s) => {
|
|
121
|
+
if (s && typeof s === "object") return s;
|
|
122
|
+
const out = {};
|
|
123
|
+
for (const kv of String(s ?? "").split(",")) { const i = kv.indexOf("="); if (i > 0) out[kv.slice(0, i).trim()] = kv.slice(i + 1).trim(); }
|
|
124
|
+
return out;
|
|
125
|
+
};
|
|
126
|
+
/** docker's "2026-10-18 10:00:00 +0200 CEST" (or RFC3339) -> ms; NaN when unparseable. */
|
|
127
|
+
export const parseDockerTime = (s) => {
|
|
128
|
+
const m = /^(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d)(?:\.\d+)? ([+-])(\d\d)(\d\d)/.exec(String(s ?? ""));
|
|
129
|
+
return m ? Date.parse(`${m[1]}T${m[2]}${m[3]}${m[4]}:${m[5]}`) : Date.parse(String(s ?? ""));
|
|
130
|
+
};
|
|
131
|
+
const parseSize = (s) => {
|
|
132
|
+
const m = /^([\d.]+)\s*([kKMGT]?i?B)/.exec(String(s ?? "").trim());
|
|
133
|
+
if (!m) return null;
|
|
134
|
+
const mult = { B: 1, kB: 1e3, KB: 1e3, KiB: 1024, MB: 1e6, MiB: 1048576, GB: 1e9, GiB: 1073741824, TB: 1e12, TiB: 1099511627776 }[m[2]] ?? 1;
|
|
135
|
+
return Math.round(Number(m[1]) * mult);
|
|
136
|
+
};
|
|
137
|
+
const jsonLines = (s) => String(s ?? "").split("\n").map((l) => l.trim()).filter(Boolean).flatMap((l) => { try { return [JSON.parse(l)]; } catch { return []; } });
|
|
138
|
+
const isInside = (root, p) => { const r = resolve(root), x = resolve(p); return x.startsWith(r + sep); };
|
|
139
|
+
const isDirectChild = (root, p) => dirname(resolve(p)) === resolve(root) && !!basename(p);
|
|
140
|
+
const errText = (e) => String(e?.stderr || e?.message || e).trim().split("\n")[0].slice(0, 160);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Build the gc plan. Returns {items, summary, notes, ledgers}. `items[]`: {type, id, sizeBytes, ageMs, owned, action, reason, ...ctx}.
|
|
144
|
+
* Only items with owned && action === "reclaim" are ever acted on by applyGc.
|
|
145
|
+
*/
|
|
146
|
+
export async function planGc({ probes, scopes = new Set(GC_SCOPES), olderThanMs = LEDGER_TTL_MS, ttlMs = null, deleteMergedBranches = true, now = Date.now() } = {}) {
|
|
147
|
+
const P = probes, fs = P.fs ?? nodeFs;
|
|
148
|
+
const items = [], notes = [], seen = new Set(), liveIds = new Set(), ledgers = [];
|
|
149
|
+
const want = (type) => scopes.has(SCOPE_OF[type]);
|
|
150
|
+
const ttl = (type) => (Number.isFinite(ttlMs?.[type]) ? ttlMs[type] : olderThanMs); // forge.yaml hygiene TTLs, else --older-than
|
|
151
|
+
const add = (it) => { const k = `${it.type}:${it.id}`; if (seen.has(k)) return; seen.add(k); items.push(it); };
|
|
152
|
+
const age = (t) => (Number.isFinite(t) ? Math.max(0, now - t) : null);
|
|
153
|
+
const mtime = (p) => { try { return fs.statSync(p).mtimeMs; } catch { return NaN; } };
|
|
154
|
+
const git = (args, cwd) => P.git(args, { cwd: cwd ?? P.cwd });
|
|
155
|
+
|
|
156
|
+
/** Uncommitted or unpushed work in a checkout; null when git cannot inspect `dir` as its own top level. */
|
|
157
|
+
const workIn = async (dir) => {
|
|
158
|
+
try {
|
|
159
|
+
const top = String(await git(["rev-parse", "--show-toplevel"], dir)).trim();
|
|
160
|
+
if (!top || resolve(top) !== resolve(dir)) return null; // not its own checkout (git would walk up to the parent repo)
|
|
161
|
+
if (String(await git(["status", "--porcelain"], dir)).trim()) return "uncommitted work";
|
|
162
|
+
if (String(await git(["rev-list", "HEAD", "--not", "--remotes"], dir)).trim()) return "unpushed work";
|
|
163
|
+
return "";
|
|
164
|
+
} catch { return null; }
|
|
165
|
+
};
|
|
166
|
+
const nonEmpty = (d) => { try { return fs.readdirSync(d).length > 0; } catch { return false; } };
|
|
167
|
+
|
|
168
|
+
// ---- worktree list (registered dirs and checked-out branches) ----
|
|
169
|
+
let registered = new Set(), checkedOut = new Set(), worktreeListOk = false;
|
|
170
|
+
if (want("worktree") || want("branch")) {
|
|
171
|
+
try {
|
|
172
|
+
let cur = null;
|
|
173
|
+
for (const l of String(await git(["worktree", "list", "--porcelain"])).split("\n")) {
|
|
174
|
+
if (l.startsWith("worktree ")) { cur = l.slice(9); registered.add(resolve(cur)); }
|
|
175
|
+
else if (l.startsWith("branch refs/heads/")) checkedOut.add(l.slice(18));
|
|
176
|
+
}
|
|
177
|
+
worktreeListOk = true;
|
|
178
|
+
} catch (e) { notes.push(`git worktree list failed: ${errText(e)}`); }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- open PR heads and issue states (branches) ----
|
|
182
|
+
let openHeads = null;
|
|
183
|
+
const openPrHeads = async () => {
|
|
184
|
+
if (openHeads) return openHeads;
|
|
185
|
+
try { openHeads = new Set(JSON.parse(String(await P.gh(["pr", "list", "--state", "open", "--limit", "1000", "--json", "headRefName"]))).map((p) => p.headRefName)); }
|
|
186
|
+
catch (e) { openHeads = false; notes.push(`gh pr list failed: ${errText(e)}`); }
|
|
187
|
+
return openHeads;
|
|
188
|
+
};
|
|
189
|
+
const issueStates = new Map();
|
|
190
|
+
const issueState = async (n) => {
|
|
191
|
+
if (issueStates.has(n)) return issueStates.get(n);
|
|
192
|
+
let s;
|
|
193
|
+
try { s = String(JSON.parse(String(await P.gh(["issue", "view", String(n), "--json", "state"]))).state ?? "").toUpperCase() || null; }
|
|
194
|
+
catch (e) { s = /could not resolve|not found|no issue/i.test(errText(e)) ? "INVALID" : null; }
|
|
195
|
+
issueStates.set(n, s);
|
|
196
|
+
return s;
|
|
197
|
+
};
|
|
198
|
+
const isProtected = (b) => (P.protectedBranches ?? ["main", "master", "staging"]).includes(b) || /^milestone\//.test(b) || !LANE_BRANCH_RE.test(b);
|
|
199
|
+
/** Shared branch safety: never protected, never with an open PR, never with local-only commits. Returns a report reason or "". */
|
|
200
|
+
const branchBlock = async (b, { local }) => {
|
|
201
|
+
if (isProtected(b)) return "protected branch";
|
|
202
|
+
const heads = await openPrHeads();
|
|
203
|
+
if (heads === false) return "cannot verify open PRs";
|
|
204
|
+
if (heads.has(b)) return "open PR";
|
|
205
|
+
if (local) {
|
|
206
|
+
if (checkedOut.has(b)) return "checked out in a worktree";
|
|
207
|
+
try { if (String(await git(["rev-list", `refs/heads/${b}`, "--not", "--remotes"])).trim()) return "unpushed work"; }
|
|
208
|
+
catch { return "cannot inspect branch"; }
|
|
209
|
+
}
|
|
210
|
+
return "";
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// ---- 1. lane ledgers (#4179) ----
|
|
214
|
+
let batches = [];
|
|
215
|
+
try { batches = fs.readdirSync(P.ledgerRoot); } catch { /* no ledgers */ }
|
|
216
|
+
for (const bdir of batches) {
|
|
217
|
+
let files = [];
|
|
218
|
+
try { files = fs.readdirSync(join(P.ledgerRoot, bdir)).filter((f) => f.endsWith(".json")); } catch { continue; }
|
|
219
|
+
for (const f of files) {
|
|
220
|
+
const path = join(P.ledgerRoot, bdir, f);
|
|
221
|
+
let j; try { j = JSON.parse(fs.readFileSync(path, "utf8")); } catch { notes.push(`unreadable ledger ${path}`); continue; }
|
|
222
|
+
if (!j || !Array.isArray(j.resources)) continue;
|
|
223
|
+
const sameHost = j.host === P.host;
|
|
224
|
+
// A live owner on this host is a running lane, even past `expires` (long CI waits refresh it only on register).
|
|
225
|
+
const stale = sameHost ? !(Number.isSafeInteger(j.pid) && P.alive(j.pid)) : Number(j.expires) < now;
|
|
226
|
+
ledgers.push({ path, batch: j.batch, lane: j.lane, stale });
|
|
227
|
+
for (const r of j.resources) {
|
|
228
|
+
const type = TYPE_OF_KIND[r.kind];
|
|
229
|
+
if (!type) continue;
|
|
230
|
+
const id = String(r.value);
|
|
231
|
+
const provider = r.provider ?? r.handle?.provider ?? null;
|
|
232
|
+
// A container sandbox (#4253) is reaped through its ledger entry, never again by the labelled-container pass.
|
|
233
|
+
if (type === "sandbox" && provider === "container") { liveIds.add(`container:${id}`); seen.add(`container:${id}`); }
|
|
234
|
+
if (!stale) { liveIds.add(`${type}:${type === "worktree" || type === "tmp" ? resolve(id) : id}`); if (want(type)) add({ type, id, sizeBytes: null, ageMs: age(r.registered), owned: true, action: "report", reason: `live lane ${j.lane}`, ledger: path }); continue; }
|
|
235
|
+
if (!want(type)) continue;
|
|
236
|
+
const it = { type, id, sizeBytes: null, ageMs: age(r.registered), owned: true, action: "reclaim", reason: `stale ledger ${j.lane} (owner ${j.pid}@${j.host} gone)`, ledger: path, kind: r.kind, value: r.value, marker: r.marker ?? null };
|
|
237
|
+
if (type === "worktree") {
|
|
238
|
+
it.id = resolve(id);
|
|
239
|
+
it.registered = registered.has(it.id);
|
|
240
|
+
if (fs.existsSync(id)) {
|
|
241
|
+
it.sizeBytes = P.dirSize?.(id) ?? null;
|
|
242
|
+
const w = await workIn(id);
|
|
243
|
+
if ((P.procsInDir?.(id) ?? []).length) { it.action = "report"; it.reason = "live process inside"; }
|
|
244
|
+
else if (w) { it.action = "report"; it.reason = w; }
|
|
245
|
+
else if (w === null && nonEmpty(id)) { it.action = "report"; it.reason = it.registered ? "cannot inspect worktree" : "unpushed work (cannot inspect)"; }
|
|
246
|
+
}
|
|
247
|
+
} else if (type === "branch") {
|
|
248
|
+
it.local = true; it.branch = id;
|
|
249
|
+
let exists = true;
|
|
250
|
+
try { await git(["rev-parse", "--verify", "--quiet", `refs/heads/${id}`]); } catch { exists = false; }
|
|
251
|
+
if (!exists) { it.gone = true; it.reason += ", branch already gone"; }
|
|
252
|
+
else { const why = await branchBlock(id, { local: true }); if (why) { it.action = "report"; it.reason = why; } }
|
|
253
|
+
} else if (type === "tmp") {
|
|
254
|
+
it.id = resolve(id);
|
|
255
|
+
if (!isInside(P.forgeTmpRoot, id)) { it.action = "report"; it.reason = "outside ForgeDock tmp"; }
|
|
256
|
+
else it.sizeBytes = P.dirSize?.(id) ?? null;
|
|
257
|
+
} else if (type === "sandbox") {
|
|
258
|
+
it.provider = provider;
|
|
259
|
+
it.handle = { provider, id, issue: r.handle?.issue ?? j.issue ?? null };
|
|
260
|
+
if (!SANDBOX_TEARDOWN[provider]) { it.action = "report"; it.reason = `unknown sandbox provider ${provider ?? "?"}`; }
|
|
261
|
+
} else if (type === "process") {
|
|
262
|
+
const pid = Number(id);
|
|
263
|
+
if (!sameHost) { it.action = "report"; it.reason = "process on another host"; }
|
|
264
|
+
else if (!Number.isSafeInteger(pid) || pid <= 1) { it.action = "report"; it.reason = "invalid pid"; }
|
|
265
|
+
else if (P.alive(pid) && P.ppid(pid) !== 1) { it.action = "report"; it.reason = "not orphaned (parent alive)"; }
|
|
266
|
+
}
|
|
267
|
+
add(it);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---- 2. docker: labelled forgedock.owner=1 is owned; everything else is "not owned" ----
|
|
273
|
+
if (want("container") || want("volume")) {
|
|
274
|
+
const ownedBy = (labels, createdMs, type) => {
|
|
275
|
+
if (labels["forgedock.owner"] !== "1") return { owned: false, action: "report", reason: "not owned" };
|
|
276
|
+
let exp = Number(labels["forgedock.expires"]);
|
|
277
|
+
if (Number.isFinite(exp) && exp > 0 && exp < 1e12) exp *= 1000;
|
|
278
|
+
if (Number.isFinite(exp) && exp > 0 && exp < now) return { owned: true, action: "reclaim", reason: "forgedock label, expired" };
|
|
279
|
+
if (Number.isFinite(createdMs) && now - createdMs >= ttl(type)) return { owned: true, action: "reclaim", reason: "forgedock label, older than TTL" };
|
|
280
|
+
return { owned: true, action: "report", reason: "forgedock label, younger than TTL" };
|
|
281
|
+
};
|
|
282
|
+
if (want("container")) {
|
|
283
|
+
try {
|
|
284
|
+
for (const c of jsonLines(await P.docker(["ps", "-a", "--size", "--no-trunc", "--format", "{{json .}}"]))) {
|
|
285
|
+
const name = String(c.Names ?? c.ID ?? "").split(",")[0];
|
|
286
|
+
if (!name || seen.has(`container:${name}`)) continue;
|
|
287
|
+
if (liveIds.has(`container:${name}`)) continue;
|
|
288
|
+
const created = parseDockerTime(c.CreatedAt);
|
|
289
|
+
add({ type: "container", id: name, sizeBytes: parseSize(c.Size), ageMs: age(created), ...ownedBy(parseLabels(c.Labels), created, "container") });
|
|
290
|
+
}
|
|
291
|
+
} catch (e) { notes.push(`docker containers unavailable: ${errText(e)}`); }
|
|
292
|
+
}
|
|
293
|
+
if (want("volume")) {
|
|
294
|
+
try {
|
|
295
|
+
const vols = jsonLines(await P.docker(["volume", "ls", "--format", "{{json .}}"]));
|
|
296
|
+
const created = new Map();
|
|
297
|
+
const labelled = vols.filter((v) => parseLabels(v.Labels)["forgedock.owner"] === "1").map((v) => v.Name);
|
|
298
|
+
if (labelled.length) {
|
|
299
|
+
try { for (const l of String(await P.docker(["volume", "inspect", "--format", "{{.Name}}\t{{.CreatedAt}}", ...labelled])).split("\n")) { const [n, t] = l.split("\t"); if (n) created.set(n, Date.parse(t)); } }
|
|
300
|
+
catch { /* age unknown: kept unless expired */ }
|
|
301
|
+
}
|
|
302
|
+
for (const v of vols) {
|
|
303
|
+
if (!v.Name || seen.has(`volume:${v.Name}`) || liveIds.has(`volume:${v.Name}`)) continue;
|
|
304
|
+
add({ type: "volume", id: v.Name, sizeBytes: null, ageMs: age(created.get(v.Name)), ...ownedBy(parseLabels(v.Labels), created.get(v.Name), "volume") });
|
|
305
|
+
}
|
|
306
|
+
} catch (e) { notes.push(`docker volumes unavailable: ${errText(e)}`); }
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---- 3. worktrees: lean-* / agent-* under the worktree root ----
|
|
311
|
+
if (want("worktree") && worktreeListOk) {
|
|
312
|
+
let names = [];
|
|
313
|
+
try { names = fs.readdirSync(P.worktreeRoot); } catch { /* no worktree root */ }
|
|
314
|
+
for (const n of names) {
|
|
315
|
+
const dir = resolve(join(P.worktreeRoot, n));
|
|
316
|
+
if (seen.has(`worktree:${dir}`) || liveIds.has(`worktree:${dir}`)) continue;
|
|
317
|
+
try { if (!fs.lstatSync(dir).isDirectory()) continue; } catch { continue; }
|
|
318
|
+
const reg = registered.has(dir);
|
|
319
|
+
const it = { type: "worktree", id: dir, sizeBytes: P.dirSize?.(dir) ?? null, ageMs: age(mtime(dir)), owned: WORKTREE_DIR_RE.test(n), action: "report", reason: "", registered: reg };
|
|
320
|
+
if (!it.owned) { it.reason = "not a ForgeDock worktree name"; add(it); continue; }
|
|
321
|
+
const procs = P.procsInDir?.(dir) ?? [];
|
|
322
|
+
const w = await workIn(dir);
|
|
323
|
+
if (procs.length) it.reason = "live process inside";
|
|
324
|
+
else if (reg && !(it.ageMs >= ttl("worktree"))) it.reason = "younger than TTL";
|
|
325
|
+
else if (w) it.reason = w;
|
|
326
|
+
else if (w === null && (reg || nonEmpty(dir))) it.reason = reg ? "cannot inspect worktree" : "unpushed work (cannot inspect)";
|
|
327
|
+
else { it.action = "reclaim"; it.reason = reg ? "stale worktree, clean and pushed" : "unregistered leftover dir"; }
|
|
328
|
+
add(it);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---- 4. branches: feat/issue-<n>-lean, local and origin, abandoned (closed or invalid issue) and older than TTL ----
|
|
333
|
+
if (want("branch")) {
|
|
334
|
+
const scan = async (ref, local) => {
|
|
335
|
+
let out = "";
|
|
336
|
+
try { out = String(await git(["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", ref])); }
|
|
337
|
+
catch (e) { notes.push(`git for-each-ref ${ref} failed: ${errText(e)}`); return; }
|
|
338
|
+
for (const line of out.split("\n").filter(Boolean)) {
|
|
339
|
+
let [name, ts] = line.split("\t");
|
|
340
|
+
if (!local) { if (!name.startsWith("origin/")) continue; name = name.slice(7); }
|
|
341
|
+
const m = LANE_BRANCH_RE.exec(name);
|
|
342
|
+
if (!m) continue; // never consider non-lane branches (main, staging, milestone/*, user branches)
|
|
343
|
+
const id = local ? name : `origin/${name}`;
|
|
344
|
+
if (seen.has(`branch:${id}`) || liveIds.has(`branch:${name}`)) continue;
|
|
345
|
+
const it = { type: "branch", id, branch: name, local, sizeBytes: null, ageMs: age(Number(ts) * 1000), owned: true, action: "report", reason: "" };
|
|
346
|
+
const why = await branchBlock(name, { local });
|
|
347
|
+
if (why) it.reason = why;
|
|
348
|
+
else if (!(it.ageMs >= ttl("branch"))) it.reason = "younger than TTL";
|
|
349
|
+
else {
|
|
350
|
+
const st = await issueState(Number(m[1]));
|
|
351
|
+
if (st === "CLOSED" || st === "INVALID") { it.action = "reclaim"; it.reason = `abandoned (issue #${m[1]} ${st.toLowerCase()})`; }
|
|
352
|
+
else it.reason = st ? `issue #${m[1]} ${st.toLowerCase()}` : "cannot read issue state";
|
|
353
|
+
}
|
|
354
|
+
add(it);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
await scan("refs/heads/", true);
|
|
358
|
+
await scan("refs/remotes/origin/", false);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---- 5. tmp: ~/.forge/tmp/runs (#4104 rule) and runner scratch ~/.forge/tmp/<repoKey>/<issue> ----
|
|
362
|
+
if (want("tmp")) {
|
|
363
|
+
const root = P.forgeTmpRoot;
|
|
364
|
+
let top = [];
|
|
365
|
+
try { top = fs.readdirSync(root); } catch { /* none */ }
|
|
366
|
+
for (const n of top) {
|
|
367
|
+
const p = join(root, n);
|
|
368
|
+
let st; try { st = fs.lstatSync(p); } catch { continue; }
|
|
369
|
+
if (n === "runs" && st.isDirectory()) {
|
|
370
|
+
let runs = []; try { runs = fs.readdirSync(p); } catch { /* none */ }
|
|
371
|
+
for (const r of runs) {
|
|
372
|
+
const dir = resolve(join(p, r));
|
|
373
|
+
if (seen.has(`tmp:${dir}`) || liveIds.has(`tmp:${dir}`)) continue;
|
|
374
|
+
try { if (!fs.lstatSync(dir).isDirectory()) continue; } catch { continue; }
|
|
375
|
+
const it = { type: "tmp", id: dir, sizeBytes: P.dirSize?.(dir) ?? null, ageMs: null, owned: false, action: "report", reason: "no owner marker", runDir: true };
|
|
376
|
+
try {
|
|
377
|
+
const own = JSON.parse(fs.readFileSync(join(dir, RUN_OWNER), "utf8"));
|
|
378
|
+
it.owned = true; it.ageMs = age(Number(own.created));
|
|
379
|
+
// Same rule as makeRunTmp().prune (#4104): marker, older than the TTL, owner dead on this host.
|
|
380
|
+
if (!(it.ageMs >= ttl("tmp"))) it.reason = "younger than TTL";
|
|
381
|
+
else if (own.host !== P.host) it.reason = "owner on another host";
|
|
382
|
+
else if (Number.isSafeInteger(own.pid) && P.alive(own.pid)) it.reason = "owner alive";
|
|
383
|
+
else { it.action = "reclaim"; it.reason = "stale run tmp, owner dead"; }
|
|
384
|
+
} catch { /* not ours */ }
|
|
385
|
+
add(it);
|
|
386
|
+
}
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (!st.isDirectory()) { add({ type: "tmp", id: resolve(p), sizeBytes: st.size, ageMs: age(st.mtimeMs), owned: false, action: "report", reason: "unrecognised entry" }); continue; }
|
|
390
|
+
let kids = []; try { kids = fs.readdirSync(p); } catch { /* unreadable */ }
|
|
391
|
+
const scratch = kids.length > 0 && kids.every((k) => /^\d+$/.test(k));
|
|
392
|
+
if (!scratch) { add({ type: "tmp", id: resolve(p), sizeBytes: P.dirSize?.(p) ?? null, ageMs: age(st.mtimeMs), owned: false, action: "report", reason: "unrecognised entry" }); continue; }
|
|
393
|
+
for (const k of kids) {
|
|
394
|
+
const dir = resolve(join(p, k));
|
|
395
|
+
if (seen.has(`tmp:${dir}`) || liveIds.has(`tmp:${dir}`)) continue;
|
|
396
|
+
const it = { type: "tmp", id: dir, sizeBytes: P.dirSize?.(dir) ?? null, ageMs: age(mtime(dir)), owned: true, action: "report", reason: "" };
|
|
397
|
+
if (!(it.ageMs >= ttl("tmp"))) it.reason = "younger than TTL";
|
|
398
|
+
else if ((P.procsInDir?.(dir) ?? []).length) it.reason = "owner alive (process inside)";
|
|
399
|
+
else { it.action = "reclaim"; it.reason = "stale runner scratch, no live process"; }
|
|
400
|
+
add(it);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---- 6. dependency cache (#4220): its own 7-day snapshot policy; dead publishers' tmp dirs and dead locks ----
|
|
406
|
+
if (want("depcache") && P.depCache?.scan) {
|
|
407
|
+
let entries = [];
|
|
408
|
+
try { entries = P.depCache.scan(); } catch (e) { notes.push(`depcache scan failed: ${errText(e)}`); }
|
|
409
|
+
for (const e of entries) {
|
|
410
|
+
add({ type: "depcache", id: resolve(e.path), sizeBytes: e.kind === "lock" ? null : P.dirSize?.(e.path) ?? null, ageMs: e.ageMs ?? null, owned: true, action: e.locked ? "report" : "reclaim", reason: e.reason, entry: e });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!deleteMergedBranches) {
|
|
415
|
+
for (const it of items) if (it.type === "branch" && it.action === "reclaim") { it.action = "report"; it.reason = "delete_merged_branches: false"; }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const summary = {};
|
|
419
|
+
for (const t of GC_TYPES) {
|
|
420
|
+
const of = items.filter((i) => i.type === t);
|
|
421
|
+
summary[t] = {
|
|
422
|
+
count: of.length,
|
|
423
|
+
sizeBytes: of.reduce((s, i) => s + (i.sizeBytes ?? 0), 0),
|
|
424
|
+
oldestMs: of.reduce((m, i) => (i.ageMs != null && i.ageMs > m ? i.ageMs : m), 0),
|
|
425
|
+
reclaimable: of.filter((i) => i.owned && i.action === "reclaim").length,
|
|
426
|
+
notOwned: of.filter((i) => !i.owned).length,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
return { items, summary, notes, ledgers, scopes: [...scopes], olderThanMs, ...(ttlMs ? { ttlMs } : {}) };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* forge.yaml `execution` of the checkout and its remote transport (#4254): `P.sandboxTransport` when injected, else the
|
|
434
|
+
* HTTP transport with the credential from the env or keychain. null when unconfigured or refused (never throws).
|
|
435
|
+
*/
|
|
436
|
+
const remoteTransport = (P) => {
|
|
437
|
+
if (P.sandboxTransport) return P.sandboxTransport;
|
|
438
|
+
try {
|
|
439
|
+
const sec = parseForgeYaml(nodeFs.readFileSync(join(P.cwd, "forge.yaml"), "utf8"))?.execution;
|
|
440
|
+
return remoteTransportFor(parseExecution(sec && typeof sec === "object" ? sec : {}), { env: P.env ?? process.env });
|
|
441
|
+
} catch { return null; }
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
/** Sandbox teardown by provider (#4253, execution.mjs): a container is removed; a local lane is its own process; a remote one is destroyed through its transport. */
|
|
445
|
+
const SANDBOX_TEARDOWN = {
|
|
446
|
+
container: async (P, h) => { try { await P.docker(["rm", "-f", h.id]); return true; } catch (e) { return /no such container/i.test(errText(e)); } },
|
|
447
|
+
local: async () => true,
|
|
448
|
+
remote: async (P, h) => { const t = remoteTransport(P); if (!t) return false; try { return (await t.destroy(h.id)) !== false; } catch { return false; } },
|
|
449
|
+
};
|
|
450
|
+
const defaultSandboxTeardown = (P) => async (provider, handle) => (SANDBOX_TEARDOWN[provider] ? SANDBOX_TEARDOWN[provider](P, handle) : false);
|
|
451
|
+
|
|
452
|
+
/** Reclaim every owned item marked "reclaim"; release ledger entries that were reclaimed. Never touches anything else. */
|
|
453
|
+
export async function applyGc(plan, probes) {
|
|
454
|
+
const P = probes, fs = P.fs ?? nodeFs;
|
|
455
|
+
const reclaimed = [], failed = [];
|
|
456
|
+
const git = (args) => P.git(args, { cwd: P.cwd });
|
|
457
|
+
let prune = false;
|
|
458
|
+
for (const it of plan.items) {
|
|
459
|
+
if (!(it.owned === true && it.action === "reclaim")) continue;
|
|
460
|
+
try {
|
|
461
|
+
switch (it.type) {
|
|
462
|
+
case "container": await P.docker(["rm", "-f", it.id]); break;
|
|
463
|
+
case "volume": await P.docker(["volume", "rm", it.id]); break;
|
|
464
|
+
case "sandbox": if (!(await (P.sandboxTeardown ?? defaultSandboxTeardown(P))(it.provider, it.handle))) throw new Error("sandbox teardown failed"); break;
|
|
465
|
+
case "process": if (P.alive(Number(it.id)) && !(await P.killPid(Number(it.id), it.marker))) throw new Error("kill refused"); break;
|
|
466
|
+
case "tmp":
|
|
467
|
+
if (!isInside(P.forgeTmpRoot, it.id)) throw new Error("outside ForgeDock tmp");
|
|
468
|
+
if (it.runDir) { if (!(await makeRunTmp({ root: dirname(it.id), host: P.host, alive: P.alive }).remove(it.id))) throw new Error("run marker missing"); }
|
|
469
|
+
else fs.rmSync(it.id, { recursive: true, force: true });
|
|
470
|
+
break;
|
|
471
|
+
case "depcache":
|
|
472
|
+
if (!(await P.depCache?.remove?.(it.entry))) throw new Error("locked or outside the dependency cache");
|
|
473
|
+
break;
|
|
474
|
+
case "worktree":
|
|
475
|
+
if (it.registered) await git(["worktree", "remove", "--force", it.id]);
|
|
476
|
+
else if (fs.existsSync(it.id)) {
|
|
477
|
+
if (!isDirectChild(P.worktreeRoot, it.id)) throw new Error("not a direct child of the worktree root");
|
|
478
|
+
fs.rmSync(it.id, { recursive: true, force: true });
|
|
479
|
+
}
|
|
480
|
+
prune = true;
|
|
481
|
+
break;
|
|
482
|
+
case "branch":
|
|
483
|
+
if (it.gone) break;
|
|
484
|
+
if (it.local) await git(["branch", "-D", it.branch]);
|
|
485
|
+
else await git(["push", "origin", "--delete", it.branch]);
|
|
486
|
+
break;
|
|
487
|
+
default: throw new Error(`unknown type ${it.type}`);
|
|
488
|
+
}
|
|
489
|
+
reclaimed.push(it);
|
|
490
|
+
if (it.ledger) {
|
|
491
|
+
const l = plan.ledgers.find((x) => x.path === it.ledger);
|
|
492
|
+
if (l) { const led = makeLedger({ root: dirname(dirname(l.path)), batch: l.batch, lane: l.lane, fs }); if (led.path === l.path) led.release(it.kind, it.value); }
|
|
493
|
+
}
|
|
494
|
+
} catch (e) { failed.push({ ...it, error: errText(e) }); }
|
|
495
|
+
}
|
|
496
|
+
if (prune) { try { await git(["worktree", "prune"]); } catch { /* best effort */ } }
|
|
497
|
+
for (const l of plan.ledgers.filter((x) => x.stale)) {
|
|
498
|
+
const led = makeLedger({ root: dirname(dirname(l.path)), batch: l.batch, lane: l.lane, fs });
|
|
499
|
+
if (led.path === l.path && led.read() && led.entries().length === 0) led.remove();
|
|
500
|
+
}
|
|
501
|
+
return { reclaimed, failed };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const fmtSize = (b) => (b == null ? "?" : b >= 1073741824 ? `${(b / 1073741824).toFixed(1)}G` : b >= 1048576 ? `${(b / 1048576).toFixed(1)}M` : b >= 1024 ? `${Math.round(b / 1024)}K` : `${b}B`);
|
|
505
|
+
const fmtAge = (ms) => (ms == null ? "?" : ms >= 86400e3 ? `${Math.floor(ms / 86400e3)}d` : ms >= 3600e3 ? `${Math.floor(ms / 3600e3)}h` : `${Math.floor(ms / 60e3)}m`);
|
|
506
|
+
|
|
507
|
+
export function formatGcReport(plan, { applied = null } = {}) {
|
|
508
|
+
const L = [];
|
|
509
|
+
const ttlText = plan.ttlMs ? `TTL docker ${fmtAge(plan.ttlMs.container)}, worktrees ${fmtAge(plan.ttlMs.worktree)}, tmp ${fmtAge(plan.ttlMs.tmp)}` : `older than ${fmtAge(plan.olderThanMs)}`;
|
|
510
|
+
L.push(`forgedock gc ${applied ? "--apply" : "(dry-run)"}: ${ttlText}, scopes ${plan.scopes.join(",")}`);
|
|
511
|
+
L.push(` ${"type".padEnd(10)} ${"count".padStart(5)} ${"size".padStart(7)} ${"oldest".padStart(6)} ${"reclaimable".padStart(11)} ${"not owned".padStart(9)}`);
|
|
512
|
+
for (const t of GC_TYPES) {
|
|
513
|
+
if (!plan.scopes.includes(SCOPE_OF[t])) continue;
|
|
514
|
+
const s = plan.summary[t];
|
|
515
|
+
L.push(` ${t.padEnd(10)} ${String(s.count).padStart(5)} ${fmtSize(s.sizeBytes).padStart(7)} ${fmtAge(s.oldestMs).padStart(6)} ${String(s.reclaimable).padStart(11)} ${String(s.notOwned).padStart(9)}`);
|
|
516
|
+
}
|
|
517
|
+
for (const it of plan.items) {
|
|
518
|
+
const tag = !it.owned ? "not owned" : it.action === "reclaim" ? (applied ? (applied.failed.some((f) => f.type === it.type && f.id === it.id) ? "FAILED" : "reclaimed") : "reclaim") : "report";
|
|
519
|
+
L.push(` ${tag.padEnd(9)} ${it.type.padEnd(9)} ${fmtSize(it.sizeBytes).padStart(7)} ${fmtAge(it.ageMs).padStart(5)} ${it.id} (${it.reason})`);
|
|
520
|
+
}
|
|
521
|
+
for (const n of plan.notes) L.push(` note: ${n}`);
|
|
522
|
+
for (const f of applied?.failed ?? []) L.push(` failed: ${f.type} ${f.id}: ${f.error}`);
|
|
523
|
+
const n = plan.items.filter((i) => i.owned && i.action === "reclaim").length;
|
|
524
|
+
L.push(applied ? `gc: reclaimed ${applied.reclaimed.length} of ${n} item(s)` : `gc: ${n} item(s) reclaimable; run with --apply to reclaim`);
|
|
525
|
+
return L.join("\n") + "\n";
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** CLI entry: returns the exit code (0 ok, 1 apply failures, 2 bad flags). */
|
|
529
|
+
export async function runGc(argv = [], { probes, hygiene = null, now = Date.now(), out = (s) => process.stdout.write(s), err = (s) => process.stderr.write(s) } = {}) {
|
|
530
|
+
let opts;
|
|
531
|
+
try { opts = parseGcArgs(argv); } catch (e) { err(`gc: ${e.message}\n`); return 2; }
|
|
532
|
+
const P = probes ?? makeGcProbes();
|
|
533
|
+
const h = hygiene ?? readHygiene(P.cwd ?? process.cwd(), { fs: P.fs ?? nodeFs });
|
|
534
|
+
// An explicit --older-than overrides every forge.yaml hygiene TTL.
|
|
535
|
+
const plan = await planGc({ probes: P, scopes: opts.scopes, olderThanMs: opts.olderThanMs, ...(opts.olderThanGiven ? {} : { ttlMs: hygieneTtls(h) }), deleteMergedBranches: h.delete_merged_branches, now });
|
|
536
|
+
if (!opts.apply) { out(formatGcReport(plan)); return 0; }
|
|
537
|
+
const applied = await applyGc(plan, P);
|
|
538
|
+
out(formatGcReport(plan, { applied }));
|
|
539
|
+
return applied.failed.length ? 1 : 0;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Plan + apply with forge.yaml hygiene settings (orchestrate preflight/admission GC, #4183). Never throws: a failure
|
|
544
|
+
* becomes a note. Returns {plan, applied, reclaimed, bytes}.
|
|
545
|
+
*/
|
|
546
|
+
export async function runOwnedGc({ probes, hygiene = HYGIENE_DEFAULTS, scopes = new Set(GC_SCOPES), now = Date.now() } = {}) {
|
|
547
|
+
let plan = null, applied = { reclaimed: [], failed: [] };
|
|
548
|
+
try {
|
|
549
|
+
plan = await planGc({ probes, scopes, ttlMs: hygieneTtls(hygiene), deleteMergedBranches: hygiene.delete_merged_branches !== false, now });
|
|
550
|
+
applied = await applyGc(plan, probes);
|
|
551
|
+
} catch (e) {
|
|
552
|
+
plan = plan ?? { items: [], summary: {}, notes: [], ledgers: [], scopes: [...scopes], olderThanMs: LEDGER_TTL_MS };
|
|
553
|
+
plan.notes.push(`gc failed: ${errText(e)}`);
|
|
554
|
+
}
|
|
555
|
+
return { plan, applied, reclaimed: applied.reclaimed.length, bytes: applied.reclaimed.reduce((s, i) => s + (i.sizeBytes ?? 0), 0) };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* doctor's "Resource hygiene" line (#4183): owned counts/sizes per type, reclaimable count, and unowned items older than
|
|
560
|
+
* their type TTL (reported only, never touched). warn when something is reclaimable (and not applied) or unowned-stale.
|
|
561
|
+
*/
|
|
562
|
+
export function hygieneCheck(plan, { applied = null, ttlMs = plan?.ttlMs ?? hygieneTtls() } = {}) {
|
|
563
|
+
const items = plan?.items ?? [];
|
|
564
|
+
const parts = [];
|
|
565
|
+
for (const t of GC_TYPES) {
|
|
566
|
+
const owned = items.filter((i) => i.type === t && i.owned);
|
|
567
|
+
if (owned.length) parts.push(`${owned.length} ${t}${owned.length === 1 ? "" : "s"} ${fmtSize(owned.reduce((s, i) => s + (i.sizeBytes ?? 0), 0))}`);
|
|
568
|
+
}
|
|
569
|
+
const reclaimable = items.filter((i) => i.owned && i.action === "reclaim").length;
|
|
570
|
+
const stale = items.filter((i) => !i.owned && i.ageMs != null && i.ageMs >= (ttlMs?.[i.type] ?? LEDGER_TTL_MS)).length;
|
|
571
|
+
const bits = [`owned: ${parts.length ? parts.join(", ") : "none"}`];
|
|
572
|
+
if (applied) bits.push(`reclaimed ${applied.reclaimed.length} of ${reclaimable}${applied.failed.length ? ` (${applied.failed.length} failed)` : ""}`);
|
|
573
|
+
else bits.push(`${reclaimable} reclaimable`);
|
|
574
|
+
if (stale) bits.push(`${stale} unowned stale (report only)`);
|
|
575
|
+
const pending = applied ? applied.failed.length : reclaimable;
|
|
576
|
+
const status = pending > 0 || stale > 0 ? "warn" : "pass";
|
|
577
|
+
let detail = bits.join("; ");
|
|
578
|
+
if (!applied && reclaimable > 0) detail += ". Run: npx forgedock doctor --fix (or: npx forgedock gc --apply)";
|
|
579
|
+
return { status, detail, reclaimable, stale };
|
|
580
|
+
}
|