diff --git a/src/commands/list.ts b/src/commands/list.ts index 57346b5..786d732 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -14,13 +14,11 @@ const styles = Object.fromEntries( styleDefs.map(([k, c, ch]) => [k, { icon: `${c}${ch}${reset}`, color: c }]) ) -interface DisplaySession { - branch: string - prompt?: string - key: string -} - -function renderSessions(sessions: DisplaySession[], statuses: Record) { +function renderSessions( + sessions: { branch: string; prompt?: string }[], + statuses: Record, + keyFn: (s: { branch: string }) => string = s => s.branch, +) { const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length)) const cols = process.stdout.columns || 80 const prefixWidth = branchWidth + 4 @@ -29,7 +27,7 @@ function renderSessions(sessions: DisplaySession[], statuses: Record 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt @@ -41,6 +39,58 @@ function renderLegend() { console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⊛ review${reset}`) } +// ── Shared logic ───────────────────────────────────────────────────── + +async function resolveStatus( + s: { branch: string; worktree: string; in_review?: boolean }, + key: string, + vmRunning: boolean, +): Promise<{ key: string; status: string; staleReview: boolean }> { + let staleReview = false + if (vmRunning) { + const active = await vm.isClaudeActive(s.worktree, s.branch) + if (active && s.in_review) return { key, status: "review", staleReview: false } + staleReview = !active && !!s.in_review + if (active) return { key, status: "active", staleReview: false } + } + const dirty = await git.isDirty(s.worktree) + if (dirty) return { key, status: "dirty", staleReview } + const commits = await git.hasNewCommits(s.worktree) + return { key, status: commits ? "saved" : "idle", staleReview } +} + +async function clearStaleReviews(staleReviews: { repoRoot: string; branch: string }[]) { + if (staleReviews.length === 0) return + const byRepo = Map.groupBy(staleReviews, r => r.repoRoot) + for (const [repoRoot, reviews] of byRepo) { + const fresh = await state.load(repoRoot) + for (const { branch } of reviews) { + if (fresh.sessions[branch]) fresh.sessions[branch].in_review = false + } + await state.save(repoRoot, fresh).catch(() => {}) + } +} + +async function backfillPrompts(sessions: { worktree: string; prompt?: string }[], vmRunning: boolean) { + if (!vmRunning) return + const needsPrompt = sessions.filter(s => !s.prompt) + if (needsPrompt.length === 0) return + try { + const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null") + if (result.exitCode === 0 && result.stdout) { + const entries = result.stdout.split("\n").filter(Boolean).map(line => { + try { return JSON.parse(line) } catch { return null } + }).filter(Boolean) + + for (const s of needsPrompt) { + const cPath = vm.containerPath(s.worktree) + const match = entries.find((e: any) => e.project === cPath) + if (match?.display) s.prompt = match.display + } + } + } catch {} +} + // ── Commands ───────────────────────────────────────────────────────── export async function action(opts: { json?: boolean; all?: boolean; add?: string; remove?: string }) { @@ -51,63 +101,31 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string const root = await git.repoRoot() const st = await state.load(root) const sessions = Object.values(st.sessions) + const vmRunning = (await vm.status()) === "running" - // Discover prompts from Claude history for sessions that lack one - const needsPrompt = sessions.filter(s => !s.prompt) - if (needsPrompt.length > 0 && (await vm.status()) === "running") { - try { - const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null") - if (result.exitCode === 0 && result.stdout) { - const entries = result.stdout.split("\n").filter(Boolean).map(line => { - try { return JSON.parse(line) } catch { return null } - }).filter(Boolean) - - for (const s of needsPrompt) { - const cPath = vm.containerPath(s.worktree) - const match = entries.find((e: any) => e.project === cPath) - if (match?.display) { - s.prompt = match.display - } - } - } - } catch {} - } + await backfillPrompts(sessions, vmRunning) if (sessions.length === 0 && !opts.json) { console.log("◆ No active sessions.") - if ((await vm.status()) !== "running") { + if (!vmRunning) { console.log(`\n${red}VM is not running.${reset}`) } return } - // Determine status for each session in parallel - const staleReviewBranches: string[] = [] - const statusEntries = await Promise.all( - sessions.map(async (s): Promise<[string, string]> => { - const active = await vm.isClaudeActive(s.worktree, s.branch) - if (active && s.in_review) return [s.branch, "review"] - if (!active && s.in_review) { - staleReviewBranches.push(s.branch) - s.in_review = false - } - if (active) return [s.branch, "active"] - const dirty = await git.isDirty(s.worktree) - if (dirty) return [s.branch, "dirty"] - const commits = await git.hasNewCommits(s.worktree) - return [s.branch, commits ? "saved" : "idle"] - }) + const results = await Promise.all( + sessions.map(s => resolveStatus(s, s.branch, vmRunning)) ) - const statuses = Object.fromEntries(statusEntries) + const statuses = Object.fromEntries(results.map(r => [r.key, r.status])) - // Clear stale in_review flags in a single load/save cycle - if (staleReviewBranches.length > 0) { - const fresh = await state.load(root) - for (const branch of staleReviewBranches) { - if (fresh.sessions[branch]) fresh.sessions[branch].in_review = false + const staleReviews: { repoRoot: string; branch: string }[] = [] + for (let i = 0; i < results.length; i++) { + if (results[i].staleReview) { + sessions[i].in_review = false + staleReviews.push({ repoRoot: root, branch: sessions[i].branch }) } - await state.save(root, fresh).catch(() => {}) } + await clearStaleReviews(staleReviews) if (opts.json) { const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] })) @@ -115,11 +133,10 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string return } - const displaySessions = sessions.map(s => ({ ...s, key: s.branch })) - renderSessions(displaySessions, statuses) + renderSessions(sessions, statuses) renderLegend() - if ((await vm.status()) !== "running") { + if (!vmRunning) { console.log(`\n${red}VM is not running.${reset}`) } } @@ -158,58 +175,29 @@ async function actionAll(opts: { json?: boolean }) { return } - // Determine status for each session in parallel, keyed by repo+branch const vmRunning = (await vm.status()) === "running" - const staleReviews: { repoRoot: string; branch: string }[] = [] - const statusEntries = await Promise.all( - sessions.map(async (s): Promise<[string, string]> => { - const key = `${s.repo}/${s.branch}` - if (!vmRunning) return [key, "idle"] - const active = await vm.isClaudeActive(s.worktree, s.branch) - if (active && s.in_review) return [key, "review"] - if (!active && s.in_review) { - staleReviews.push({ repoRoot: s.repoRoot, branch: s.branch }) - } - if (active) return [key, "active"] - const dirty = await git.isDirty(s.worktree) - if (dirty) return [key, "dirty"] - const commits = await git.hasNewCommits(s.worktree) - return [key, commits ? "saved" : "idle"] - }) + + await backfillPrompts(sessions, vmRunning) + + const results = await Promise.all( + sessions.map(s => resolveStatus(s, `${s.repo}/${s.branch}`, vmRunning)) ) - const statuses = Object.fromEntries(statusEntries) + const statuses = Object.fromEntries(results.map(r => [r.key, r.status])) - // Clear stale in_review flags grouped by repo - if (staleReviews.length > 0) { - const byRepo = new Map() - for (const { repoRoot, branch } of staleReviews) { - const list = byRepo.get(repoRoot) ?? [] - list.push(branch) - byRepo.set(repoRoot, list) - } - for (const [repoRoot, branches] of byRepo) { - try { - const fresh = await state.load(repoRoot) - for (const branch of branches) { - if (fresh.sessions[branch]) fresh.sessions[branch].in_review = false - } - await state.save(repoRoot, fresh) - } catch {} + const staleReviews: { repoRoot: string; branch: string }[] = [] + for (let i = 0; i < results.length; i++) { + if (results[i].staleReview) { + sessions[i].in_review = false + staleReviews.push({ repoRoot: sessions[i].repoRoot, branch: sessions[i].branch }) } } + await clearStaleReviews(staleReviews) - // Group by repo - const byRepo = new Map() - for (const s of sessions) { - const list = byRepo.get(s.repo) ?? [] - list.push(s) - byRepo.set(s.repo, list) - } + const byRepo = Map.groupBy(sessions, s => s.repo) for (const [repo, repoSessions] of byRepo) { console.log(`\n${dim}── ${reset}${repo}${dim} ──${reset}`) - const displaySessions = repoSessions.map(s => ({ ...s, key: `${s.repo}/${s.branch}` })) - renderSessions(displaySessions, statuses) + renderSessions(repoSessions, statuses, s => `${repo}/${s.branch}`) } renderLegend() diff --git a/src/state.ts b/src/state.ts index d8aa03b..3454c76 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,5 +1,5 @@ import { join, basename, resolve } from "path" -import { readdir, rename, mkdir } from "fs/promises" +import { readdir, rename, mkdir, rmdir } from "fs/promises" import { homedir } from "os" export interface Session { @@ -61,17 +61,35 @@ interface GlobalState { } const GLOBAL_DIR = join(homedir(), ".sandlot") +const GLOBAL_STATE_PATH = join(GLOBAL_DIR, "registry.json") -function globalStatePath(): string { - return join(GLOBAL_DIR, "registry.json") +async function withGlobalLock(fn: () => Promise): Promise { + const lockPath = join(GLOBAL_DIR, "registry.lock") + await mkdir(GLOBAL_DIR, { recursive: true }) + for (let i = 0; i < 20; i++) { + try { + await mkdir(lockPath) + break + } catch { + if (i === 19) throw new Error("Could not acquire registry lock") + await Bun.sleep(50) + } + } + try { + return await fn() + } finally { + await rmdir(lockPath).catch(() => {}) + } } async function loadGlobal(): Promise { - const file = Bun.file(globalStatePath()) + 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 + if (data && Array.isArray(data.projects)) { + return { projects: data.projects.filter((p: unknown) => typeof p === "string") } + } } catch {} } return { projects: [] } @@ -79,10 +97,9 @@ async function loadGlobal(): Promise { async function saveGlobal(gs: GlobalState): Promise { await mkdir(GLOBAL_DIR, { recursive: true }) - const path = globalStatePath() - const tmpPath = path + ".tmp" + const tmpPath = GLOBAL_STATE_PATH + ".tmp" await Bun.write(tmpPath, JSON.stringify(gs, null, 2) + "\n") - await rename(tmpPath, path) + await rename(tmpPath, GLOBAL_STATE_PATH) } function normalizePath(dir: string): string { @@ -92,30 +109,35 @@ function normalizePath(dir: string): string { /** Register a project directory in the global state. */ export async function registerProject(repoRoot: string): Promise { const normalized = normalizePath(repoRoot) - const gs = await loadGlobal() - if (!gs.projects.includes(normalized)) { - gs.projects.push(normalized) - await saveGlobal(gs) - } + 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 { const target = normalizePath(dir) - 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 + 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): Promise { +export async function scanAndRegister(dir: string, maxDepth = 5): Promise { const root = normalizePath(dir) const found: string[] = [] - async function walk(d: string) { + async function walk(d: string, depth: number) { + if (depth > maxDepth) return let entries try { entries = await readdir(d, { withFileTypes: true }) @@ -127,27 +149,28 @@ export async function scanAndRegister(dir: string): Promise { if (hasSandlot) { const stateFile = Bun.file(join(d, ".sandlot", "state.json")) if (await stateFile.exists()) { - found.push(normalizePath(d)) + found.push(d) } } const children = entries.filter(e => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules") - await Promise.all(children.map(entry => walk(join(d, entry.name)))) + await Promise.all(children.map(entry => walk(join(d, entry.name), depth + 1))) } - await walk(root) + await walk(root, 0) - // Bulk register all found projects in a single load/save cycle if (found.length > 0) { - const gs = await loadGlobal() - let changed = false - for (const p of found) { - if (!gs.projects.includes(p)) { - gs.projects.push(p) - changed = true + await withGlobalLock(async () => { + const gs = await loadGlobal() + let changed = false + for (const p of found) { + if (!gs.projects.includes(p)) { + gs.projects.push(p) + changed = true + } } - } - if (changed) await saveGlobal(gs) + if (changed) await saveGlobal(gs) + }) } return found