From 6b833d03415292273bfc4d628cf53ba41518af15 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 00:22:59 +0000 Subject: [PATCH 01/12] feat: gstack-clean reclaims per-repo .gstack scratch and logs, dry run by default (#2627) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- BROWSER.md | 12 + TODOS.md | 5 + bin/gstack-clean | 12 + changelog.d/capy-gsta226-e2-gstack-clean.md | 10 + docs/troubleshooting.md | 24 ++ lib/repo-state-clean.ts | 365 ++++++++++++++++ lib/result-codes-clean.ts | 30 ++ lib/result-codes.ts | 2 + test/fixtures/module-size-ratchet.json | 4 +- test/gstack-clean-producers.test.ts | 104 +++++ test/gstack-clean.test.ts | 456 ++++++++++++++++++++ 11 files changed, 1023 insertions(+), 1 deletion(-) create mode 100755 bin/gstack-clean create mode 100644 changelog.d/capy-gsta226-e2-gstack-clean.md create mode 100644 lib/repo-state-clean.ts create mode 100644 lib/result-codes-clean.ts create mode 100644 test/gstack-clean-producers.test.ts create mode 100644 test/gstack-clean.test.ts diff --git a/BROWSER.md b/BROWSER.md index 13aa70909..704ce6391 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -1515,6 +1515,18 @@ capacity each), flushed to disk asynchronously via `Bun.write()`: - Network: `.gstack/browse-network.log` - Dialog: `.gstack/browse-dialog.log` +The browse server appends to these logs (and to `browse-daemon.log`, +`browse-startup-error.log` and the `browse-audit.jsonl` command audit) for +the life of the project, and codex scratch files collect in `.gstack/tmp/`. +Run `gstack-clean` in the repository to see what it would reclaim, then +`gstack-clean --apply` to do it: it cuts these logs to their last 256 KB +once they pass 1 MB, removes `tmp/codex-*` files older than 7 days, and +trims the audit log only when you pass `--audit-days N`. It never touches +`browse.json`, `session-state.json`, `chromium-profile/`, `browser-skills/` +or `browse-states/`, and it skips the browse files while the daemon runs. +Deleting the whole `.gstack/` folder instead also deletes your cookies and +browser skills. + The `console`, `network`, and `dialog` commands read from the in-memory buffers (not disk) so capture is real-time even when disk is slow. diff --git a/TODOS.md b/TODOS.md index 89b7d0396..42136bc71 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2,6 +2,11 @@ ## NEXT PRIORITY +### P3: gstack-clean follow-ups (filed 2026-10-31, GSTA-226 E2, #2627) + +- **Codex templates delete their own scratch.** The `/codex` review, challenge and consult blocks leave `codex-err-*`, `codex-resp-*`, `codex-out-*` and `codex-prompt-*` mktemp files under `TMP_ROOT` (which falls back to `.gstack/tmp`) and the `codex-prompt.*` and `codex-focus.*` free-text files in `.gstack/tmp`; `gstack-clean` removes them after 7 days. Delete each file at the end of its block instead. Skill prose for every user, so it needs its own review. **Effort:** S. **Priority:** P3. +- **Browse hints `gstack-clean` when `.gstack/tmp` is large.** Print one line at daemon start when `.gstack/tmp` or the browse logs pass a size threshold, naming `gstack-clean`. **Effort:** S. **Priority:** P3. + ### P3: GST-421 and GST-412 follow-ups (filed 2026-10-31, from the PR B /ship review) - **Check the Approvals table's own cells.** `--check-prose` skips the whole rendered Approvals block, including the Asked, Covers and Still pending cells the lane types. Narrow the skip to the Exact words column. **Priority:** P3. diff --git a/bin/gstack-clean b/bin/gstack-clean new file mode 100755 index 000000000..d5c363986 --- /dev/null +++ b/bin/gstack-clean @@ -0,0 +1,12 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * gstack-clean — reclaim throwaway files in this repository's .gstack folder + * (#2627): old codex scratch in tmp/, oversized browse logs cut in place, and + * with --audit-days N old browse audit entries. Dry run unless --apply; + * `gstack-clean --help` lists the fixed thresholds. The rules live in + * lib/repo-state-clean.ts; error codes GSTACK_CLEAN_* are documented in + * docs/troubleshooting.md. + */ +import { main } from '../lib/repo-state-clean'; + +process.exit(main(process.argv.slice(2))); diff --git a/changelog.d/capy-gsta226-e2-gstack-clean.md b/changelog.d/capy-gsta226-e2-gstack-clean.md new file mode 100644 index 000000000..91f4b993b --- /dev/null +++ b/changelog.d/capy-gsta226-e2-gstack-clean.md @@ -0,0 +1,10 @@ +**`gstack-clean` reclaims the throwaway files that pile up in a repository's `.gstack/` folder, and changes nothing unless you pass `--apply`** (#2627). Until now nothing removed old codex scratch files or trimmed the browse logs, so the folder only grew (the reporter measured 8.3 MB in six weeks), and the only way out was deleting the whole folder, which also deletes your cookies, Chromium profile and browser skills. + +### Added +- **`bin/gstack-clean`** (rules in `lib/repo-state-clean.ts`): cleans `/.gstack` only. It removes top-level `tmp/codex-*` files older than 7 days, cuts `browse-console.log`, `browse-network.log`, `browse-dialog.log`, `browse-daemon.log` and `browse-startup-error.log` to their last 256 KB once they pass 1 MB (in place, starting after a line break, so a running writer keeps appending), and with `--audit-days N` drops `browse-audit.jsonl` entries older than N days while keeping every line without a readable timestamp. Everything else, including `browse.json`, `session-state.json`, `chromium-profile/`, `browser-skills/`, `browse-states/` and the other `tmp/` entries, is never touched. +- **Safety:** it refuses (exit 3) outside git, when `.gstack` is a symlink, when `BROWSE_STATE_FILE` names another folder, and when the folder is, holds or sits inside the gstack state root. It skips symlinks and files changed in the last hour, skips browse files while the browse daemon runs and `tmp/` while codex runs, and skips a group when it cannot tell. `--json` prints the same result as one versioned object. +- Result codes `GSTACK_CLEAN_USAGE`, `GSTACK_CLEAN_TARGET_REFUSED`, `GSTACK_CLEAN_PARTIAL` and `GSTACK_CLEAN_INTERNAL`, each with a docs/troubleshooting.md entry; BROWSER.md names the command where it lists the log files. + +### Tests +- `test/gstack-clean.test.ts`: the dry run changes no byte or mtime, `--apply` removes exactly the allowlist, protected files survive at any age, every refusal touches nothing, links out of the folder and a symlinked `tmp/` are left alone, live and unknown liveness skip their group, the cut keeps the inode and a line boundary, audit trimming keeps the right lines byte for byte, exit codes 0, 1, 2 and 3, and the `--json` shape. +- `test/gstack-clean-producers.test.ts`: every tracked producer of a `.gstack/tmp/` entry is classified as scratch or kept, and any new `codex-*` name must be added to the scratch list on purpose. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9cf9db55c..42de15501 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5818,3 +5818,27 @@ These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modul ### `RENDER_DEST_NOT_OWNED: exists and holds no loadout.lock.json` **Meaning.** `--out-dir` is a non-empty directory that an earlier `gstack-modules render` did not write, so replacing it could delete someone's files. **Fix.** Pass a new or empty directory, or the directory of an earlier render. + +## Per-repo cleanup (gstack-clean) + +These codes come from `bin/gstack-clean`, which reclaims old codex scratch in `/.gstack/tmp/`, browse logs over 1 MB and, with `--audit-days N`, old browse audit entries. It changes nothing unless you pass `--apply`, and `gstack-clean --help` lists what it touches. + + +### `gstack-clean: an unknown flag or a bad --audit-days value … (GSTACK_CLEAN_USAGE)` + +**Meaning.** A flag is not one of `--apply`, `--audit-days N`, `--json` or `--help`, or `--audit-days` got something other than a whole number of days from 1 to 99999. The line quotes the bad value and the last line is the usage line. Exit 2; nothing was read or changed. **Fix.** `gstack-clean --audit-days 30`, for example. + + +### `gstack-clean: the folder to clean is not a safe per-repo .gstack folder … (GSTACK_CLEAN_TARGET_REFUSED)` + +**Meaning.** gstack-clean only cleans `/.gstack` for the directory you run it in. It refuses outside a git checkout, when `.gstack` is a symlink, when `BROWSE_STATE_FILE` points at a different folder's `browse.json`, and when the folder is, holds or sits inside the gstack state root (`~/.gstack` by default), where your learnings and settings live. Exit 3; nothing was touched. **Fix.** Run it from inside the repository, unset `BROWSE_STATE_FILE` (or point it at this repository's `.gstack/browse.json`), and replace a symlinked `.gstack` with a real folder. + + +### `FAILED ` … `(GSTACK_CLEAN_PARTIAL)` + +**Meaning.** One or more removals or cuts failed, usually `EACCES` or `EPERM` (a read-only file or folder) or `EBUSY` (another program holds the file). Every other action finished. Exit 1. **Fix.** Rerunning is safe: fix the permissions on each FAILED path, or wait for the program holding it to finish, then run `gstack-clean --apply` again. + + +### `gstack-clean: unexpected gstack-clean failure … (GSTACK_CLEAN_INTERNAL)` + +**Meaning.** gstack-clean hit an error it does not expect; the line names it. Exit 1. Changes made before the error stay made, and each was checked right before it was made. **Fix.** Rerun once; if it repeats, report the printed error to the gstack maintainers. diff --git a/lib/repo-state-clean.ts b/lib/repo-state-clean.ts new file mode 100644 index 000000000..200906a31 --- /dev/null +++ b/lib/repo-state-clean.ts @@ -0,0 +1,365 @@ +/** + * repo-state-clean — gstack-clean's plan and apply (#2627; plan GSTA-226 D8, + * D9). Reclaims a fixed allowlist of byproducts in one repository's + * `/.gstack`, and nothing else: + * + * tmp/codex-* top-level regular files older than 7 days + * browse-*.log over 1 MB, cut in place to the last 256 KB from a + * line start (same inode, so O_APPEND writers go on) + * browse-audit.jsonl only with --audit-days N: entries older than N days + * + * browse.json, session-state.json, chromium-profile/, browser-skills/, + * browse-states/ and every other tmp/ entry (restamp journal, gate-ahead, + * pregate.json, ship receipt, autoplan/, ship-queue/, eval-bisect/) are never + * touched; test/gstack-clean-producers.test.ts classifies every tmp producer. + * Dry run unless --apply. Each change re-checks the file and its group's + * liveness first (live browse daemon, running codex, a write in the last + * hour); an unknown answer skips the group with its reason. + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { resolveStateRoot } from './state-root'; +import { resultLine } from './result-codes'; + +export const CLEAN_SCHEMA_VERSION = 1; +const DAY_MS = 24 * 60 * 60 * 1000; +export const SCRATCH_PREFIX = 'codex-'; +export const SCRATCH_MIN_AGE_MS = 7 * DAY_MS; +export const RECENT_MS = 60 * 60 * 1000; +export const LOG_CUT_OVER_BYTES = 1024 * 1024; +export const LOG_KEEP_BYTES = 256 * 1024; +export const BROWSE_LOGS = ['browse-console.log', 'browse-network.log', 'browse-dialog.log', 'browse-daemon.log', 'browse-startup-error.log'] as const; +export const AUDIT_LOG = 'browse-audit.jsonl'; +export const USAGE = 'usage: gstack-clean [--apply] [--audit-days N] [--json]'; + +export const HELP = `gstack-clean — reclaim throwaway files in this repository's .gstack folder + +${USAGE} + +Reports what it would do and changes nothing unless you pass --apply. +Target: /.gstack of the current directory. Refuses (exit 3) +outside git, when .gstack is a symlink, when BROWSE_STATE_FILE names another +folder, and when the folder is, holds or sits inside the gstack state root. + +What it cleans (fixed thresholds): + tmp/codex-* codex scratch files older than 7 days: removed + browse-console.log, browse-network.log, browse-dialog.log, + browse-daemon.log, browse-startup-error.log + over 1 MB: cut in place to the last 256 KB, + starting after a line break + browse-audit.jsonl only with --audit-days N: entries older than N days + removed; lines without a readable ts are kept + +Everything else (browse.json, session-state.json, chromium-profile/, +browser-skills/, browse-states/, and every other tmp/ entry) is never touched. +Files changed in the last hour are skipped, browse files are skipped while the +browse daemon runs, tmp/ is skipped while codex runs, and a group is skipped +when that cannot be told. + +Options: + --apply make the changes + --audit-days N also trim browse-audit.jsonl to the last N days (N >= 1) + --json print one versioned JSON object instead of text + +Exit codes: 0 done or nothing to do, 1 an action failed (rerunning is safe), +2 usage, 3 target refused. +`; + +type Group = 'tmp' | 'browse'; +type Kind = 'remove' | 'cut' | 'rotate'; +export interface CleanOptions { apply: boolean; json: boolean; auditDays: number | null } +interface Candidate { file: string; group: Group; kind: Kind } +interface Action extends Candidate { bytes: number; reason: string; keep?: Buffer } +interface Skip { file: string; group: Group; reason: string } +interface Done { action: Action; errno?: string } +export interface CleanTarget { target: string; chosenBy: string } +interface Io { out: (line: string) => void; err: (line: string) => void } + +class Refused extends Error {} +class UsageError extends Error {} + +export function parseCleanArgs(argv: string[]): CleanOptions | 'help' { + const opts: CleanOptions = { apply: false, json: false, auditDays: null }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + if (arg === '--help' || arg === '-h') return 'help'; + if (arg === '--apply') { opts.apply = true; continue; } + if (arg === '--json') { opts.json = true; continue; } + if (arg === '--audit-days' || arg.startsWith('--audit-days=')) { + const value = arg === '--audit-days' ? argv[++i] : arg.slice('--audit-days='.length); + if (value === undefined || !/^[1-9]\d{0,4}$/.test(value)) throw new UsageError(`--audit-days needs a whole number of days from 1 to 99999; got ${JSON.stringify(value ?? '')}`); + opts.auditDays = Number(value); + continue; + } + throw new UsageError(`unknown flag ${JSON.stringify(arg)}`); + } + return opts; +} + +/** realpath when the path exists, else its nearest existing parent's realpath plus the rest. */ +function canonical(p: string): string { + const abs = path.resolve(p); + try { return fs.realpathSync(abs); } catch { /* missing: resolve the parent */ } + const parent = path.dirname(abs); + return parent === abs ? abs : path.join(canonical(parent), path.basename(abs)); +} + +const within = (child: string, parent: string) => child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep); + +/** The verified target, or the missing path when .gstack does not exist. Throws Refused. */ +export function resolveCleanTarget(env: NodeJS.ProcessEnv, cwd: string): CleanTarget | { missing: string } { + const git = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', timeout: 10_000, windowsHide: true }); + const top = git.status === 0 ? git.stdout.trim() : ''; + if (!top) throw new Refused(`${cwd} is not inside a git work tree`); + const toplevel = fs.realpathSync(top); + const candidate = path.join(toplevel, '.gstack'); + if (env.BROWSE_STATE_FILE && canonical(path.resolve(cwd, env.BROWSE_STATE_FILE)) !== canonical(path.join(candidate, 'browse.json'))) { + throw new Refused(`BROWSE_STATE_FILE is ${env.BROWSE_STATE_FILE}, not ${path.join(candidate, 'browse.json')}`); + } + let st: fs.Stats; + try { st = fs.lstatSync(candidate); } catch (e: any) { + if (e?.code === 'ENOENT') return { missing: candidate }; + throw e; + } + if (st.isSymbolicLink()) throw new Refused(`${candidate} is a symlink`); + if (!st.isDirectory()) throw new Refused(`${candidate} is not a directory`); + const target = fs.realpathSync(candidate); + const stateRoot = canonical(path.resolve(cwd, resolveStateRoot(env))); + if (within(target, stateRoot) || within(stateRoot, target)) { + throw new Refused(`${target} is, holds or sits inside the gstack state root ${stateRoot}`); + } + const from = canonical(cwd) === toplevel ? '' : `, found from ${cwd}`; + return { target, chosenBy: `the .gstack folder at the git toplevel ${toplevel}${from}` }; +} + +function pidAlive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch (e: any) { return e?.code !== 'ESRCH'; } +} + +/** Why browse files must wait, or null when no browse daemon can be using them. */ +function browseBusy(target: string): string | null { + if (process.platform === 'win32') return 'cannot tell whether the browse daemon is running on Windows'; + for (const name of ['browse.json', 'browse.json.lock']) { + let text: string; + try { text = fs.readFileSync(path.join(target, name), 'utf8'); } catch (e: any) { + if (e?.code === 'ENOENT') continue; + return `cannot read ${name} (${e?.code ?? 'error'}) to tell whether the browse daemon is running`; + } + let pid: unknown; + try { pid = name === 'browse.json' ? JSON.parse(text)?.pid : Number(text.trim()); } catch { pid = undefined; } + if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return `${name} names no pid, so the browse daemon may be running`; + if (pidAlive(pid)) return `the browse daemon (pid ${pid}, ${name}) is running`; + } + return null; +} + +/** Why tmp/ must wait, or null when no codex process runs. */ +function codexBusy(): string | null { + if (process.platform === 'win32') return 'cannot tell whether codex is running on Windows'; + const ps = spawnSync('ps', ['-A', '-o', 'args='], { encoding: 'utf8', timeout: 10_000, windowsHide: true }); + if (ps.error || ps.status !== 0) return 'cannot list processes to tell whether codex is running'; + for (const line of ps.stdout.split('\n')) { + const words = line.trim().split(/\s+/).slice(0, 2); + if (words.some(w => /^codex(?:\.js|-[^.]*)?$/.test(path.basename(w)))) return `codex is running (${line.trim().slice(0, 80)})`; + } + return null; +} + +/** Audit lines to keep (newer than the cutoff, or without a readable ts), or why the file is malformed. */ +function auditKeep(buf: Buffer, cutoffMs: number): Buffer | string { + const kept: string[] = []; + for (const [i, line] of buf.toString('utf8').split(/(?<=\n)/).entries()) { + const body = line.replace(/\r?\n$/, ''); + if (body.trim() === '') { kept.push(line); continue; } + let entry: unknown; + try { entry = JSON.parse(body); } catch { return `line ${i + 1} is not JSON, so the file was left as it is`; } + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return `line ${i + 1} is not a JSON object, so the file was left as it is`; + const ts = Date.parse(String((entry as { ts?: unknown }).ts ?? '')); + if (Number.isNaN(ts) || ts >= cutoffMs) kept.push(line); + } + return Buffer.from(kept.join(''), 'utf8'); +} + +function readTail(file: string, size: number): Buffer { + const fd = fs.openSync(file, 'r'); + try { + const length = Math.min(size, LOG_KEEP_BYTES); + const buf = Buffer.alloc(length); + return buf.subarray(0, fs.readSync(fd, buf, 0, length, size - length)); + } finally { fs.closeSync(fd); } +} + +/** + * One file's action, a skip, or null when the file is not a target. `busy` is + * the group's liveness answer; applyClean asks it again right before a change. + */ +function evaluate(c: Candidate, dir: string, busy: string | null, opts: CleanOptions, now: number): Action | Skip | null { + let st: fs.Stats; + try { st = fs.lstatSync(c.file); } catch (e: any) { + return e?.code === 'ENOENT' ? null : { ...c, reason: `cannot stat (${e?.code ?? 'error'})` }; + } + if (st.isSymbolicLink()) return { ...c, reason: 'is a symlink; left alone' }; + if (!st.isFile()) return c.kind === 'remove' ? { ...c, reason: 'is not a regular file; left alone' } : null; + if (path.dirname(fs.realpathSync(c.file)) !== dir) return { ...c, reason: 'resolves outside the folder being cleaned; left alone' }; + if (c.kind === 'remove' && now - st.mtimeMs < SCRATCH_MIN_AGE_MS) return null; + if (c.kind === 'cut' && st.size <= LOG_CUT_OVER_BYTES) return null; + if (now - st.mtimeMs < RECENT_MS) return { ...c, reason: 'modified within the last hour' }; + if (busy) return { ...c, reason: busy }; + if (c.kind === 'remove') return { ...c, bytes: st.size, reason: 'codex scratch older than 7 days' }; + if (c.kind === 'cut') { + const tail = readTail(c.file, st.size); + const at = tail.indexOf(0x0a); + if (at < 0) return { ...c, reason: 'no line break in its last 256 KB; left alone' }; + const keep = tail.subarray(at + 1); + return { ...c, keep, bytes: st.size - keep.length, reason: 'over 1 MB; keeps the last 256 KB from a line start' }; + } + const keep = auditKeep(fs.readFileSync(c.file), now - opts.auditDays! * DAY_MS); + if (typeof keep === 'string') return { ...c, reason: keep }; + if (keep.length === st.size) return null; + return { ...c, keep, bytes: st.size - keep.length, reason: `audit entries older than ${opts.auditDays} days` }; +} + +function candidates(target: string, opts: CleanOptions): { list: Candidate[]; skips: Skip[] } { + const list: Candidate[] = []; + const skips: Skip[] = []; + const tmp = path.join(target, 'tmp'); + let tmpStat: fs.Stats | null = null; + try { tmpStat = fs.lstatSync(tmp); } catch (e: any) { + if (e?.code !== 'ENOENT') skips.push({ file: tmp, group: 'tmp', reason: `cannot stat (${e?.code ?? 'error'}); the tmp group is skipped` }); + } + if (tmpStat?.isSymbolicLink()) skips.push({ file: tmp, group: 'tmp', reason: 'tmp/ is a symlink; the tmp group is refused' }); + else if (tmpStat && !tmpStat.isDirectory()) skips.push({ file: tmp, group: 'tmp', reason: 'tmp is not a directory; the tmp group is skipped' }); + else if (tmpStat) { + for (const name of fs.readdirSync(tmp).sort()) if (name.startsWith(SCRATCH_PREFIX)) list.push({ file: path.join(tmp, name), group: 'tmp', kind: 'remove' }); + } + for (const name of BROWSE_LOGS) list.push({ file: path.join(target, name), group: 'browse', kind: 'cut' }); + if (opts.auditDays !== null) list.push({ file: path.join(target, AUDIT_LOG), group: 'browse', kind: 'rotate' }); + return { list, skips }; +} + +const dirOf = (c: Candidate, target: string) => (c.group === 'tmp' ? path.join(target, 'tmp') : target); +const busyFor = (group: Group, target: string) => (group === 'tmp' ? codexBusy() : browseBusy(target)); + +export function planClean(target: string, opts: CleanOptions, now = Date.now()): { actions: Action[]; skips: Skip[] } { + const { list, skips } = candidates(target, opts); + const busy: Record = { + tmp: list.some(c => c.group === 'tmp') ? busyFor('tmp', target) : null, + browse: busyFor('browse', target), + }; + const actions: Action[] = []; + for (const c of list) { + const result = evaluate(c, dirOf(c, target), busy[c.group], opts, now); + if (result && 'bytes' in result) actions.push(result); + else if (result) skips.push(result); + } + return { actions, skips }; +} + +function rewriteInPlace(file: string, keep: Buffer): void { + const fd = fs.openSync(file, 'r+'); + try { + fs.writeSync(fd, keep, 0, keep.length, 0); + fs.ftruncateSync(fd, keep.length); + } finally { fs.closeSync(fd); } +} + +/** Re-checks each planned action against its file and its group's liveness right before the change. */ +export function applyClean(target: string, planned: Action[], opts: CleanOptions): { done: Done[]; skips: Skip[] } { + const done: Done[] = []; + const skips: Skip[] = []; + for (const p of planned) { + const fresh = evaluate({ file: p.file, group: p.group, kind: p.kind }, dirOf(p, target), busyFor(p.group, target), opts, Date.now()); + if (!fresh) continue; + if (!('bytes' in fresh)) { skips.push(fresh); continue; } + try { + if (fresh.kind === 'remove') fs.unlinkSync(fresh.file); + else rewriteInPlace(fresh.file, fresh.keep!); + done.push({ action: fresh }); + } catch (e: any) { + done.push({ action: fresh, errno: e?.code ?? 'UNKNOWN' }); + } + } + return { done, skips }; +} + +const VERB: Record = { remove: ['would remove', 'removed'], cut: ['would cut', 'cut'], rotate: ['would trim', 'trimmed'] }; + +function report(io: Io, opts: CleanOptions, t: CleanTarget, rows: Done[], skips: Skip[]): number { + const rel = (file: string) => path.relative(t.target, file) || '.'; + const failed = rows.filter(r => r.errno); + const ok = rows.filter(r => !r.errno); + const bytes = ok.reduce((n, r) => n + r.action.bytes, 0); + const exit = failed.length ? 1 : 0; + const partial = resultLine('gstack-clean', 'GSTACK_CLEAN_PARTIAL', `${failed.length} of ${rows.length} actions failed`); + if (opts.json) { + io.out(JSON.stringify({ + schema_version: CLEAN_SCHEMA_VERSION, tool: 'gstack-clean', mode: opts.apply ? 'apply' : 'dry-run', + status: failed.length ? 'partial' : rows.length === 0 ? 'nothing_to_do' : opts.apply ? 'done' : 'planned', + target: t.target, chosen_by: t.chosenBy, audit_days: opts.auditDays, + actions: rows.map(({ action: a, errno }) => ({ + path: a.file, rel: rel(a.file), group: a.group, action: a.kind, bytes: a.bytes, reason: a.reason, + status: errno ? 'failed' : opts.apply ? 'done' : 'planned', ...(errno ? { errno } : {}), + })), + skips: skips.map(s => ({ path: s.file, rel: rel(s.file), group: s.group, reason: s.reason })), + total: { actions: ok.length, bytes, failed: failed.length, skipped: skips.length }, + error: failed.length ? { code: 'GSTACK_CLEAN_PARTIAL', message: partial } : null, + exit_code: exit, + }, null, 2)); + if (failed.length) io.err(partial); + return exit; + } + io.out(`gstack-clean: target ${t.target} (${t.chosenBy})`); + for (const { action: a, errno } of rows) { + if (errno) { io.out(`FAILED ${a.file} ${errno}`); continue; } + const size = a.kind === 'remove' ? `${a.bytes} bytes` : `${opts.apply ? 'freed' : 'freeing'} ${a.bytes} bytes`; + io.out(`${VERB[a.kind][opts.apply ? 1 : 0].padEnd(12)} ${rel(a.file)} (${size}): ${a.reason}`); + } + for (const s of skips) io.out(`${'skip'.padEnd(12)} ${rel(s.file)}: ${s.reason}`); + const failNote = failed.length ? `; ${failed.length} failed` : ''; + io.out(`total: ${ok.length} action${ok.length === 1 ? '' : 's'} ${opts.apply ? 'freed' : 'would free'} ${bytes} bytes${failNote}; ${skips.length} skipped`); + if (failed.length) io.err(partial); + else if (!opts.apply) io.out(rows.length ? `nothing changed; to do this: gstack-clean --apply${opts.auditDays === null ? '' : ` --audit-days ${opts.auditDays}`}` : 'nothing changed; nothing to clean'); + return exit; +} + +function run(argv: string[], env: NodeJS.ProcessEnv, cwd: string, io: Io): number { + let parsed: CleanOptions | 'help'; + try { parsed = parseCleanArgs(argv); } catch (e) { + if (!(e instanceof UsageError)) throw e; + io.err(resultLine('gstack-clean', 'GSTACK_CLEAN_USAGE', e.message)); + io.err(USAGE); + return 2; + } + if (parsed === 'help') { io.out(HELP.trimEnd()); return 0; } + const opts = parsed; + let resolved: CleanTarget | { missing: string }; + try { resolved = resolveCleanTarget(env, cwd); } catch (e) { + if (!(e instanceof Refused)) throw e; + const line = resultLine('gstack-clean', 'GSTACK_CLEAN_TARGET_REFUSED', e.message); + io.err(line); + if (opts.json) io.out(JSON.stringify({ schema_version: CLEAN_SCHEMA_VERSION, tool: 'gstack-clean', status: 'refused', error: { code: 'GSTACK_CLEAN_TARGET_REFUSED', message: line }, exit_code: 3 }, null, 2)); + return 3; + } + if ('missing' in resolved) { + if (opts.json) io.out(JSON.stringify({ schema_version: CLEAN_SCHEMA_VERSION, tool: 'gstack-clean', mode: opts.apply ? 'apply' : 'dry-run', status: 'nothing_to_do', target: resolved.missing, actions: [], skips: [], total: { actions: 0, bytes: 0, failed: 0, skipped: 0 }, error: null, exit_code: 0 }, null, 2)); + else io.out(`nothing to clean: ${resolved.missing} does not exist`); + return 0; + } + const plan = planClean(resolved.target, opts); + if (!opts.apply) return report(io, opts, resolved, plan.actions.map(action => ({ action })), plan.skips); + const applied = applyClean(resolved.target, plan.actions, opts); + return report(io, opts, resolved, applied.done, [...plan.skips, ...applied.skips]); +} + +/** The bin's entry. Never throws: an unexpected error prints one GSTACK_CLEAN_INTERNAL line and exits 1. */ +export function main(argv: string[], env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): number { + const io: Io = { out: line => process.stdout.write(`${line}\n`), err: line => process.stderr.write(`${line}\n`) }; + try { + return run(argv, env, cwd, io); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + io.err(resultLine('gstack-clean', 'GSTACK_CLEAN_INTERNAL', `${err.name}: ${err.message}`.slice(0, 300))); + return 1; + } +} diff --git a/lib/result-codes-clean.ts b/lib/result-codes-clean.ts new file mode 100644 index 000000000..5a4dc94c0 --- /dev/null +++ b/lib/result-codes-clean.ts @@ -0,0 +1,30 @@ +/** + * Result codes for gstack-clean (lib/repo-state-clean.ts, #2627). Kept beside + * lib/result-codes.ts (which spreads this table into RESULT_CODES) so the owner + * module stays within the module-size ratchet; the same rules apply: one stable + * docs/troubleshooting.md anchor, a summary and a fix clause each. + */ +import type { ResultCode } from './result-codes'; + +export const CLEAN_RESULT_CODES = { + GSTACK_CLEAN_USAGE: { + anchor: 'gstack-clean-usage', + summary: 'an unknown flag or a bad --audit-days value; nothing was read or changed', + fix: 'gstack-clean [--apply] [--audit-days N] [--json], where N is a whole number of days, 1 or more', + }, + GSTACK_CLEAN_TARGET_REFUSED: { + anchor: 'gstack-clean-target-refused', + summary: 'the folder to clean is not a safe per-repo .gstack folder, so nothing was touched', + fix: 'run gstack-clean inside a git checkout whose .gstack is a real folder (not a symlink) outside the gstack state root, with BROWSE_STATE_FILE unset or naming that folder\'s browse.json', + }, + GSTACK_CLEAN_PARTIAL: { + anchor: 'gstack-clean-partial', + summary: 'one or more removals or cuts failed; the others finished', + fix: 'rerunning is safe: fix the permissions on each FAILED path or wait for the program holding it, then rerun gstack-clean --apply', + }, + GSTACK_CLEAN_INTERNAL: { + anchor: 'gstack-clean-internal', + summary: 'unexpected gstack-clean failure; the line names the error', + fix: 'rerun once; if it repeats, report the printed error to the gstack maintainers', + }, +} as const satisfies Record; diff --git a/lib/result-codes.ts b/lib/result-codes.ts index a7ac73c3d..f4bdd00ba 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -14,6 +14,7 @@ import { RELEASE_RESULT_CODES } from './result-codes-release'; import { CAPY_RESULT_CODES } from './result-codes-capy'; import { BUDGET_RESULT_CODES } from './result-codes-budget'; import { EVAL_RESULT_CODES } from './result-codes-eval'; +import { CLEAN_RESULT_CODES } from './result-codes-clean'; export interface ResultCode { /** Stable `` in docs/troubleshooting.md. */ @@ -763,6 +764,7 @@ export const RESULT_CODES = { ...CAPY_RESULT_CODES, ...BUDGET_RESULT_CODES, ...EVAL_RESULT_CODES, + ...CLEAN_RESULT_CODES, } as const satisfies Record; export type ResultCodeName = keyof typeof RESULT_CODES; diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index 8139bdb26..8a6052c6e 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -176,7 +176,9 @@ { "file": "scripts/gate-cost-replay.ts", "movedFrom": "(new: CLI behind lib/gate-cost.ts: cached report artifacts, merged-PR replay and the train model table, GSTA-49 T0)" }, { "file": "lib/gate-cost-history.ts", "movedFrom": "(new: red attribution, red-head screen replay, READY times and the READY-arrival train replay, GSTA-49 T0b)" }, { "file": "lib/pr-wait.ts", "movedFrom": "(new: gstack-pr-wait event logic, GSTA-115 item 1; design ported from tstack pr-wait)" }, - { "file": "lib/pr-wait-github.ts", "movedFrom": "(new: gstack-pr-wait gh reads, GSTA-115 item 1; design ported from tstack pr-wait)" } + { "file": "lib/pr-wait-github.ts", "movedFrom": "(new: gstack-pr-wait gh reads, GSTA-115 item 1; design ported from tstack pr-wait)" }, + { "file": "lib/repo-state-clean.ts", "movedFrom": "(new: per-repo .gstack cleanup behind bin/gstack-clean, #2627)" }, + { "file": "lib/result-codes-clean.ts", "movedFrom": "lib/result-codes.ts (the gstack-clean rows, #2627)" } ], "residualFiles": [ { "file": "scripts/test-free-shards.ts", "maxLines": 2361, "reason": "free lane policy after the shard engine took spawn, sandbox, logs, seeds and flags" }, diff --git a/test/gstack-clean-producers.test.ts b/test/gstack-clean-producers.test.ts new file mode 100644 index 000000000..ba3cef654 --- /dev/null +++ b/test/gstack-clean-producers.test.ts @@ -0,0 +1,104 @@ +/** + * gstack-clean removes only top-level tmp/codex-* files in a repository's + * .gstack (lib/repo-state-clean.ts, #2627 D8). This test greps the tracked + * templates, lib, bin and scripts for every producer of a .gstack/tmp/ entry + * and fails when one is not classified below, as scratch gstack-clean may + * remove or as kept, or when a codex-* name is not on the scratch list (a kept + * file named codex-* would be deleted after 7 days). Classify a new producer + * here before shipping it. + */ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { SCRATCH_PREFIX } from '../lib/repo-state-clean'; + +const ROOT = path.resolve(import.meta.dir, '..'); + +/** Throwaway names gstack-clean removes after 7 days; every one starts with SCRATCH_PREFIX. Exact names, so a new codex-* producer must be looked at. */ +const SCRATCH: Record = { + 'codex-err-XXXXXX': 'codex stderr capture under TMP_ROOT, which falls back to .gstack/tmp (codex/sections/*.md.tmpl)', + 'codex-resp-XXXXXX': 'codex response capture under TMP_ROOT (codex/sections/*.md.tmpl)', + 'codex-out-XXXXXX': 'codex review output under TMP_ROOT (codex/sections/review-mode.md.tmpl)', + 'codex-prompt-XXXXXX': 'codex review prompt under TMP_ROOT (codex/sections/review-mode.md.tmpl)', + 'codex-prompt.XXXXXX': 'codex challenge and consult prompt, a free-text file in .gstack/tmp (codex/sections/*.md.tmpl)', + 'codex-focus.XXXXXX': 'codex review focus, a free-text file in .gstack/tmp (codex/sections/review-mode.md.tmpl)', +}; + +/** Names or name patterns (prefix* or *suffix) gstack-clean never touches. */ +const KEPT: Record = { + 'restamp-journal.json': 'gstack-restamp rollback journal (lib/restamp.ts)', + 'gate-ahead*': 'gate-ahead record and worktree (lib/restamp.ts)', + 'pregate.json': 'pregate result (lib/pregate/index.ts)', + 'ship-queue': 'ship-queue state (lib/ship-queue-gate.ts)', + 'ship-receipt.md': '/ship handoff receipt', + 'ship-gate.json': '/ship handoff gate record', + 'issues.json': '/ship issue links', + 'docs-audit.json': '/ship documentation audit record', + 'tree-receipt.json': 'gstack-tree-receipt output', + 'base-red.json': '/ship base-red record', + 'review-*': '/ship review loop records', + 'fix-check-*': '/ship fix-check inputs', + 'autoplan': 'autoplan phase artifacts (autoplan/SKILL.md.tmpl)', + 'eval-bisect': '/investigate eval bisect cache', + 'price-gate': 'gstack-eval-plan price receipts', + 'qt.txt': 'question-tuning summary file', + 'tune-words.txt': '/plan-tune free text', + 'before.txt': '/ship PR screenshot input', + 'after.txt': '/ship PR screenshot input', + '*.XXXXXX': 'free-text and page mktemp files (scripts/resolvers/free-text-file.ts, scripts/resolvers/gbrain.ts)', + '<*': 'a free-text file named by the basename its mktemp printed', + 'name*': 'bin/gstack-paths comment example for TMP_ROOT', +}; + +const matches = (name: string, pattern: string) => pattern.endsWith('*') ? name.startsWith(pattern.slice(0, -1)) : pattern.startsWith('*') ? name.endsWith(pattern.slice(1)) : name === pattern; + +function trackedSources(): string[] { + const r = spawnSync('git', ['ls-files', '-z', '--', '*.tmpl', 'lib', 'bin', 'scripts', 'autoplan/bin', 'browse/src'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }); + if (r.status !== 0) throw new Error('git ls-files failed: ' + r.stderr); + return r.stdout.split('\0').filter(f => f && !/(^|\/)test\//.test(f) && !f.endsWith('.test.ts') && fs.existsSync(path.join(ROOT, f))); +} + +/** Every .gstack/tmp entry name a source can create, with where it was seen. */ +function producers(): Map { + const found = new Map(); + const add = (name: string, where: string) => { if (name && !found.has(name)) found.set(name, where); }; + for (const file of trackedSources()) { + const text = fs.readFileSync(path.join(ROOT, file), 'utf8'); + const at = (index: number) => file + ':' + (text.slice(0, index).split('\n').length); + for (const m of text.matchAll(/\.gstack\/tmp\/([^\s"'`)\]}$;,|&*\\]*)/g)) add(m[1]!.split('/')[0]!.replace(/\.+$/, ''), at(m.index!)); + for (const m of text.matchAll(/['"]\.gstack['"],\s*['"]tmp['"](?:,\s*['"]([^'"]+)['"])?/g)) add(m[1] ?? '', at(m.index!)); + for (const m of text.matchAll(/\$\{?TMP_ROOT\}?\/([A-Za-z0-9._<>-]+)/g)) add(m[1]!, at(m.index!)); + for (const m of text.matchAll(/\{\{FREE_TEXT_FILE(?::([^}]*))?\}\}/g)) { + for (const spec of (m[1] ?? 'TEXT_FILE=text').split(':')) add((spec.split('=')[1] || 'text') + '.XXXXXX', at(m.index!)); + } + } + return found; +} + +describe('gstack-clean producer classification', () => { + const found = producers(); + + test('the scan sees the known producers', () => { + for (const name of ['restamp-journal.json', 'pregate.json', 'ship-queue', 'autoplan', 'codex-err-XXXXXX']) expect(found.has(name)).toBe(true); + }); + + test('every .gstack/tmp producer is classified', () => { + const problems: string[] = []; + for (const [name, where] of found) { + if (name.startsWith(SCRATCH_PREFIX)) { + if (!(name in SCRATCH)) problems.push(where + ': ' + name + ' starts with ' + SCRATCH_PREFIX + ', so gstack-clean removes it after 7 days; add it to SCRATCH if it is throwaway, or rename it'); + } else if (!Object.keys(KEPT).some(pattern => matches(name, pattern))) { + problems.push(where + ': ' + name + ' is not classified; add it to KEPT (or name it ' + SCRATCH_PREFIX + '* and add it to SCRATCH)'); + } + } + expect(problems).toEqual([]); + }); + + test('every classified entry still has a producer, and scratch is what gstack-clean removes', () => { + expect(Object.keys(SCRATCH).filter(name => !name.startsWith(SCRATCH_PREFIX))).toEqual([]); + const names = [...found.keys()]; + expect(Object.keys(SCRATCH).filter(name => !found.has(name))).toEqual([]); + expect(Object.keys(KEPT).filter(pattern => !names.some(name => !name.startsWith(SCRATCH_PREFIX) && matches(name, pattern)))).toEqual([]); + }); +}); diff --git a/test/gstack-clean.test.ts b/test/gstack-clean.test.ts new file mode 100644 index 000000000..b49fac8f7 --- /dev/null +++ b/test/gstack-clean.test.ts @@ -0,0 +1,456 @@ +/** + * gstack-clean (#2627, GSTA-226 D8/D9): dry run changes nothing, apply removes + * exactly the allowlist, every refusal touches nothing, links never lead it + * outside the folder, liveness and unknown liveness skip a group, the in-place + * cut keeps the inode and a line boundary, audit rotation keeps newer and + * unreadable lines byte for byte, exit codes 0/1/2/3, and the --json shape. + * Fixtures live under os.tmpdir(), outside any checkout, with GSTACK_HOME at + * a temp state root; every fixture path is resolved and checked to stay there. + */ +import { afterAll, describe, expect, test } from 'bun:test'; +import { spawn, spawnSync } from 'node:child_process'; +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { bunIsolationArgs } from '../lib/bun-isolation'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin', 'gstack-clean'); +const DAY = 24 * 60 * 60 * 1000; +const roots: string[] = []; + +afterAll(() => { + for (const root of roots) { + spawnSync('chmod', ['-R', 'u+rwx', root], { timeout: 10_000 }); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +interface Fixture { root: string; home: string; state: string; repo: string; gs: string } + +/** Bun's install cache and the machine's git shim write under HOME; neither is gstack-clean's doing. */ +const HOME_NOISE = new Set(['home/.bun', 'home/.capy']); + +function git(cwd: string, ...args: string[]): string { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 }); + if (r.status !== 0) throw new Error('git ' + args.join(' ') + ': ' + r.stderr); + return r.stdout.trim(); +} + +function inRoot(root: string, p: string): string { + const real = fs.realpathSync(p); + if (real !== root && !real.startsWith(root + path.sep)) throw new Error(p + ' resolves outside the fixture root ' + root); + return real; +} + +function fixture(): Fixture { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-clean-'))); + roots.push(root); + if (root.startsWith(ROOT + path.sep)) throw new Error('fixture root is inside the checkout'); + const home = path.join(root, 'home'); + const state = path.join(root, 'state'); + const repo = path.join(root, 'repo'); + fs.mkdirSync(home); + fs.mkdirSync(state); + fs.mkdirSync(repo); + git(repo, 'init', '-q'); + expect(git(repo, 'rev-parse', '--show-toplevel')).toBe(repo); + const gs = path.join(repo, '.gstack'); + fs.mkdirSync(path.join(gs, 'tmp'), { recursive: true }); + inRoot(root, gs); + return { root, home, state, repo, gs }; +} + +function put(file: string, content: string | Buffer, ageMs = 30 * DAY): string { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + const when = new Date(Date.now() - ageMs); + fs.utimesSync(file, when, when); + return file; +} + +/** Log text of whole numbered lines, about the given size. */ +function logText(bytes: number): string { + const lines: string[] = []; + let size = 0; + for (let i = 0; size < bytes; i++) { + const line = 'line ' + String(i).padStart(7, '0') + ' ' + 'x'.repeat(i % 90) + '\n'; + lines.push(line); + size += line.length; + } + return lines.join(''); +} + +function deadPid(): number { + const r = spawnSync('true', [], { timeout: 10_000 }); + return r.pid!; +} + +function run(f: Fixture, args: string[], opts: { cwd?: string; env?: Record } = {}) { + const env: Record = { ...process.env, HOME: f.home, GSTACK_HOME: f.state, ...opts.env }; + for (const name of ['GSTACK_STATE_ROOT', 'GSTACK_STATE_DIR', 'CLAUDE_PLUGIN_DATA', 'BROWSE_STATE_FILE']) if (!(opts.env && name in opts.env)) delete env[name]; + for (const [k, v] of Object.entries(env)) if (v === undefined) delete env[k]; + const r = spawnSync(process.execPath, [...bunIsolationArgs(), BIN, ...args], { cwd: opts.cwd ?? f.repo, env: env as NodeJS.ProcessEnv, encoding: 'utf8', timeout: 30_000 }); + return { code: r.status, out: r.stdout, err: r.stderr }; +} + +/** Every entry under the fixture root (links not followed): type, inode, mtime and content hash. */ +function snapshot(root: string): Record { + const out: Record = {}; + const walk = (dir: string) => { + for (const name of fs.readdirSync(dir).sort()) { + const p = path.join(dir, name); + const st = fs.lstatSync(p); + const rel = path.relative(root, p); + if (HOME_NOISE.has(rel)) continue; + if (st.isSymbolicLink()) out[rel] = 'link ' + fs.readlinkSync(p); + else if (st.isDirectory()) { out[rel] = 'dir'; walk(p); } + else out[rel] = 'file ' + st.ino + ' ' + st.mtimeMs + ' ' + crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); + } + }; + walk(root); + return out; +} + +function changedKeys(before: Record, after: Record): string[] { + return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => before[k] !== after[k]).sort(); +} + +/** A .gstack with one of everything: targets, near misses and protected files. */ +function populate(f: Fixture): void { + const t = path.join(f.gs, 'tmp'); + put(path.join(t, 'codex-err-old1'), 'old stderr', 8 * DAY); + put(path.join(t, 'codex-resp-old2'), 'old response', 400 * DAY); + put(path.join(t, 'codex-resp-new'), 'two days old', 2 * DAY); + for (const name of ['restamp-journal.json', 'gate-ahead.json', 'gate-ahead-2.json', 'pregate.json', 'ship-receipt.md', 'brief.AbC123', 'page.Xy12']) + put(path.join(t, name), '{"keep":true}', 400 * DAY); + for (const dir of ['autoplan', 'ship-queue', 'eval-bisect', 'price-gate']) put(path.join(t, dir, 'codex-nested-old'), 'nested', 400 * DAY); + put(path.join(t, 'codex-dir', 'inside'), 'a directory named like scratch', 400 * DAY); + put(path.join(f.gs, 'browse-console.log'), logText(1.5 * 1024 * 1024), 2 * 60 * 60 * 1000); + put(path.join(f.gs, 'browse-network.log'), logText(500 * 1024), 400 * DAY); + put(path.join(f.gs, 'browse-daemon.log.1'), logText(2 * 1024 * 1024), 400 * DAY); + put(path.join(f.gs, 'browse-audit.jsonl'), '{"ts":"2020-01-01T00:00:00.000Z","cmd":"goto"}\n', 400 * DAY); + put(path.join(f.gs, 'browse.json'), JSON.stringify({ pid: deadPid(), port: 1 }), 400 * DAY); + put(path.join(f.gs, 'session-state.json'), '{"cookies":[]}', 400 * DAY); + put(path.join(f.gs, 'chromium-profile', 'Default', 'Cookies'), 'cookies', 400 * DAY); + put(path.join(f.gs, 'browser-skills', 'mine', 'script.ts'), 'export {}', 400 * DAY); + put(path.join(f.gs, 'browse-states', 'saved.json'), '{}', 400 * DAY); +} + +describe('gstack-clean', () => { + test('dry run changes no byte or mtime and names the target first', () => { + const f = fixture(); + populate(f); + const before = snapshot(f.root); + const r = run(f, []); + expect(r.code).toBe(0); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + const lines = r.out.trimEnd().split('\n'); + expect(lines[0]).toBe('gstack-clean: target ' + f.gs + ' (the .gstack folder at the git toplevel ' + f.repo + ')'); + expect(r.out).toMatch(/would remove +tmp\/codex-err-old1 \(10 bytes\)/); + expect(r.out).toMatch(/would cut +browse-console\.log \(freeing \d+ bytes\)/); + expect(r.out).toMatch(/skip +tmp\/codex-dir: is not a regular file/); + expect(r.out).toMatch(/^total: 3 actions would free \d+ bytes; 1 skipped$/m); + expect(lines.at(-1)).toBe('nothing changed; to do this: gstack-clean --apply'); + }); + + test('apply removes exactly the allowlist; protected files survive at any age', () => { + const f = fixture(); + populate(f); + const before = snapshot(f.root); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.err).toBe(''); + const after = snapshot(f.root); + expect(changedKeys(before, after)).toEqual([ + 'repo/.gstack/browse-console.log', + 'repo/.gstack/tmp/codex-err-old1', + 'repo/.gstack/tmp/codex-resp-old2', + ]); + expect(after['repo/.gstack/tmp/codex-err-old1']).toBeUndefined(); + expect(after['repo/.gstack/browse-console.log']).toBeDefined(); + expect(r.out).toMatch(/^removed +tmp\/codex-resp-old2 \(12 bytes\)/m); + expect(r.out).toMatch(/^total: 3 actions freed \d+ bytes; 1 skipped$/m); + expect(r.out).not.toContain('nothing changed'); + }); + + test('a missing .gstack is nothing to clean', () => { + const f = fixture(); + fs.rmSync(f.gs, { recursive: true }); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.out.trim()).toBe('nothing to clean: ' + f.gs + ' does not exist'); + }); + + describe('refusals touch nothing (exit 3)', () => { + const refused = (f: Fixture, r: ReturnType, before: Record, why: RegExp) => { + expect(r.code).toBe(3); + expect(r.out).toBe(''); + expect(r.err.trim()).toMatch(why); + expect(r.err.trim()).toEndWith('(GSTACK_CLEAN_TARGET_REFUSED)'); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + }; + + test('outside git', () => { + const f = fixture(); + const plain = path.join(f.root, 'plain'); + put(path.join(plain, '.gstack', 'tmp', 'codex-err-old'), 'x', 30 * DAY); + const before = snapshot(f.root); + refused(f, run(f, ['--apply'], { cwd: plain }), before, /is not inside a git work tree/); + }); + + test('a symlinked .gstack', () => { + const f = fixture(); + fs.rmSync(f.gs, { recursive: true }); + const elsewhere = path.join(f.root, 'elsewhere'); + put(path.join(elsewhere, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + fs.symlinkSync(elsewhere, f.gs); + const before = snapshot(f.root); + refused(f, run(f, ['--apply']), before, /\.gstack is a symlink/); + }); + + test('BROWSE_STATE_FILE naming another folder; its own browse.json is accepted', () => { + const f = fixture(); + put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + const before = snapshot(f.root); + const foreign = path.join(f.root, 'other', '.gstack', 'browse.json'); + refused(f, run(f, ['--apply'], { env: { BROWSE_STATE_FILE: foreign } }), before, /BROWSE_STATE_FILE is .*other\/\.gstack\/browse\.json/); + const own = run(f, [], { env: { BROWSE_STATE_FILE: path.join(f.gs, 'browse.json') } }); + expect(own.code).toBe(0); + expect(own.out).toContain('would remove'); + }); + + test('the target is the state root (HOME/.gstack with GSTACK_HOME=HOME/.gstack)', () => { + const f = fixture(); + git(f.home, 'init', '-q'); + const home = path.join(f.home, '.gstack'); + put(path.join(home, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + put(path.join(home, 'projects', 'p', 'learnings.jsonl'), '{}\n', 30 * DAY); + const before = snapshot(f.root); + refused(f, run(f, ['--apply'], { cwd: f.home, env: { HOME: f.home, GSTACK_HOME: home } }), before, /sits inside the gstack state root/); + }); + + test('the target holds the state root', () => { + const f = fixture(); + put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + const inner = path.join(f.gs, 'state'); + fs.mkdirSync(inner); + const before = snapshot(f.root); + refused(f, run(f, ['--apply'], { env: { GSTACK_HOME: inner } }), before, /holds or sits inside the gstack state root/); + }); + }); + + test('a link in tmp/ pointing outside leaves the outside file intact', () => { + const f = fixture(); + const outside = put(path.join(f.root, 'outside', 'codex-precious'), 'precious', 30 * DAY); + fs.symlinkSync(outside, path.join(f.gs, 'tmp', 'codex-link')); + const before = snapshot(f.root); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.out).toMatch(/skip +tmp\/codex-link: is a symlink/); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + expect(fs.readFileSync(outside, 'utf8')).toBe('precious'); + }); + + test('a symlinked tmp/ refuses the tmp group', () => { + const f = fixture(); + fs.rmSync(path.join(f.gs, 'tmp'), { recursive: true }); + const outsideTmp = path.join(f.root, 'outside-tmp'); + put(path.join(outsideTmp, 'codex-err-old'), 'x', 30 * DAY); + fs.symlinkSync(outsideTmp, path.join(f.gs, 'tmp')); + const before = snapshot(f.root); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.out).toMatch(/skip +tmp: tmp\/ is a symlink; the tmp group is refused/); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + }); + + describe('liveness', () => { + const bigLog = (f: Fixture, ageMs = 2 * 60 * 60 * 1000) => put(path.join(f.gs, 'browse-console.log'), logText(1.2 * 1024 * 1024), ageMs); + + test('a live browse daemon (browse.json names a running pid) skips browse files', () => { + const f = fixture(); + bigLog(f); + put(path.join(f.gs, 'browse.json'), JSON.stringify({ pid: process.pid, port: 1 })); + const before = snapshot(f.root); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.out).toContain('skip browse-console.log: the browse daemon (pid ' + process.pid + ', browse.json) is running'); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + }); + + test('a starting daemon (browse.json.lock with a live pid) skips browse files', () => { + const f = fixture(); + bigLog(f); + put(path.join(f.gs, 'browse.json.lock'), process.pid + '\n'); + const r = run(f, ['--apply']); + expect(r.out).toContain('browse.json.lock) is running'); + expect(fs.statSync(path.join(f.gs, 'browse-console.log')).size).toBeGreaterThan(1024 * 1024); + }); + + test('an unreadable browse.json is unknown liveness and skips browse files', () => { + const f = fixture(); + bigLog(f); + put(path.join(f.gs, 'browse.json'), 'not json'); + const before = snapshot(f.root); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.out).toContain('browse.json names no pid, so the browse daemon may be running'); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + }); + + test('no process listing is unknown liveness and skips tmp/', () => { + const f = fixture(); + put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + const onlyGit = path.join(f.root, 'only-git'); + fs.mkdirSync(onlyGit); + const gitBin = spawnSync('bash', ['-c', 'command -v git'], { encoding: 'utf8', timeout: 10_000 }).stdout.trim(); + fs.symlinkSync(gitBin, path.join(onlyGit, 'git')); + const before = snapshot(path.join(f.repo)); + const r = run(f, ['--apply'], { env: { PATH: onlyGit } }); + expect(r.code).toBe(0); + expect(r.out).toContain('skip tmp/codex-err-old: cannot list processes to tell whether codex is running'); + expect(changedKeys(before, snapshot(f.repo))).toEqual([]); + }); + + test('a running codex skips tmp/', async () => { + const f = fixture(); + put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + const fake = path.join(f.root, 'fakebin', 'codex'); + put(fake, '#!/bin/sh\nsleep 30\n', 0); + fs.chmodSync(fake, 0o755); + const child = spawn(fake, [], { stdio: 'ignore' }); + try { + await Bun.sleep(300); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.out).toMatch(/skip +tmp\/codex-err-old: codex is running/); + expect(fs.existsSync(path.join(f.gs, 'tmp', 'codex-err-old'))).toBe(true); + } finally { + child.kill('SIGKILL'); + } + }); + + test('a file modified within the last hour is skipped', () => { + const f = fixture(); + bigLog(f, 0); + const before = snapshot(f.root); + const r = run(f, ['--apply']); + expect(r.out).toContain('skip browse-console.log: modified within the last hour'); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + }); + }); + + test('the in-place cut keeps the inode and starts on a line boundary', () => { + const f = fixture(); + const text = logText(1.5 * 1024 * 1024); + const log = put(path.join(f.gs, 'browse-daemon.log'), text, 2 * 60 * 60 * 1000); + const ino = fs.statSync(log).ino; + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + const kept = fs.readFileSync(log, 'utf8'); + expect(fs.statSync(log).ino).toBe(ino); + expect(kept.length).toBeLessThanOrEqual(256 * 1024); + expect(kept.length).toBeGreaterThan(255 * 1024 - 200); + expect(text.endsWith(kept)).toBe(true); + expect(text[text.length - kept.length - 1]).toBe('\n'); + expect(kept.startsWith('line ')).toBe(true); + expect(r.out).toMatch(new RegExp('^cut +browse-daemon\\.log \\(freed ' + (text.length - kept.length) + ' bytes\\)', 'm')); + }); + + test('--audit-days keeps newer and unparseable lines byte for byte; a malformed file is left alone', () => { + const f = fixture(); + const iso = (ageMs: number) => new Date(Date.now() - ageMs).toISOString(); + const oldLine = '{"ts":"' + iso(40 * DAY) + '","cmd":"goto"}\n'; + const keepLines = [ + '{"ts":"' + iso(DAY) + '","cmd":"text","args":"caf\u00e9"}\n', + '{"cmd":"no-ts"}\n', + '{"ts":"not a date","cmd":"bad-ts"}\n', + '\n', + '{"ts":"' + iso(2 * DAY) + '", "cmd":"spaced"}', + ]; + const audit = put(path.join(f.gs, 'browse-audit.jsonl'), oldLine + keepLines[0] + oldLine + keepLines.slice(1).join(''), 2 * 60 * 60 * 1000); + const ino = fs.statSync(audit).ino; + const untouched = run(f, ['--apply']); + expect(untouched.out).not.toContain('browse-audit.jsonl'); + const r = run(f, ['--apply', '--audit-days', '30']); + expect(r.code).toBe(0); + expect(r.out).toMatch(/^trimmed +browse-audit\.jsonl \(freed \d+ bytes\): audit entries older than 30 days/m); + expect(fs.readFileSync(audit, 'utf8')).toBe(keepLines.join('')); + expect(fs.statSync(audit).ino).toBe(ino); + + const g = fixture(); + put(path.join(g.gs, 'browse-audit.jsonl'), oldLine + '{"ts": truncated\n', 2 * 60 * 60 * 1000); + const before = snapshot(g.root); + const bad = run(g, ['--apply', '--audit-days=30']); + expect(bad.code).toBe(0); + expect(bad.out).toContain('skip browse-audit.jsonl: line 2 is not JSON, so the file was left as it is'); + expect(changedKeys(before, snapshot(g.root))).toEqual([]); + }); + + describe('exit codes', () => { + test('2: an unknown flag or a bad --audit-days quotes the value; the last line is usage', () => { + const f = fixture(); + for (const [args, quoted] of [[['--force'], '"--force"'], [['--audit-days', 'abc'], '"abc"'], [['--audit-days=0'], '"0"'], [['--audit-days'], '""']] as const) { + const r = run(f, [...args]); + expect(r.code).toBe(2); + expect(r.out).toBe(''); + const lines = r.err.trimEnd().split('\n'); + expect(lines[0]).toContain(quoted); + expect(lines[0]).toEndWith('(GSTACK_CLEAN_USAGE)'); + expect(lines.at(-1)).toBe('usage: gstack-clean [--apply] [--audit-days N] [--json]'); + } + }); + + test('1: a read-only folder and a read-only file give FAILED lines; rerunning is safe', () => { + const f = fixture(); + put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + const log = put(path.join(f.gs, 'browse-dialog.log'), logText(1.1 * 1024 * 1024), 2 * 60 * 60 * 1000); + fs.chmodSync(log, 0o444); + fs.chmodSync(path.join(f.gs, 'tmp'), 0o555); + try { + const r = run(f, ['--apply']); + expect(r.code).toBe(1); + expect(r.out).toContain('FAILED ' + path.join(f.gs, 'tmp', 'codex-err-old') + ' EACCES'); + expect(r.out).toContain('FAILED ' + log + ' EACCES'); + expect(r.err).toContain('rerunning is safe'); + expect(r.err.trim()).toEndWith('(GSTACK_CLEAN_PARTIAL)'); + } finally { + fs.chmodSync(path.join(f.gs, 'tmp'), 0o755); + } + expect(fs.existsSync(path.join(f.gs, 'tmp', 'codex-err-old'))).toBe(true); + }); + + test('--help lists the fixed thresholds and exits 0', () => { + const f = fixture(); + const r = run(f, ['--help']); + expect(r.code).toBe(0); + for (const needle of ['older than 7 days', 'over 1 MB', 'last 256 KB', '--audit-days N', 'in the last hour']) expect(r.out).toContain(needle); + }); + }); + + test('--json prints one versioned object with the same content', () => { + const f = fixture(); + populate(f); + const dry = run(f, ['--json']); + expect(dry.code).toBe(0); + const plan = JSON.parse(dry.out); + expect(Object.keys(plan)).toEqual(['schema_version', 'tool', 'mode', 'status', 'target', 'chosen_by', 'audit_days', 'actions', 'skips', 'total', 'error', 'exit_code']); + expect(plan).toMatchObject({ schema_version: 1, tool: 'gstack-clean', mode: 'dry-run', status: 'planned', target: f.gs, audit_days: null, error: null, exit_code: 0 }); + expect(plan.actions.map((a: any) => [a.rel, a.group, a.action, a.status])).toEqual([ + ['tmp/codex-err-old1', 'tmp', 'remove', 'planned'], + ['tmp/codex-resp-old2', 'tmp', 'remove', 'planned'], + ['browse-console.log', 'browse', 'cut', 'planned'], + ]); + expect(plan.skips).toEqual([{ path: path.join(f.gs, 'tmp', 'codex-dir'), rel: 'tmp/codex-dir', group: 'tmp', reason: 'is not a regular file; left alone' }]); + expect(plan.total).toEqual({ actions: 3, bytes: plan.actions.reduce((n: number, a: any) => n + a.bytes, 0), failed: 0, skipped: 1 }); + const applied = JSON.parse(run(f, ['--json', '--apply']).out); + expect(applied).toMatchObject({ mode: 'apply', status: 'done', exit_code: 0 }); + expect(applied.actions.map((a: any) => a.status)).toEqual(['done', 'done', 'done']); + expect(JSON.parse(run(f, ['--json']).out)).toMatchObject({ status: 'nothing_to_do', actions: [] }); + const refused = run(f, ['--json'], { cwd: f.root }); + expect(refused.code).toBe(3); + expect(JSON.parse(refused.out)).toMatchObject({ schema_version: 1, status: 'refused', error: { code: 'GSTACK_CLEAN_TARGET_REFUSED' }, exit_code: 3 }); + }); +}); From c6148309b9b0687d8b1f7dfb47f0c06e892cd5b1 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 01:03:58 +0000 Subject: [PATCH 02/12] fix(clean): leave hard-linked and unreadable logs alone; cut through a no-follow descriptor checked against the evaluated inode Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/capy-gsta226-e2-gstack-clean.md | 4 +- lib/repo-state-clean.ts | 14 +++++-- test/gstack-clean.test.ts | 44 +++++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/changelog.d/capy-gsta226-e2-gstack-clean.md b/changelog.d/capy-gsta226-e2-gstack-clean.md index 91f4b993b..c4eb9c5e4 100644 --- a/changelog.d/capy-gsta226-e2-gstack-clean.md +++ b/changelog.d/capy-gsta226-e2-gstack-clean.md @@ -2,9 +2,9 @@ ### Added - **`bin/gstack-clean`** (rules in `lib/repo-state-clean.ts`): cleans `/.gstack` only. It removes top-level `tmp/codex-*` files older than 7 days, cuts `browse-console.log`, `browse-network.log`, `browse-dialog.log`, `browse-daemon.log` and `browse-startup-error.log` to their last 256 KB once they pass 1 MB (in place, starting after a line break, so a running writer keeps appending), and with `--audit-days N` drops `browse-audit.jsonl` entries older than N days while keeping every line without a readable timestamp. Everything else, including `browse.json`, `session-state.json`, `chromium-profile/`, `browser-skills/`, `browse-states/` and the other `tmp/` entries, is never touched. -- **Safety:** it refuses (exit 3) outside git, when `.gstack` is a symlink, when `BROWSE_STATE_FILE` names another folder, and when the folder is, holds or sits inside the gstack state root. It skips symlinks and files changed in the last hour, skips browse files while the browse daemon runs and `tmp/` while codex runs, and skips a group when it cannot tell. `--json` prints the same result as one versioned object. +- **Safety:** it refuses (exit 3) outside git, when `.gstack` is a symlink, when `BROWSE_STATE_FILE` names another folder, and when the folder is, holds or sits inside the gstack state root. It skips symlinks, logs with more than one hard link, files it cannot read and files changed in the last hour, skips browse files while the browse daemon runs and `tmp/` while codex runs, and skips a group when it cannot tell. `--json` prints the same result as one versioned object. - Result codes `GSTACK_CLEAN_USAGE`, `GSTACK_CLEAN_TARGET_REFUSED`, `GSTACK_CLEAN_PARTIAL` and `GSTACK_CLEAN_INTERNAL`, each with a docs/troubleshooting.md entry; BROWSER.md names the command where it lists the log files. ### Tests -- `test/gstack-clean.test.ts`: the dry run changes no byte or mtime, `--apply` removes exactly the allowlist, protected files survive at any age, every refusal touches nothing, links out of the folder and a symlinked `tmp/` are left alone, live and unknown liveness skip their group, the cut keeps the inode and a line boundary, audit trimming keeps the right lines byte for byte, exit codes 0, 1, 2 and 3, and the `--json` shape. +- `test/gstack-clean.test.ts`: the dry run changes no byte or mtime, `--apply` removes exactly the allowlist, protected files survive at any age, every refusal touches nothing, links out of the folder, hard-linked and unreadable logs and a symlinked `tmp/` are left alone, a log with no line break in its tail and a malformed audit file are left alone, live and unknown liveness skip their group, the cut keeps the inode and a line boundary, audit trimming keeps the right lines byte for byte, exit codes 0, 1, 2 and 3, and the `--json` shape. - `test/gstack-clean-producers.test.ts`: every tracked producer of a `.gstack/tmp/` entry is classified as scratch or kept, and any new `codex-*` name must be added to the scratch list on purpose. diff --git a/lib/repo-state-clean.ts b/lib/repo-state-clean.ts index 200906a31..796458e18 100644 --- a/lib/repo-state-clean.ts +++ b/lib/repo-state-clean.ts @@ -207,14 +207,19 @@ function evaluate(c: Candidate, dir: string, busy: string | null, opts: CleanOpt if (now - st.mtimeMs < RECENT_MS) return { ...c, reason: 'modified within the last hour' }; if (busy) return { ...c, reason: busy }; if (c.kind === 'remove') return { ...c, bytes: st.size, reason: 'codex scratch older than 7 days' }; + if (st.nlink !== 1) return { ...c, reason: `has ${st.nlink} hard links, so cutting it would change a file outside this folder; left alone` }; + let content: Buffer; + try { content = c.kind === 'cut' ? readTail(c.file, st.size) : fs.readFileSync(c.file); } catch (e: any) { + return { ...c, reason: `cannot read (${e?.code ?? 'error'}); left alone` }; + } if (c.kind === 'cut') { - const tail = readTail(c.file, st.size); + const tail = content; const at = tail.indexOf(0x0a); if (at < 0) return { ...c, reason: 'no line break in its last 256 KB; left alone' }; const keep = tail.subarray(at + 1); return { ...c, keep, bytes: st.size - keep.length, reason: 'over 1 MB; keeps the last 256 KB from a line start' }; } - const keep = auditKeep(fs.readFileSync(c.file), now - opts.auditDays! * DAY_MS); + const keep = auditKeep(content, now - opts.auditDays! * DAY_MS); if (typeof keep === 'string') return { ...c, reason: keep }; if (keep.length === st.size) return null; return { ...c, keep, bytes: st.size - keep.length, reason: `audit entries older than ${opts.auditDays} days` }; @@ -257,8 +262,11 @@ export function planClean(target: string, opts: CleanOptions, now = Date.now()): } function rewriteInPlace(file: string, keep: Buffer): void { - const fd = fs.openSync(file, 'r+'); + const before = fs.lstatSync(file); + const fd = fs.openSync(file, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW ?? 0)); try { + const st = fs.fstatSync(fd); + if (st.ino !== before.ino || st.dev !== before.dev || st.nlink !== 1) throw Object.assign(new Error('the file changed before it could be cut'), { code: 'ECHANGED' }); fs.writeSync(fd, keep, 0, keep.length, 0); fs.ftruncateSync(fd, keep.length); } finally { fs.closeSync(fd); } diff --git a/test/gstack-clean.test.ts b/test/gstack-clean.test.ts index b49fac8f7..3f47d8a83 100644 --- a/test/gstack-clean.test.ts +++ b/test/gstack-clean.test.ts @@ -389,6 +389,50 @@ describe('gstack-clean', () => { expect(changedKeys(before, snapshot(g.root))).toEqual([]); }); + test('a hard-linked log is left alone, and the other inode keeps every byte', () => { + const f = fixture(); + const outside = put(path.join(f.root, 'outside.txt'), logText(1.2 * 1024 * 1024), 2 * 60 * 60 * 1000); + fs.linkSync(outside, path.join(f.gs, 'browse-console.log')); + const before = fs.readFileSync(outside); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.out).toMatch(/skip +browse-console\.log: .*hard link/); + expect(fs.readFileSync(outside).equals(before)).toBe(true); + }); + + test('a log with no line break in its last 256 KB is left alone', () => { + const f = fixture(); + put(path.join(f.gs, 'browse-network.log'), 'x'.repeat(1.2 * 1024 * 1024), 2 * 60 * 60 * 1000); + const before = snapshot(f.root); + const r = run(f, ['--apply']); + expect(r.out).toMatch(/skip +browse-network\.log: no line break/); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + }); + + test('an audit line that is not a JSON object leaves the audit file alone', () => { + const f = fixture(); + put(path.join(f.gs, 'browse-audit.jsonl'), '{"ts":"2020-01-01T00:00:00Z"}\n[1]\n', 2 * 60 * 60 * 1000); + const before = snapshot(f.root); + const r = run(f, ['--apply', '--audit-days', '7']); + expect(r.out).toMatch(/skip +browse-audit\.jsonl: line 2 is not a JSON object/); + expect(changedKeys(before, snapshot(f.root))).toEqual([]); + }); + + test.skipIf(process.getuid?.() === 0)('an unreadable log is skipped and the rest still runs', () => { + const f = fixture(); + put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + const log = put(path.join(f.gs, 'browse-console.log'), logText(1.2 * 1024 * 1024), 2 * 60 * 60 * 1000); + fs.chmodSync(log, 0o000); + try { + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.out).toMatch(/skip +browse-console\.log: cannot read/); + expect(fs.existsSync(path.join(f.gs, 'tmp', 'codex-err-old'))).toBe(false); + } finally { + fs.chmodSync(log, 0o644); + } + }); + describe('exit codes', () => { test('2: an unknown flag or a bad --audit-days quotes the value; the last line is usage', () => { const f = fixture(); From b8f72cd4de0692c9503e6b45920d2b1dfcb2562c Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 01:08:26 +0000 Subject: [PATCH 03/12] test(clean): tests that expect tmp removal see a quiet ps, so a codex elsewhere on the host cannot flake them Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/gstack-clean.test.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/test/gstack-clean.test.ts b/test/gstack-clean.test.ts index 3f47d8a83..094110fe8 100644 --- a/test/gstack-clean.test.ts +++ b/test/gstack-clean.test.ts @@ -59,6 +59,8 @@ function fixture(): Fixture { const gs = path.join(repo, '.gstack'); fs.mkdirSync(path.join(gs, 'tmp'), { recursive: true }); inRoot(root, gs); + fs.mkdirSync(path.join(root, 'quiet-ps')); + fs.writeFileSync(path.join(root, 'quiet-ps', 'ps'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); return { root, home, state, repo, gs }; } @@ -87,8 +89,13 @@ function deadPid(): number { return r.pid!; } -function run(f: Fixture, args: string[], opts: { cwd?: string; env?: Record } = {}) { - const env: Record = { ...process.env, HOME: f.home, GSTACK_HOME: f.state, ...opts.env }; +/** A ps that lists no process, so an unrelated codex elsewhere on the host cannot skip tmp/; realPs keeps the host's. */ +function quietPsPath(f: Fixture): string { + return path.join(f.root, 'quiet-ps') + path.delimiter + (process.env.PATH ?? ''); +} + +function run(f: Fixture, args: string[], opts: { cwd?: string; env?: Record; realPs?: boolean } = {}) { + const env: Record = { ...process.env, HOME: f.home, GSTACK_HOME: f.state, ...(opts.realPs ? {} : { PATH: quietPsPath(f) }), ...opts.env }; for (const name of ['GSTACK_STATE_ROOT', 'GSTACK_STATE_DIR', 'CLAUDE_PLUGIN_DATA', 'BROWSE_STATE_FILE']) if (!(opts.env && name in opts.env)) delete env[name]; for (const [k, v] of Object.entries(env)) if (v === undefined) delete env[k]; const r = spawnSync(process.execPath, [...bunIsolationArgs(), BIN, ...args], { cwd: opts.cwd ?? f.repo, env: env as NodeJS.ProcessEnv, encoding: 'utf8', timeout: 30_000 }); @@ -323,7 +330,7 @@ describe('gstack-clean', () => { const child = spawn(fake, [], { stdio: 'ignore' }); try { await Bun.sleep(300); - const r = run(f, ['--apply']); + const r = run(f, ['--apply'], { realPs: true }); expect(r.code).toBe(0); expect(r.out).toMatch(/skip +tmp\/codex-err-old: codex is running/); expect(fs.existsSync(path.join(f.gs, 'tmp', 'codex-err-old'))).toBe(true); @@ -433,6 +440,23 @@ describe('gstack-clean', () => { } }); + test.skipIf(process.platform === 'win32')('tmp removal is unaffected by a codex process elsewhere on the host', async () => { + const f = fixture(); + put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY); + const fake = path.join(f.root, 'fakebin', 'codex'); + put(fake, '#!/bin/sh\nsleep 30\n', 0); + fs.chmodSync(fake, 0o755); + const child = spawn(fake, [], { stdio: 'ignore' }); + try { + await Bun.sleep(300); + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(fs.existsSync(path.join(f.gs, 'tmp', 'codex-err-old'))).toBe(false); + } finally { + child.kill('SIGKILL'); + } + }); + describe('exit codes', () => { test('2: an unknown flag or a bad --audit-days quotes the value; the last line is usage', () => { const f = fixture(); From 9e5c80c141547a7540a90b7d3fb10f5e303e1088 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 01:11:29 +0000 Subject: [PATCH 04/12] fix(clean): an unlistable tmp/ skips its group; liveness asked once per group on apply; BROWSER.md says which logs reset at daemon start Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- BROWSER.md | 7 ++++--- TODOS.md | 1 + changelog.d/capy-gsta226-e2-gstack-clean.md | 2 +- lib/repo-state-clean.ts | 23 ++++++++++++++++++--- test/gstack-clean.test.ts | 14 +++++++++++++ 5 files changed, 40 insertions(+), 7 deletions(-) diff --git a/BROWSER.md b/BROWSER.md index 704ce6391..3ecf950b1 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -1515,9 +1515,10 @@ capacity each), flushed to disk asynchronously via `Bun.write()`: - Network: `.gstack/browse-network.log` - Dialog: `.gstack/browse-dialog.log` -The browse server appends to these logs (and to `browse-daemon.log`, -`browse-startup-error.log` and the `browse-audit.jsonl` command audit) for -the life of the project, and codex scratch files collect in `.gstack/tmp/`. +The console, network, dialog and startup-error logs start empty at each daemon +start and can grow while one daemon runs; `browse-daemon.log` and the +`browse-audit.jsonl` command audit build up across restarts, and codex scratch +files collect in `.gstack/tmp/`. Run `gstack-clean` in the repository to see what it would reclaim, then `gstack-clean --apply` to do it: it cuts these logs to their last 256 KB once they pass 1 MB, removes `tmp/codex-*` files older than 7 days, and diff --git a/TODOS.md b/TODOS.md index 42136bc71..8e6f8c155 100644 --- a/TODOS.md +++ b/TODOS.md @@ -5,6 +5,7 @@ ### P3: gstack-clean follow-ups (filed 2026-10-31, GSTA-226 E2, #2627) - **Codex templates delete their own scratch.** The `/codex` review, challenge and consult blocks leave `codex-err-*`, `codex-resp-*`, `codex-out-*` and `codex-prompt-*` mktemp files under `TMP_ROOT` (which falls back to `.gstack/tmp`) and the `codex-prompt.*` and `codex-focus.*` free-text files in `.gstack/tmp`; `gstack-clean` removes them after 7 days. Delete each file at the end of its block instead. Skill prose for every user, so it needs its own review. **Effort:** S. **Priority:** P3. +- **`gstack-clean` reclaims `browse-daemon.log.1`.** The browse CLI renames `browse-daemon.log` to `.1` past 10 MB and nothing ever removes the copy. Add it to the browse group (remove when older than an hour and the daemon is not running). **Effort:** S. **Priority:** P3. - **Browse hints `gstack-clean` when `.gstack/tmp` is large.** Print one line at daemon start when `.gstack/tmp` or the browse logs pass a size threshold, naming `gstack-clean`. **Effort:** S. **Priority:** P3. ### P3: GST-421 and GST-412 follow-ups (filed 2026-10-31, from the PR B /ship review) diff --git a/changelog.d/capy-gsta226-e2-gstack-clean.md b/changelog.d/capy-gsta226-e2-gstack-clean.md index c4eb9c5e4..6cc9c5b5b 100644 --- a/changelog.d/capy-gsta226-e2-gstack-clean.md +++ b/changelog.d/capy-gsta226-e2-gstack-clean.md @@ -6,5 +6,5 @@ - Result codes `GSTACK_CLEAN_USAGE`, `GSTACK_CLEAN_TARGET_REFUSED`, `GSTACK_CLEAN_PARTIAL` and `GSTACK_CLEAN_INTERNAL`, each with a docs/troubleshooting.md entry; BROWSER.md names the command where it lists the log files. ### Tests -- `test/gstack-clean.test.ts`: the dry run changes no byte or mtime, `--apply` removes exactly the allowlist, protected files survive at any age, every refusal touches nothing, links out of the folder, hard-linked and unreadable logs and a symlinked `tmp/` are left alone, a log with no line break in its tail and a malformed audit file are left alone, live and unknown liveness skip their group, the cut keeps the inode and a line boundary, audit trimming keeps the right lines byte for byte, exit codes 0, 1, 2 and 3, and the `--json` shape. +- `test/gstack-clean.test.ts`: the dry run changes no byte or mtime, `--apply` removes exactly the allowlist, protected files survive at any age, every refusal touches nothing, links out of the folder, hard-linked and unreadable logs and a symlinked `tmp/` are left alone, a log with no line break in its tail and a malformed audit file are left alone, an unlistable `tmp/` skips only its group, a codex process elsewhere on the host cannot flake the tests, live and unknown liveness skip their group, the cut keeps the inode and a line boundary, audit trimming keeps the right lines byte for byte, exit codes 0, 1, 2 and 3, and the `--json` shape. - `test/gstack-clean-producers.test.ts`: every tracked producer of a `.gstack/tmp/` entry is classified as scratch or kept, and any new `codex-*` name must be added to the scratch list on purpose. diff --git a/lib/repo-state-clean.ts b/lib/repo-state-clean.ts index 796458e18..6fc8ef128 100644 --- a/lib/repo-state-clean.ts +++ b/lib/repo-state-clean.ts @@ -27,6 +27,7 @@ const DAY_MS = 24 * 60 * 60 * 1000; export const SCRATCH_PREFIX = 'codex-'; export const SCRATCH_MIN_AGE_MS = 7 * DAY_MS; export const RECENT_MS = 60 * 60 * 1000; +const LIVENESS_RECHECK_MS = 2000; export const LOG_CUT_OVER_BYTES = 1024 * 1024; export const LOG_KEEP_BYTES = 256 * 1024; export const BROWSE_LOGS = ['browse-console.log', 'browse-network.log', 'browse-dialog.log', 'browse-daemon.log', 'browse-startup-error.log'] as const; @@ -201,7 +202,11 @@ function evaluate(c: Candidate, dir: string, busy: string | null, opts: CleanOpt } if (st.isSymbolicLink()) return { ...c, reason: 'is a symlink; left alone' }; if (!st.isFile()) return c.kind === 'remove' ? { ...c, reason: 'is not a regular file; left alone' } : null; - if (path.dirname(fs.realpathSync(c.file)) !== dir) return { ...c, reason: 'resolves outside the folder being cleaned; left alone' }; + let real: string; + try { real = fs.realpathSync(c.file); } catch (e: any) { + return e?.code === 'ENOENT' ? null : { ...c, reason: `cannot resolve (${e?.code ?? 'error'})` }; + } + if (path.dirname(real) !== dir) return { ...c, reason: 'resolves outside the folder being cleaned; left alone' }; if (c.kind === 'remove' && now - st.mtimeMs < SCRATCH_MIN_AGE_MS) return null; if (c.kind === 'cut' && st.size <= LOG_CUT_OVER_BYTES) return null; if (now - st.mtimeMs < RECENT_MS) return { ...c, reason: 'modified within the last hour' }; @@ -236,7 +241,11 @@ function candidates(target: string, opts: CleanOptions): { list: Candidate[]; sk if (tmpStat?.isSymbolicLink()) skips.push({ file: tmp, group: 'tmp', reason: 'tmp/ is a symlink; the tmp group is refused' }); else if (tmpStat && !tmpStat.isDirectory()) skips.push({ file: tmp, group: 'tmp', reason: 'tmp is not a directory; the tmp group is skipped' }); else if (tmpStat) { - for (const name of fs.readdirSync(tmp).sort()) if (name.startsWith(SCRATCH_PREFIX)) list.push({ file: path.join(tmp, name), group: 'tmp', kind: 'remove' }); + let names: string[] = []; + try { names = fs.readdirSync(tmp).sort(); } catch (e: any) { + skips.push({ file: tmp, group: 'tmp', reason: `cannot list (${e?.code ?? 'error'}); the tmp group is skipped` }); + } + for (const name of names) if (name.startsWith(SCRATCH_PREFIX)) list.push({ file: path.join(tmp, name), group: 'tmp', kind: 'remove' }); } for (const name of BROWSE_LOGS) list.push({ file: path.join(target, name), group: 'browse', kind: 'cut' }); if (opts.auditDays !== null) list.push({ file: path.join(target, AUDIT_LOG), group: 'browse', kind: 'rotate' }); @@ -276,8 +285,16 @@ function rewriteInPlace(file: string, keep: Buffer): void { export function applyClean(target: string, planned: Action[], opts: CleanOptions): { done: Done[]; skips: Skip[] } { const done: Done[] = []; const skips: Skip[] = []; + const asked: Partial> = {}; + const busyNow = (group: Group): string | null => { + const last = asked[group]; + if (last && Date.now() - last.at < LIVENESS_RECHECK_MS) return last.busy; + const busy = busyFor(group, target); + asked[group] = { at: Date.now(), busy }; + return busy; + }; for (const p of planned) { - const fresh = evaluate({ file: p.file, group: p.group, kind: p.kind }, dirOf(p, target), busyFor(p.group, target), opts, Date.now()); + const fresh = evaluate({ file: p.file, group: p.group, kind: p.kind }, dirOf(p, target), busyNow(p.group), opts, Date.now()); if (!fresh) continue; if (!('bytes' in fresh)) { skips.push(fresh); continue; } try { diff --git a/test/gstack-clean.test.ts b/test/gstack-clean.test.ts index 094110fe8..55007ebc1 100644 --- a/test/gstack-clean.test.ts +++ b/test/gstack-clean.test.ts @@ -407,6 +407,20 @@ describe('gstack-clean', () => { expect(fs.readFileSync(outside).equals(before)).toBe(true); }); + test.skipIf(process.getuid?.() === 0)('an unlistable tmp/ skips the tmp group and the browse logs are still cut', () => { + const f = fixture(); + const log = put(path.join(f.gs, 'browse-console.log'), logText(1.2 * 1024 * 1024), 2 * 60 * 60 * 1000); + fs.chmodSync(path.join(f.gs, 'tmp'), 0o000); + try { + const r = run(f, ['--apply']); + expect(r.code).toBe(0); + expect(r.out).toMatch(/skip +tmp: cannot list/); + expect(fs.statSync(log).size).toBeLessThanOrEqual(256 * 1024); + } finally { + fs.chmodSync(path.join(f.gs, 'tmp'), 0o755); + } + }); + test('a log with no line break in its last 256 KB is left alone', () => { const f = fixture(); put(path.join(f.gs, 'browse-network.log'), 'x'.repeat(1.2 * 1024 * 1024), 2 * 60 * 60 * 1000); From 32f43717fe40ad5f67f9f03406d644396059f813 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 01:20:10 +0000 Subject: [PATCH 05/12] fix(clean): write output synchronously so --json reaches a pipe whole; test fixes for root, wrapper git and the ps stub Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- TODOS.md | 1 + changelog.d/capy-gsta226-e2-gstack-clean.md | 2 +- lib/repo-state-clean.ts | 10 ++++++- test/gstack-clean.test.ts | 31 ++++++++------------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/TODOS.md b/TODOS.md index 8e6f8c155..d687299bc 100644 --- a/TODOS.md +++ b/TODOS.md @@ -5,6 +5,7 @@ ### P3: gstack-clean follow-ups (filed 2026-10-31, GSTA-226 E2, #2627) - **Codex templates delete their own scratch.** The `/codex` review, challenge and consult blocks leave `codex-err-*`, `codex-resp-*`, `codex-out-*` and `codex-prompt-*` mktemp files under `TMP_ROOT` (which falls back to `.gstack/tmp`) and the `codex-prompt.*` and `codex-focus.*` free-text files in `.gstack/tmp`; `gstack-clean` removes them after 7 days. Delete each file at the end of its block instead. Skill prose for every user, so it needs its own review. **Effort:** S. **Priority:** P3. +- **Codex scratch lands at the git toplevel.** `bin/gstack-paths` falls back to the relative `.gstack/tmp` when `TMPDIR` and `TMP` are unset, so a /codex run from a subfolder leaves scratch in `/.gstack/tmp`, where `gstack-clean` does not look. Make the fallback `$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp`. **Effort:** S. **Priority:** P3. - **`gstack-clean` reclaims `browse-daemon.log.1`.** The browse CLI renames `browse-daemon.log` to `.1` past 10 MB and nothing ever removes the copy. Add it to the browse group (remove when older than an hour and the daemon is not running). **Effort:** S. **Priority:** P3. - **Browse hints `gstack-clean` when `.gstack/tmp` is large.** Print one line at daemon start when `.gstack/tmp` or the browse logs pass a size threshold, naming `gstack-clean`. **Effort:** S. **Priority:** P3. diff --git a/changelog.d/capy-gsta226-e2-gstack-clean.md b/changelog.d/capy-gsta226-e2-gstack-clean.md index 6cc9c5b5b..757d4d70c 100644 --- a/changelog.d/capy-gsta226-e2-gstack-clean.md +++ b/changelog.d/capy-gsta226-e2-gstack-clean.md @@ -6,5 +6,5 @@ - Result codes `GSTACK_CLEAN_USAGE`, `GSTACK_CLEAN_TARGET_REFUSED`, `GSTACK_CLEAN_PARTIAL` and `GSTACK_CLEAN_INTERNAL`, each with a docs/troubleshooting.md entry; BROWSER.md names the command where it lists the log files. ### Tests -- `test/gstack-clean.test.ts`: the dry run changes no byte or mtime, `--apply` removes exactly the allowlist, protected files survive at any age, every refusal touches nothing, links out of the folder, hard-linked and unreadable logs and a symlinked `tmp/` are left alone, a log with no line break in its tail and a malformed audit file are left alone, an unlistable `tmp/` skips only its group, a codex process elsewhere on the host cannot flake the tests, live and unknown liveness skip their group, the cut keeps the inode and a line boundary, audit trimming keeps the right lines byte for byte, exit codes 0, 1, 2 and 3, and the `--json` shape. +- `test/gstack-clean.test.ts`: the dry run changes no byte or mtime, `--apply` removes exactly the allowlist, protected files survive at any age, every refusal touches nothing, links out of the folder, hard-linked and unreadable logs and a symlinked `tmp/` are left alone, a log with no line break in its tail and a malformed audit file are left alone, an unlistable `tmp/` skips only its group, `--json` reaches a pipe whole with hundreds of actions, live and unknown liveness skip their group, the cut keeps the inode and a line boundary, audit trimming keeps the right lines byte for byte, exit codes 0, 1, 2 and 3, and the `--json` shape. - `test/gstack-clean-producers.test.ts`: every tracked producer of a `.gstack/tmp/` entry is classified as scratch or kept, and any new `codex-*` name must be added to the scratch list on purpose. diff --git a/lib/repo-state-clean.ts b/lib/repo-state-clean.ts index 6fc8ef128..e30336f75 100644 --- a/lib/repo-state-clean.ts +++ b/lib/repo-state-clean.ts @@ -377,9 +377,17 @@ function run(argv: string[], env: NodeJS.ProcessEnv, cwd: string, io: Io): numbe return report(io, opts, resolved, applied.done, [...plan.skips, ...applied.skips]); } +/** Synchronous, so a pipe gets every byte before the bin calls process.exit. */ +function writeAll(fd: number, text: string): void { + const buf = Buffer.from(text, 'utf8'); + for (let off = 0; off < buf.length;) { + try { off += fs.writeSync(fd, buf, off); } catch (e: any) { if (e?.code !== 'EAGAIN') throw e; } + } +} + /** The bin's entry. Never throws: an unexpected error prints one GSTACK_CLEAN_INTERNAL line and exits 1. */ export function main(argv: string[], env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): number { - const io: Io = { out: line => process.stdout.write(`${line}\n`), err: line => process.stderr.write(`${line}\n`) }; + const io: Io = { out: line => writeAll(1, `${line}\n`), err: line => writeAll(2, `${line}\n`) }; try { return run(argv, env, cwd, io); } catch (e) { diff --git a/test/gstack-clean.test.ts b/test/gstack-clean.test.ts index 55007ebc1..73972d5b5 100644 --- a/test/gstack-clean.test.ts +++ b/test/gstack-clean.test.ts @@ -310,12 +310,11 @@ describe('gstack-clean', () => { test('no process listing is unknown liveness and skips tmp/', () => { const f = fixture(); put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY); - const onlyGit = path.join(f.root, 'only-git'); - fs.mkdirSync(onlyGit); - const gitBin = spawnSync('bash', ['-c', 'command -v git'], { encoding: 'utf8', timeout: 10_000 }).stdout.trim(); - fs.symlinkSync(gitBin, path.join(onlyGit, 'git')); + const failingPs = path.join(f.root, 'failing-ps'); + fs.mkdirSync(failingPs); + fs.writeFileSync(path.join(failingPs, 'ps'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); const before = snapshot(path.join(f.repo)); - const r = run(f, ['--apply'], { env: { PATH: onlyGit } }); + const r = run(f, ['--apply'], { env: { PATH: failingPs + path.delimiter + (process.env.PATH ?? '') } }); expect(r.code).toBe(0); expect(r.out).toContain('skip tmp/codex-err-old: cannot list processes to tell whether codex is running'); expect(changedKeys(before, snapshot(f.repo))).toEqual([]); @@ -454,21 +453,13 @@ describe('gstack-clean', () => { } }); - test.skipIf(process.platform === 'win32')('tmp removal is unaffected by a codex process elsewhere on the host', async () => { + test('--json reaches a pipe whole with hundreds of actions', () => { const f = fixture(); - put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY); - const fake = path.join(f.root, 'fakebin', 'codex'); - put(fake, '#!/bin/sh\nsleep 30\n', 0); - fs.chmodSync(fake, 0o755); - const child = spawn(fake, [], { stdio: 'ignore' }); - try { - await Bun.sleep(300); - const r = run(f, ['--apply']); - expect(r.code).toBe(0); - expect(fs.existsSync(path.join(f.gs, 'tmp', 'codex-err-old'))).toBe(false); - } finally { - child.kill('SIGKILL'); - } + for (let i = 0; i < 300; i++) put(path.join(f.gs, 'tmp', 'codex-err-' + String(i).padStart(6, '0')), 'x', 30 * DAY); + const env: Record = { PATH: quietPsPath(f), HOME: f.home, GSTACK_HOME: f.state }; + const cmd = [process.execPath, ...bunIsolationArgs(), BIN, '--json'].map(a => "'" + a.replace(/'/g, "'\''") + "'").join(' ') + ' | (sleep 1; cat)'; + const r = spawnSync('sh', ['-c', cmd], { cwd: f.repo, env, encoding: 'utf8', timeout: 30_000 }); + expect(JSON.parse(r.stdout).actions.length).toBe(300); }); describe('exit codes', () => { @@ -485,7 +476,7 @@ describe('gstack-clean', () => { } }); - test('1: a read-only folder and a read-only file give FAILED lines; rerunning is safe', () => { + test.skipIf(process.getuid?.() === 0)('1: a read-only folder and a read-only file give FAILED lines; rerunning is safe', () => { const f = fixture(); put(path.join(f.gs, 'tmp', 'codex-err-old'), 'x', 30 * DAY); const log = put(path.join(f.gs, 'browse-dialog.log'), logText(1.1 * 1024 * 1024), 2 * 60 * 60 * 1000); From 1bca95029fc76f7597fccf3658ed20b844318bb2 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 01:26:23 +0000 Subject: [PATCH 06/12] fix(clean): every change rechecks the verified folder and the measured file; full writes; EPIPE ends output quietly Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/capy-gsta226-e2-gstack-clean.md | 4 +- lib/repo-state-clean.ts | 59 ++++++++++++++++----- test/gstack-clean.test.ts | 11 ++++ 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/changelog.d/capy-gsta226-e2-gstack-clean.md b/changelog.d/capy-gsta226-e2-gstack-clean.md index 757d4d70c..edfa9f6f7 100644 --- a/changelog.d/capy-gsta226-e2-gstack-clean.md +++ b/changelog.d/capy-gsta226-e2-gstack-clean.md @@ -2,9 +2,9 @@ ### Added - **`bin/gstack-clean`** (rules in `lib/repo-state-clean.ts`): cleans `/.gstack` only. It removes top-level `tmp/codex-*` files older than 7 days, cuts `browse-console.log`, `browse-network.log`, `browse-dialog.log`, `browse-daemon.log` and `browse-startup-error.log` to their last 256 KB once they pass 1 MB (in place, starting after a line break, so a running writer keeps appending), and with `--audit-days N` drops `browse-audit.jsonl` entries older than N days while keeping every line without a readable timestamp. Everything else, including `browse.json`, `session-state.json`, `chromium-profile/`, `browser-skills/`, `browse-states/` and the other `tmp/` entries, is never touched. -- **Safety:** it refuses (exit 3) outside git, when `.gstack` is a symlink, when `BROWSE_STATE_FILE` names another folder, and when the folder is, holds or sits inside the gstack state root. It skips symlinks, logs with more than one hard link, files it cannot read and files changed in the last hour, skips browse files while the browse daemon runs and `tmp/` while codex runs, and skips a group when it cannot tell. `--json` prints the same result as one versioned object. +- **Safety:** it refuses (exit 3) outside git, when `.gstack` is a symlink, when `BROWSE_STATE_FILE` names another folder, and when the folder is, holds or sits inside the gstack state root. It skips symlinks, logs with more than one hard link, files it cannot read and files changed in the last hour, skips browse files while the browse daemon runs and `tmp/` while codex runs, and skips a group when it cannot tell. Right before each change it checks that the folder is still the one it verified and that the file still has the size, time and inode it measured, and it stops that change if either moved. `--json` prints the same result as one versioned object. - Result codes `GSTACK_CLEAN_USAGE`, `GSTACK_CLEAN_TARGET_REFUSED`, `GSTACK_CLEAN_PARTIAL` and `GSTACK_CLEAN_INTERNAL`, each with a docs/troubleshooting.md entry; BROWSER.md names the command where it lists the log files. ### Tests -- `test/gstack-clean.test.ts`: the dry run changes no byte or mtime, `--apply` removes exactly the allowlist, protected files survive at any age, every refusal touches nothing, links out of the folder, hard-linked and unreadable logs and a symlinked `tmp/` are left alone, a log with no line break in its tail and a malformed audit file are left alone, an unlistable `tmp/` skips only its group, `--json` reaches a pipe whole with hundreds of actions, live and unknown liveness skip their group, the cut keeps the inode and a line boundary, audit trimming keeps the right lines byte for byte, exit codes 0, 1, 2 and 3, and the `--json` shape. +- `test/gstack-clean.test.ts`: the dry run changes no byte or mtime, `--apply` removes exactly the allowlist, protected files survive at any age, every refusal touches nothing, links out of the folder, hard-linked and unreadable logs and a symlinked `tmp/` are left alone, a log with no line break in its tail and a malformed audit file are left alone, an unlistable `tmp/` skips only its group, `--json` reaches a pipe whole with hundreds of actions and a reader that closes early is not an error, live and unknown liveness skip their group, the cut keeps the inode and a line boundary, audit trimming keeps the right lines byte for byte, exit codes 0, 1, 2 and 3, and the `--json` shape. - `test/gstack-clean-producers.test.ts`: every tracked producer of a `.gstack/tmp/` entry is classified as scratch or kept, and any new `codex-*` name must be added to the scratch list on purpose. diff --git a/lib/repo-state-clean.ts b/lib/repo-state-clean.ts index e30336f75..dfc866413 100644 --- a/lib/repo-state-clean.ts +++ b/lib/repo-state-clean.ts @@ -71,7 +71,8 @@ type Group = 'tmp' | 'browse'; type Kind = 'remove' | 'cut' | 'rotate'; export interface CleanOptions { apply: boolean; json: boolean; auditDays: number | null } interface Candidate { file: string; group: Group; kind: Kind } -interface Action extends Candidate { bytes: number; reason: string; keep?: Buffer } +interface Seen { dev: number; ino: number; size: number; mtimeMs: number } +interface Action extends Candidate { bytes: number; reason: string; keep?: Buffer; seen: Seen } interface Skip { file: string; group: Group; reason: string } interface Done { action: Action; errno?: string } export interface CleanTarget { target: string; chosenBy: string } @@ -211,7 +212,8 @@ function evaluate(c: Candidate, dir: string, busy: string | null, opts: CleanOpt if (c.kind === 'cut' && st.size <= LOG_CUT_OVER_BYTES) return null; if (now - st.mtimeMs < RECENT_MS) return { ...c, reason: 'modified within the last hour' }; if (busy) return { ...c, reason: busy }; - if (c.kind === 'remove') return { ...c, bytes: st.size, reason: 'codex scratch older than 7 days' }; + const seen: Seen = { dev: st.dev, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs }; + if (c.kind === 'remove') return { ...c, seen, bytes: st.size, reason: 'codex scratch older than 7 days' }; if (st.nlink !== 1) return { ...c, reason: `has ${st.nlink} hard links, so cutting it would change a file outside this folder; left alone` }; let content: Buffer; try { content = c.kind === 'cut' ? readTail(c.file, st.size) : fs.readFileSync(c.file); } catch (e: any) { @@ -222,12 +224,15 @@ function evaluate(c: Candidate, dir: string, busy: string | null, opts: CleanOpt const at = tail.indexOf(0x0a); if (at < 0) return { ...c, reason: 'no line break in its last 256 KB; left alone' }; const keep = tail.subarray(at + 1); - return { ...c, keep, bytes: st.size - keep.length, reason: 'over 1 MB; keeps the last 256 KB from a line start' }; + return { ...c, seen, keep, bytes: st.size - keep.length, reason: 'over 1 MB; keeps the last 256 KB from a line start' }; + } + let keep: Buffer | string; + try { keep = auditKeep(content, now - opts.auditDays! * DAY_MS); } catch (e: any) { + return { ...c, reason: `cannot read (${e?.code ?? e?.name ?? 'error'}); left alone` }; } - const keep = auditKeep(content, now - opts.auditDays! * DAY_MS); if (typeof keep === 'string') return { ...c, reason: keep }; if (keep.length === st.size) return null; - return { ...c, keep, bytes: st.size - keep.length, reason: `audit entries older than ${opts.auditDays} days` }; + return { ...c, seen, keep, bytes: st.size - keep.length, reason: `audit entries older than ${opts.auditDays} days` }; } function candidates(target: string, opts: CleanOptions): { list: Candidate[]; skips: Skip[] } { @@ -270,17 +275,35 @@ export function planClean(target: string, opts: CleanOptions, now = Date.now()): return { actions, skips }; } -function rewriteInPlace(file: string, keep: Buffer): void { - const before = fs.lstatSync(file); +const changed = () => Object.assign(new Error('the file changed after it was checked; rerunning is safe'), { code: 'ECHANGED' }); +const same = (st: fs.Stats, seen: Seen) => st.dev === seen.dev && st.ino === seen.ino && st.size === seen.size && st.mtimeMs === seen.mtimeMs; + +function writeFully(fd: number, buf: Buffer, position: number): void { + for (let off = 0; off < buf.length;) { + const n = fs.writeSync(fd, buf, off, buf.length - off, position + off); + if (n <= 0) throw Object.assign(new Error('short write'), { code: 'EIO' }); + off += n; + } +} + +/** The parent folder is still the verified real folder, so a swapped link cannot redirect the change. */ +function parentIntact(file: string, dir: string): boolean { + const parent = path.dirname(file); + try { return !fs.lstatSync(parent).isSymbolicLink() && fs.realpathSync(parent) === dir; } catch { return false; } +} + +/** A cut or trim keeps the inode, so O_APPEND writers go on; refused unless the file is exactly what evaluate saw. */ +function cutInPlace(file: string, keep: Buffer, seen: Seen): void { const fd = fs.openSync(file, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW ?? 0)); try { const st = fs.fstatSync(fd); - if (st.ino !== before.ino || st.dev !== before.dev || st.nlink !== 1) throw Object.assign(new Error('the file changed before it could be cut'), { code: 'ECHANGED' }); - fs.writeSync(fd, keep, 0, keep.length, 0); + if (st.nlink !== 1 || !same(st, seen)) throw changed(); + writeFully(fd, keep, 0); fs.ftruncateSync(fd, keep.length); } finally { fs.closeSync(fd); } } + /** Re-checks each planned action against its file and its group's liveness right before the change. */ export function applyClean(target: string, planned: Action[], opts: CleanOptions): { done: Done[]; skips: Skip[] } { const done: Done[] = []; @@ -298,8 +321,11 @@ export function applyClean(target: string, planned: Action[], opts: CleanOptions if (!fresh) continue; if (!('bytes' in fresh)) { skips.push(fresh); continue; } try { - if (fresh.kind === 'remove') fs.unlinkSync(fresh.file); - else rewriteInPlace(fresh.file, fresh.keep!); + if (!parentIntact(fresh.file, dirOf(fresh, target))) throw changed(); + if (fresh.kind === 'remove') { + if (!same(fs.lstatSync(fresh.file), fresh.seen)) throw changed(); + fs.unlinkSync(fresh.file); + } else cutInPlace(fresh.file, fresh.keep!, fresh.seen); done.push({ action: fresh }); } catch (e: any) { done.push({ action: fresh, errno: e?.code ?? 'UNKNOWN' }); @@ -367,7 +393,7 @@ function run(argv: string[], env: NodeJS.ProcessEnv, cwd: string, io: Io): numbe return 3; } if ('missing' in resolved) { - if (opts.json) io.out(JSON.stringify({ schema_version: CLEAN_SCHEMA_VERSION, tool: 'gstack-clean', mode: opts.apply ? 'apply' : 'dry-run', status: 'nothing_to_do', target: resolved.missing, actions: [], skips: [], total: { actions: 0, bytes: 0, failed: 0, skipped: 0 }, error: null, exit_code: 0 }, null, 2)); + if (opts.json) io.out(JSON.stringify({ schema_version: CLEAN_SCHEMA_VERSION, tool: 'gstack-clean', mode: opts.apply ? 'apply' : 'dry-run', status: 'nothing_to_do', target: resolved.missing, chosen_by: 'the .gstack folder at the git toplevel (missing)', audit_days: opts.auditDays, actions: [], skips: [], total: { actions: 0, bytes: 0, failed: 0, skipped: 0 }, error: null, exit_code: 0 }, null, 2)); else io.out(`nothing to clean: ${resolved.missing} does not exist`); return 0; } @@ -378,10 +404,15 @@ function run(argv: string[], env: NodeJS.ProcessEnv, cwd: string, io: Io): numbe } /** Synchronous, so a pipe gets every byte before the bin calls process.exit. */ +/** A reader that went away (EPIPE) ends the output quietly; the exit code still reports what was done. */ function writeAll(fd: number, text: string): void { const buf = Buffer.from(text, 'utf8'); for (let off = 0; off < buf.length;) { - try { off += fs.writeSync(fd, buf, off); } catch (e: any) { if (e?.code !== 'EAGAIN') throw e; } + try { off += fs.writeSync(fd, buf, off); } catch (e: any) { + if (e?.code === 'EPIPE') return; + if (e?.code !== 'EAGAIN') throw e; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); + } } } @@ -392,7 +423,7 @@ export function main(argv: string[], env: NodeJS.ProcessEnv = process.env, cwd = return run(argv, env, cwd, io); } catch (e) { const err = e instanceof Error ? e : new Error(String(e)); - io.err(resultLine('gstack-clean', 'GSTACK_CLEAN_INTERNAL', `${err.name}: ${err.message}`.slice(0, 300))); + try { io.err(resultLine('gstack-clean', 'GSTACK_CLEAN_INTERNAL', `${err.name}: ${err.message}`.slice(0, 300))); } catch { /* stderr is gone too */ } return 1; } } diff --git a/test/gstack-clean.test.ts b/test/gstack-clean.test.ts index 73972d5b5..9907dfc51 100644 --- a/test/gstack-clean.test.ts +++ b/test/gstack-clean.test.ts @@ -453,6 +453,17 @@ describe('gstack-clean', () => { } }); + test('a reader that closes early does not turn a finished run into an internal error', () => { + const f = fixture(); + for (let i = 0; i < 300; i++) put(path.join(f.gs, 'tmp', 'codex-err-' + String(i).padStart(6, '0')), 'x', 30 * DAY); + const env: Record = { PATH: quietPsPath(f), HOME: f.home, GSTACK_HOME: f.state }; + const cmd = [process.execPath, ...bunIsolationArgs(), BIN, '--apply', '--json'].map(a => "'" + a.replace(/'/g, "'\\''") + "'").join(' ') + " 2>'" + path.join(f.root, 'err.txt') + "' | head -c 1 >/dev/null"; + const r = spawnSync('bash', ['-c', cmd], { cwd: f.repo, env, encoding: 'utf8', timeout: 30_000 }); + expect(r.status).toBe(0); + expect(fs.readFileSync(path.join(f.root, 'err.txt'), 'utf8')).not.toContain('GSTACK_CLEAN_INTERNAL'); + expect(fs.readdirSync(path.join(f.gs, 'tmp')).filter(n => n.startsWith('codex-'))).toEqual([]); + }); + test('--json reaches a pipe whole with hundreds of actions', () => { const f = fixture(); for (let i = 0; i < 300; i++) put(path.join(f.gs, 'tmp', 'codex-err-' + String(i).padStart(6, '0')), 'x', 30 * DAY); From 8cfec650affd3d4c689a8b5c5931a58375f4c82d Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 01:29:10 +0000 Subject: [PATCH 07/12] test(clean): the early-close test checks the bin's own exit status Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/gstack-clean.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/gstack-clean.test.ts b/test/gstack-clean.test.ts index 9907dfc51..1937cd81d 100644 --- a/test/gstack-clean.test.ts +++ b/test/gstack-clean.test.ts @@ -458,7 +458,7 @@ describe('gstack-clean', () => { for (let i = 0; i < 300; i++) put(path.join(f.gs, 'tmp', 'codex-err-' + String(i).padStart(6, '0')), 'x', 30 * DAY); const env: Record = { PATH: quietPsPath(f), HOME: f.home, GSTACK_HOME: f.state }; const cmd = [process.execPath, ...bunIsolationArgs(), BIN, '--apply', '--json'].map(a => "'" + a.replace(/'/g, "'\\''") + "'").join(' ') + " 2>'" + path.join(f.root, 'err.txt') + "' | head -c 1 >/dev/null"; - const r = spawnSync('bash', ['-c', cmd], { cwd: f.repo, env, encoding: 'utf8', timeout: 30_000 }); + const r = spawnSync('bash', ['-c', 'set -o pipefail; ' + cmd], { cwd: f.repo, env, encoding: 'utf8', timeout: 30_000 }); expect(r.status).toBe(0); expect(fs.readFileSync(path.join(f.root, 'err.txt'), 'utf8')).not.toContain('GSTACK_CLEAN_INTERNAL'); expect(fs.readdirSync(path.join(f.gs, 'tmp')).filter(n => n.startsWith('codex-'))).toEqual([]); From f0f633d2a8c4a848a2250824c98dfcdc140c4132 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 01:35:20 +0000 Subject: [PATCH 08/12] docs: list gstack-clean among the standalone CLIs Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/standalone-binaries.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/standalone-binaries.md b/docs/standalone-binaries.md index e160f5549..40074838d 100644 --- a/docs/standalone-binaries.md +++ b/docs/standalone-binaries.md @@ -14,6 +14,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that | `gstack-memorable` | **Memorable recall bridge (opt-in, third party, Claude Code only)** — connects Claude Code to the external [Memorable](https://memorable.sh) CLI *through gstack* instead of the vendor's own installer, so the hook gets gstack's guarantees: an explicit consent key (`memorable_recall`, off by default, listed by `gstack-egress grants`), a fail-closed egress receipt for every prompt handed over (`gstack-egress list --sink memorable-recall`), a HIGH-tier secret pre-scan, a trust envelope and 8 KiB cap on whatever comes back, an allowlisted environment and process-group containment for the vendor process, and clean removal. `enable` registers the hook at the stable install with a 5 s timeout and never runs the vendor's own consent command; `disable` revokes the gate first and removes the entry by identity even after Claude Code strips the tag; `status` is read-only. gstack never installs Memorable, and what its binary sends is the vendor's claim, not gstack's. Not available on Windows yet. [Full guide](memorable-workflow-memory.md). | | `gstack-decision-trail` | **Decision trail** — append one row (ts, phase, decision, why, evidence, result) to a run's TSV trail so a reviewer reads what was chosen and why without the transcript. Header once, append-only, cells flattened to one line, and a cell starting with `=`, `+`, `-` or `@` quoted so a spreadsheet cannot run it. `start ` opens a run's stretch of a shared trail (idempotent; `start` is reserved as a phase name), and `--run ` refuses an append until that start row is the newest. Ported from pstack's `log.sh` (MIT, NOTICE.md). [Rule](unattended.md#the-decision-trail). | | `gstack-worktree-audit` | **Worktree audit** — read-only table of every git worktree beside the main one, largest first: size, age, merged into the base or not, `clean`/`clean,ignored:`/`wip:`/`scratch:`, `pushed`/`ahead`/`no-remote`/`detached`, the PR on its branch through `gh` (fork PRs ignored), the newest Claude Code chat in it, and a bucket (`prunable` for an unlocked registered worktree whose directory is gone, `review` for a locked or `broken` one, `hold-wip`, `hold-open-pr`, `verify-recent-chat`, `safe` only for a clean tree that is merged into the base or whose PR is closed or merged with every commit pushed, else `review`). `-b origin/` sets the base, `--no-fetch` skips the best-effort fetch of the base ref (its one write). Never deletes. Ported from pstack's `worktree-audit.sh` (MIT, NOTICE.md). [Guide](CONTRIBUTOR_REFERENCE.md#auditing-conductor-worktrees). | +| `gstack-clean` | **Per-repo `.gstack/` cleanup** — run inside a repository to see what it would reclaim from that repository's `.gstack/` folder; `--apply` does it. It removes `tmp/codex-*` scratch files older than 7 days, cuts the browse logs (`browse-console.log`, `browse-network.log`, `browse-dialog.log`, `browse-daemon.log`, `browse-startup-error.log`) to their last 256 KB once they pass 1 MB, and trims `browse-audit.jsonl` only with `--audit-days N`. It never touches `browse.json`, `session-state.json`, `chromium-profile/`, `browser-skills/` or `browse-states/`, skips browse files while the daemon runs and `tmp/` while codex runs, and refuses (exit 3) outside git, when `.gstack` is a symlink, when `BROWSE_STATE_FILE` names another folder, and when the folder is, holds or sits inside the gstack state root. `--json` prints one versioned object. Exit 0 done, 1 an action failed (rerunning is safe), 2 usage, 3 refused. [Error codes](troubleshooting.md#gstack-clean-usage). | | `gstack-wtree` | **Working-tree fingerprint** — prints a content hash of what's actually on disk (temp index seeded from the stat cache, ~40x cheaper than a full re-hash; untracked source counts, gitignored scratch doesn't). Identical content fingerprints identically through commits, rebases, amends, and squashes — it's what binds reviews and test evidence to content instead of commit SHAs. | | `gstack-review-log` | **Review-pass receipts** — `--start ` captures the working-tree fingerprint before a diff review; `'' --finish ` consumes that single-use, repository/branch/skill-scoped receipt. Binding requires matching start/end content and reviewer-reported `completed:true` and `converged:true`; it is not independent proof that a model read the source. `--check-shared-libs ` reads a current finding as JSON on stdin and checks prior Skip decisions against the actual branch, capture and eligible raw source blobs. It returns `reusable:false` when proof is missing or unsafe, including older records without logger-versioned coverage. Final review logging computes shared-code fingerprints and coverage rather than trusting supplied proof. | | `gstack-review-read` | **Review freshness** — emits review records with computed `review_freshness.status` and `reason`: CURRENT, STALE, or UNVERIFIED for diff reviews. `/ship` and `/land-and-deploy` use the same grade; a matching commit alone never certifies a diff review. [Dashboard rules](skills.md#review-readiness-dashboard). | From 01bf5fa74fd2841f21230a865fef56888ef87840 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 01:47:49 +0000 Subject: [PATCH 09/12] test(clean): copied-install fixtures and the dependency list carry lib/result-codes-clean.ts The remote pregate failed 11 cases in gstack-doctor, plan-tune-cathedral and timeline-stop-hook-slug: each copies an explicit list of lib/result-codes-*.ts files into a temp install, and lib/result-codes.ts now imports ./result-codes-clean. Same lists GSTA-168 extended for result-codes-eval. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- scripts/pr-dependencies.ts | 2 +- test/gstack-doctor.test.ts | 2 +- test/plan-tune-cathedral.test.ts | 2 +- test/timeline-stop-hook-slug.test.ts | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/pr-dependencies.ts b/scripts/pr-dependencies.ts index e40ae2c35..5564d3792 100644 --- a/scripts/pr-dependencies.ts +++ b/scripts/pr-dependencies.ts @@ -89,7 +89,7 @@ export const SELECTION_DATA_MODULES: readonly string[] = [ // The build entry list: every compiled binary, which skills reach through their `--outfile` path instead. 'scripts/build.sh', // The reason-code table: its `fix:` prose names the command a person reruns, not a file the module consumes. - 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', + 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-clean.ts', // The exercised-path map: observed commands per case, read only by exercisedConsumers() (shadow data; a change selects no paid case). 'test/helpers/exercised-commands.json', ]; diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index 1becf6c50..21892cd6d 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -27,7 +27,7 @@ const COPIED = [ 'autoplan/SKILL.md', // its frontmatter registers the autoplan hook the hooks-row test breaks 'bin/gstack-launch-probe.sh', 'bin/gstack-doctor-check.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts', - 'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', // the windows bash row + 'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-clean.ts', // the windows bash row ]; const bases: string[] = []; afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); diff --git a/test/plan-tune-cathedral.test.ts b/test/plan-tune-cathedral.test.ts index 20109dc42..4a6d360ab 100644 --- a/test/plan-tune-cathedral.test.ts +++ b/test/plan-tune-cathedral.test.ts @@ -70,7 +70,7 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string; // These imports are resolved relative to the copied install, not the source checkout. const libDir = path.join(workDir, 'lib'); fs.mkdirSync(libDir, { recursive: true }); - for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts', 'result-codes-budget.ts', 'result-codes-eval.ts']) { + for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts', 'result-codes-budget.ts', 'result-codes-eval.ts', 'result-codes-clean.ts']) { fs.copyFileSync(path.join(ROOT, 'lib', file), path.join(libDir, file)); } diff --git a/test/timeline-stop-hook-slug.test.ts b/test/timeline-stop-hook-slug.test.ts index 857df2304..82f7c5623 100644 --- a/test/timeline-stop-hook-slug.test.ts +++ b/test/timeline-stop-hook-slug.test.ts @@ -34,6 +34,7 @@ const HOOK_FILES = [ 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', + 'lib/result-codes-clean.ts', ]; const SLUG = 'stop-hook-slug-test'; const BUDGET_MS = 2000; From c3c00d924eb85f0936282ff88dfaf1853fec642b Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 02:08:08 +0000 Subject: [PATCH 10/12] chore: regenerate gstack-doctor-components.sh for the gstack-clean result codes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-doctor-components.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 3fa027cb3..cdb8f7266 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -362,6 +362,10 @@ gstack_result_anchor() { PREREG_NO_GSTACK_ARM) printf '%s' 'prereg-no-gstack-arm' ;; EVAL_REPORT_NO_GSTACK_ROW) printf '%s' 'eval-report-no-gstack-row' ;; EVAL_ARM_DUPLICATE) printf '%s' 'eval-arm-duplicate' ;; + GSTACK_CLEAN_USAGE) printf '%s' 'gstack-clean-usage' ;; + GSTACK_CLEAN_TARGET_REFUSED) printf '%s' 'gstack-clean-target-refused' ;; + GSTACK_CLEAN_PARTIAL) printf '%s' 'gstack-clean-partial' ;; + GSTACK_CLEAN_INTERNAL) printf '%s' 'gstack-clean-internal' ;; *) return 1 ;; esac } From 5ee5b51dc33ba1d8db007d495f579dec2e3f4958 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 08:19:55 +0000 Subject: [PATCH 11/12] test(clean): classify gstack-tasks rows files (tasks-*) as kept, after main added the producer Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/gstack-clean-producers.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/gstack-clean-producers.test.ts b/test/gstack-clean-producers.test.ts index ba3cef654..eb8fd2648 100644 --- a/test/gstack-clean-producers.test.ts +++ b/test/gstack-clean-producers.test.ts @@ -39,6 +39,7 @@ const KEPT: Record = { 'base-red.json': '/ship base-red record', 'review-*': '/ship review loop records', 'fix-check-*': '/ship fix-check inputs', + 'tasks-*': 'gstack-tasks rows files (bin/gstack-tasks)', 'autoplan': 'autoplan phase artifacts (autoplan/SKILL.md.tmpl)', 'eval-bisect': '/investigate eval bisect cache', 'price-gate': 'gstack-eval-plan price receipts', From 28db5dce30512f8e70df01f8c591d71196b2e662 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 10:55:29 +0000 Subject: [PATCH 12/12] test(clean): the producer scan ends a name at a colon, so prose after a path names the file (train 78) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/gstack-clean-producers.test.ts | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/test/gstack-clean-producers.test.ts b/test/gstack-clean-producers.test.ts index eb8fd2648..0d83445ec 100644 --- a/test/gstack-clean-producers.test.ts +++ b/test/gstack-clean-producers.test.ts @@ -60,19 +60,24 @@ function trackedSources(): string[] { return r.stdout.split('\0').filter(f => f && !/(^|\/)test\//.test(f) && !f.endsWith('.test.ts') && fs.existsSync(path.join(ROOT, f))); } +/** The .gstack/tmp entry names one source text can create, each with its offset. */ +function namesIn(text: string): Array<[string, number]> { + const out: Array<[string, number]> = []; + for (const m of text.matchAll(/\.gstack\/tmp\/([^\s"'`)\]}$;:,|&*\\]*)/g)) out.push([m[1]!.split('/')[0]!.replace(/\.+$/, ''), m.index!]); + for (const m of text.matchAll(/['"]\.gstack['"],\s*['"]tmp['"](?:,\s*['"]([^'"]+)['"])?/g)) out.push([m[1] ?? '', m.index!]); + for (const m of text.matchAll(/\$\{?TMP_ROOT\}?\/([A-Za-z0-9._<>-]+)/g)) out.push([m[1]!, m.index!]); + for (const m of text.matchAll(/\{\{FREE_TEXT_FILE(?::([^}]*))?\}\}/g)) { + for (const spec of (m[1] ?? 'TEXT_FILE=text').split(':')) out.push([(spec.split('=')[1] || 'text') + '.XXXXXX', m.index!]); + } + return out; +} + /** Every .gstack/tmp entry name a source can create, with where it was seen. */ function producers(): Map { const found = new Map(); - const add = (name: string, where: string) => { if (name && !found.has(name)) found.set(name, where); }; for (const file of trackedSources()) { const text = fs.readFileSync(path.join(ROOT, file), 'utf8'); - const at = (index: number) => file + ':' + (text.slice(0, index).split('\n').length); - for (const m of text.matchAll(/\.gstack\/tmp\/([^\s"'`)\]}$;,|&*\\]*)/g)) add(m[1]!.split('/')[0]!.replace(/\.+$/, ''), at(m.index!)); - for (const m of text.matchAll(/['"]\.gstack['"],\s*['"]tmp['"](?:,\s*['"]([^'"]+)['"])?/g)) add(m[1] ?? '', at(m.index!)); - for (const m of text.matchAll(/\$\{?TMP_ROOT\}?\/([A-Za-z0-9._<>-]+)/g)) add(m[1]!, at(m.index!)); - for (const m of text.matchAll(/\{\{FREE_TEXT_FILE(?::([^}]*))?\}\}/g)) { - for (const spec of (m[1] ?? 'TEXT_FILE=text').split(':')) add((spec.split('=')[1] || 'text') + '.XXXXXX', at(m.index!)); - } + for (const [name, index] of namesIn(text)) if (name && !found.has(name)) found.set(name, file + ':' + (text.slice(0, index).split('\n').length)); } return found; } @@ -80,6 +85,10 @@ function producers(): Map { describe('gstack-clean producer classification', () => { const found = producers(); + test('a path followed by prose punctuation names the file, not the punctuation', () => { + expect(namesIn('r.stop(`.gstack/tmp/x.json: ${message}`)').map(([name]) => name)).toEqual(['x.json']); + }); + test('the scan sees the known producers', () => { for (const name of ['restamp-journal.json', 'pregate.json', 'ship-queue', 'autoplan', 'codex-err-XXXXXX']) expect(found.has(name)).toBe(true); });