Extract shared helpers in list command and add file lock for global registry
Deduplicate status resolution, stale-review cleanup, and prompt backfill between single-repo and all-repo list paths. Protect the global registry file with a mkdir-based lock to prevent concurrent read-modify-write races, and add a max-depth guard to scanAndRegister. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
751b4773c9
commit
f0b2a55c9c
|
|
@ -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<string, string>) {
|
||||
function renderSessions(
|
||||
sessions: { branch: string; prompt?: string }[],
|
||||
statuses: Record<string, string>,
|
||||
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<string, str
|
|||
|
||||
for (const s of sessions) {
|
||||
const prompt = (s.prompt ?? "").split("\n")[0]
|
||||
const status = statuses[s.key] ?? "idle"
|
||||
const status = statuses[keyFn(s)] ?? "idle"
|
||||
const { icon, color: bc } = styles[status]
|
||||
const maxPrompt = cols - prefixWidth
|
||||
const truncated = maxPrompt > 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<string, string[]>()
|
||||
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<string, typeof sessions>()
|
||||
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()
|
||||
|
|
|
|||
51
src/state.ts
51
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<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)
|
||||
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<GlobalState> {
|
||||
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<GlobalState> {
|
|||
|
||||
async function saveGlobal(gs: GlobalState): Promise<void> {
|
||||
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<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): Promise<string[]> {
|
||||
export async function scanAndRegister(dir: string, maxDepth = 5): Promise<string[]> {
|
||||
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,18 +149,18 @@ export async function scanAndRegister(dir: string): Promise<string[]> {
|
|||
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) {
|
||||
await withGlobalLock(async () => {
|
||||
const gs = await loadGlobal()
|
||||
let changed = false
|
||||
for (const p of found) {
|
||||
|
|
@ -148,6 +170,7 @@ export async function scanAndRegister(dir: string): Promise<string[]> {
|
|||
}
|
||||
}
|
||||
if (changed) await saveGlobal(gs)
|
||||
})
|
||||
}
|
||||
|
||||
return found
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user