#!/usr/bin/env node // lib/util/test-lock.mjs // K8 -- the test lock. The executor (wave-1 order 1) locks a step's test files // once the test is written and seen failing, and unlocks them when the test // passes. Two guards read the lock: // - pre-write-executor.mjs refuses Write/Edit/NotebookEdit on a locked file; // - stop-test-lock.mjs refuses "done" while the session that took the lock // still holds it. // The lock lives in the repository's git directory (/voyage/ // test-lock.json), so it can never be committed, and each worktree has its own. // // Usage: // node lib/util/test-lock.mjs lock --files --step [--cwd ] // node lib/util/test-lock.mjs unlock [--cwd ] // node lib/util/test-lock.mjs status [--cwd ] (JSON on stdout) // Exit: 0 = done, 1 = refused (another step holds the lock), 2 = could not // run (not a repository, bad usage, unreadable lock). 2 is never "unlocked". // Paths in --files are relative to the --cwd (default: the current directory). // The session is taken from CLAUDE_CODE_SESSION_ID, which Claude Code sets in // Bash tool subprocesses (code.claude.com/docs/en/env-vars). // // Credit: locking tests during a fix is Claude Code's own hook pattern // (hooks-guide "Block edits to protected files"). import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, realpathSync, readdirSync } from 'node:fs'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import { resolveRepo, canonicalPath } from '../verification/repo-tools.mjs'; export const LOCK_VERSION = 1; export function lockPathFor(gitDir) { return join(gitDir, 'voyage', 'test-lock.json'); } /** * Read the lock for the repo that `start` (a file or directory) belongs to. * @returns {{ state: 'no-repo'|'none'|'locked'|'corrupt', lock?: object, path?: string, error?: string }} */ export function readLock(start) { const repo = resolveRepo(start); if (!repo) return { state: 'no-repo' }; const path = lockPathFor(repo.gitDir); if (!existsSync(path)) return { state: 'none', path, repo }; try { const lock = JSON.parse(readFileSync(path, 'utf8')); if (!lock || !Array.isArray(lock.files) || !Number.isInteger(lock.step)) { return { state: 'corrupt', path, repo, error: 'lock has no files/step' }; } return { state: 'locked', lock, path, repo }; } catch (e) { return { state: 'corrupt', path, repo, error: e.message }; } } // A pointer from the session that took a lock to the lock file, so the stop // guard finds it even after the session has moved to another directory. const SESSION_DIR = join(tmpdir(), `voyage-test-locks-${process.getuid ? process.getuid() : 'u'}`); const sessionPointer = (sessionId) => join(SESSION_DIR, `${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 32)}.json`); // Pointers whose lock is gone are dropped on the next lock. function sweepPointers() { try { for (const f of readdirSync(SESSION_DIR)) { const p = join(SESSION_DIR, f); try { if (!existsSync(JSON.parse(readFileSync(p, 'utf8')).lock_path)) rmSync(p, { force: true }); } catch { rmSync(p, { force: true }); } } } catch { /* no dir yet */ } } function writeSessionPointer(sessionId, lockPath) { if (!sessionId) return; try { mkdirSync(SESSION_DIR, { recursive: true }); sweepPointers(); writeFileSync(sessionPointer(sessionId), JSON.stringify({ lock_path: lockPath })); } catch { /* the cwd lookup still works */ } } function removeSessionPointer(sessionId) { if (sessionId) rmSync(sessionPointer(sessionId), { force: true }); } /** * The lock this session holds, wherever it lives: { state: 'locked', lock, path } * or { state: 'none' }. A pointer to a lock that is gone, or that another * session now holds, is not this session's lock. */ export function lockForSession(sessionId) { if (!sessionId) return { state: 'none' }; try { const { lock_path } = JSON.parse(readFileSync(sessionPointer(sessionId), 'utf8')); const lock = JSON.parse(readFileSync(lock_path, 'utf8')); if (lock?.session === sessionId && Array.isArray(lock.files) && Number.isInteger(lock.step)) { return { state: 'locked', lock, path: lock_path }; } } catch { /* no pointer, or the lock is gone */ } return { state: 'none' }; } // Is `file` one of the locked files? Compared as canonical real paths. export function isLockedFile(lock, file) { const c = canonicalPath(file); return lock.files.includes(c); } function parseArgs(argv) { const out = { _: [] }; for (let i = 0; i < argv.length; i += 1) { const a = argv[i]; if (a === '--files' || a === '--step' || a === '--cwd') { out[a.slice(2)] = argv[i + 1]; i += 1; } else out._.push(a); } return out; } function fail(code, msg) { process.stderr.write(`[voyage] test-lock: ${msg}\n`); process.exit(code); } function main(argv) { const args = parseArgs(argv); const cmd = args._[0]; if (!['lock', 'unlock', 'status'].includes(cmd)) fail(2, 'usage: test-lock.mjs lock --files --step | unlock | status [--cwd ]'); const cwd = resolve(args.cwd || process.cwd()); const cur = readLock(cwd); if (cur.state === 'no-repo') fail(2, `not a git repository: ${cwd}`); if (cmd === 'status') { if (cur.state === 'corrupt') { process.stdout.write(JSON.stringify({ locked: null, state: 'corrupt', lock_path: cur.path, error: cur.error }, null, 2) + '\n'); process.exit(2); } const l = cur.lock || {}; process.stdout.write(JSON.stringify({ locked: cur.state === 'locked', step: l.step ?? null, files: l.files ?? [], session: l.session ?? null, locked_at: l.locked_at ?? null, lock_path: cur.path, }, null, 2) + '\n'); process.exit(0); } if (cmd === 'unlock') { if (cur.state === 'corrupt') fail(2, `unreadable lock ${cur.path} (${cur.error}); inspect it before removing it`); removeSessionPointer(cur.lock?.session); rmSync(cur.path, { force: true }); process.exit(0); } // lock if (!args.files) fail(2, 'lock needs --files '); if (args.step === undefined || !/^\d+$/.test(String(args.step))) fail(2, 'lock needs --step (a whole number)'); const step = Number(args.step); if (cur.state === 'corrupt') fail(2, `unreadable lock ${cur.path} (${cur.error})`); if (cur.state === 'locked' && cur.lock.step !== step) { fail(1, `step ${cur.lock.step} holds the lock; unlock it first`); } const files = String(args.files).split(',').map((f) => f.trim()).filter(Boolean).map((f) => canonicalPath(resolve(cwd, f))); if (files.length === 0) fail(2, 'lock needs at least one file'); const lock = { version: LOCK_VERSION, step, files: [...new Set(files)], session: process.env.CLAUDE_CODE_SESSION_ID || null, locked_at: new Date().toISOString(), }; mkdirSync(dirname(cur.path), { recursive: true }); writeFileSync(cur.path, JSON.stringify(lock, null, 2) + '\n'); writeSessionPointer(lock.session, cur.path); process.exit(0); } function isMain() { try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } } if (isMain()) main(process.argv.slice(2));