sandlot/src/state.ts
Chris Wanstrath da7adc674d Fix lock race condition, scan depth off-by-one, and minor cleanups
Close TOCTOU window in withGlobalLock by retrying mkdir immediately
after removing a stale lock. Fix off-by-one in scanAndRegister where
maxDepth was exceeded by one level. Export normalizePath to eliminate
duplicate logic in list.ts, use a Set for faster dedup in scan, and
simplify the styles map to a plain object literal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 18:27:09 -07:00

232 lines
6.7 KiB
TypeScript

import { join, basename, resolve } from "path"
import { readdir, rename, mkdir, rmdir, stat } from "fs/promises"
import { homedir } from "os"
export interface Session {
branch: string
worktree: string
created_at: string
prompt?: string
in_review?: boolean
}
export interface State {
sessions: Record<string, Session>
}
function statePath(repoRoot: string): string {
return join(repoRoot, ".sandlot", "state.json")
}
export async function load(repoRoot: string): Promise<State> {
const path = statePath(repoRoot)
const file = Bun.file(path)
if (await file.exists()) {
return await file.json()
}
return { sessions: {} }
}
export async function save(repoRoot: string, state: State): Promise<void> {
const path = statePath(repoRoot)
const tmpPath = path + ".tmp"
const dir = join(repoRoot, ".sandlot")
await Bun.write(join(dir, ".gitkeep"), "") // ensure dir exists
await Bun.write(tmpPath, JSON.stringify(state, null, 2) + "\n")
await rename(tmpPath, path)
}
export async function getSession(repoRoot: string, branch: string): Promise<Session | undefined> {
const state = await load(repoRoot)
return state.sessions[branch]
}
export async function setSession(repoRoot: string, session: Session): Promise<void> {
const state = await load(repoRoot)
state.sessions[session.branch] = session
await save(repoRoot, state)
await registerProject(repoRoot).catch(() => {})
}
export async function removeSession(repoRoot: string, branch: string): Promise<void> {
const state = await load(repoRoot)
delete state.sessions[branch]
await save(repoRoot, state)
}
// ── Global state: ~/.sandlot/registry.json ─────────────────────────
interface GlobalState {
projects: string[] // repo root paths
}
const GLOBAL_DIR = join(homedir(), ".sandlot")
const GLOBAL_STATE_PATH = join(GLOBAL_DIR, "registry.json")
async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> {
const lockPath = join(GLOBAL_DIR, "registry.lock")
await mkdir(GLOBAL_DIR, { recursive: true })
for (let i = 0; i < 20; i++) {
try {
await mkdir(lockPath)
} catch {
// If the lock is older than 5 minutes, assume it's stale (crashed process)
try {
const info = await stat(lockPath)
if (Date.now() - info.mtimeMs > 300_000) {
await rmdir(lockPath).catch(() => {})
// Retry mkdir immediately to close the TOCTOU window
try { await mkdir(lockPath) } catch { continue }
} else {
if (i === 19) throw new Error("Could not acquire registry lock")
await Bun.sleep(50)
continue
}
} catch {
// Lock dir vanished between our mkdir and stat — retry immediately
continue
}
}
try {
return await fn()
} finally {
await rmdir(lockPath).catch(() => {})
}
}
throw new Error("Could not acquire registry lock")
}
async function loadGlobal(): Promise<GlobalState> {
const file = Bun.file(GLOBAL_STATE_PATH)
if (await file.exists()) {
try {
const data = await file.json()
if (data && Array.isArray(data.projects)) return data
} catch {}
}
return { projects: [] }
}
async function saveGlobal(gs: GlobalState): Promise<void> {
const tmpPath = GLOBAL_STATE_PATH + ".tmp"
await Bun.write(tmpPath, JSON.stringify(gs, null, 2) + "\n")
await rename(tmpPath, GLOBAL_STATE_PATH)
}
export function normalizePath(dir: string): string {
return resolve(dir.replace(/^~(?=\/|$)/, homedir()))
}
/** Register a project directory in the global state. */
export async function registerProject(repoRoot: string): Promise<void> {
const normalized = normalizePath(repoRoot)
await withGlobalLock(async () => {
const gs = await loadGlobal()
if (!gs.projects.includes(normalized)) {
gs.projects.push(normalized)
await saveGlobal(gs)
}
})
}
/** Remove a project directory from the global state. */
export async function unregisterProject(dir: string): Promise<boolean> {
const target = normalizePath(dir)
return withGlobalLock(async () => {
const gs = await loadGlobal()
const idx = gs.projects.indexOf(target)
if (idx === -1) return false
gs.projects.splice(idx, 1)
await saveGlobal(gs)
return true
})
}
/** Recursively scan a directory for .sandlot dirs and register their parent projects. */
export async function scanAndRegister(dir: string, maxDepth = 5): Promise<string[]> {
const root = normalizePath(dir)
const found: string[] = []
async function walk(d: string, depth: number) {
if (depth >= maxDepth) return
let entries
try {
entries = await readdir(d, { withFileTypes: true })
} catch {
return
}
const hasSandlot = entries.some(e => e.isDirectory() && e.name === ".sandlot")
if (hasSandlot) {
const stateFile = Bun.file(join(d, ".sandlot", "state.json"))
if (await stateFile.exists()) {
found.push(d)
}
}
const children = entries.filter(e => e.isDirectory() && !e.isSymbolicLink() && !e.name.startsWith(".") && e.name !== "node_modules")
await Promise.all(children.map(entry => walk(join(d, entry.name), depth + 1)))
}
await walk(root, 0)
if (found.length > 0) {
await withGlobalLock(async () => {
const gs = await loadGlobal()
const existing = new Set(gs.projects)
let changed = false
for (const p of found) {
if (!existing.has(p)) {
gs.projects.push(p)
changed = true
}
}
if (changed) await saveGlobal(gs)
})
}
return found
}
export interface GlobalSession extends Session {
repo: string
repoRoot: string
}
/** Load all sessions across all registered projects. Prunes stale entries. */
export async function loadAll(): Promise<GlobalSession[]> {
const gs = await loadGlobal()
const all: GlobalSession[] = []
const stale: string[] = []
await Promise.all(gs.projects.map(async (project) => {
let st: State
try {
st = await load(project)
} catch {
// Corrupt state.json — skip but don't prune (self-heals on next session activity)
return
}
if (Object.keys(st.sessions).length === 0) {
try { await stat(join(project, ".sandlot")) } catch { stale.push(project) }
return
}
const repo = basename(project)
for (const s of Object.values(st.sessions)) {
all.push({ ...s, repo, repoRoot: project })
}
}))
// Prune projects whose state files no longer exist
if (stale.length > 0) {
const staleSet = new Set(stale)
await withGlobalLock(async () => {
const fresh = await loadGlobal()
fresh.projects = fresh.projects.filter(p => !staleSet.has(p))
await saveGlobal(fresh)
}).catch(() => {})
}
return all
}