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 }])
|
styleDefs.map(([k, c, ch]) => [k, { icon: `${c}${ch}${reset}`, color: c }])
|
||||||
)
|
)
|
||||||
|
|
||||||
interface DisplaySession {
|
function renderSessions(
|
||||||
branch: string
|
sessions: { branch: string; prompt?: string }[],
|
||||||
prompt?: string
|
statuses: Record<string, string>,
|
||||||
key: string
|
keyFn: (s: { branch: string }) => string = s => s.branch,
|
||||||
}
|
) {
|
||||||
|
|
||||||
function renderSessions(sessions: DisplaySession[], statuses: Record<string, string>) {
|
|
||||||
const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length))
|
const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length))
|
||||||
const cols = process.stdout.columns || 80
|
const cols = process.stdout.columns || 80
|
||||||
const prefixWidth = branchWidth + 4
|
const prefixWidth = branchWidth + 4
|
||||||
|
|
@ -29,7 +27,7 @@ function renderSessions(sessions: DisplaySession[], statuses: Record<string, str
|
||||||
|
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
const prompt = (s.prompt ?? "").split("\n")[0]
|
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 { icon, color: bc } = styles[status]
|
||||||
const maxPrompt = cols - prefixWidth
|
const maxPrompt = cols - prefixWidth
|
||||||
const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt
|
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}`)
|
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 ─────────────────────────────────────────────────────────
|
// ── Commands ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function action(opts: { json?: boolean; all?: boolean; add?: string; remove?: string }) {
|
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 root = await git.repoRoot()
|
||||||
const st = await state.load(root)
|
const st = await state.load(root)
|
||||||
const sessions = Object.values(st.sessions)
|
const sessions = Object.values(st.sessions)
|
||||||
|
const vmRunning = (await vm.status()) === "running"
|
||||||
|
|
||||||
// Discover prompts from Claude history for sessions that lack one
|
await backfillPrompts(sessions, vmRunning)
|
||||||
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 {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessions.length === 0 && !opts.json) {
|
if (sessions.length === 0 && !opts.json) {
|
||||||
console.log("◆ No active sessions.")
|
console.log("◆ No active sessions.")
|
||||||
if ((await vm.status()) !== "running") {
|
if (!vmRunning) {
|
||||||
console.log(`\n${red}VM is not running.${reset}`)
|
console.log(`\n${red}VM is not running.${reset}`)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine status for each session in parallel
|
const results = await Promise.all(
|
||||||
const staleReviewBranches: string[] = []
|
sessions.map(s => resolveStatus(s, s.branch, vmRunning))
|
||||||
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 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
|
const staleReviews: { repoRoot: string; branch: string }[] = []
|
||||||
if (staleReviewBranches.length > 0) {
|
for (let i = 0; i < results.length; i++) {
|
||||||
const fresh = await state.load(root)
|
if (results[i].staleReview) {
|
||||||
for (const branch of staleReviewBranches) {
|
sessions[i].in_review = false
|
||||||
if (fresh.sessions[branch]) fresh.sessions[branch].in_review = false
|
staleReviews.push({ repoRoot: root, branch: sessions[i].branch })
|
||||||
}
|
}
|
||||||
await state.save(root, fresh).catch(() => {})
|
|
||||||
}
|
}
|
||||||
|
await clearStaleReviews(staleReviews)
|
||||||
|
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] }))
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const displaySessions = sessions.map(s => ({ ...s, key: s.branch }))
|
renderSessions(sessions, statuses)
|
||||||
renderSessions(displaySessions, statuses)
|
|
||||||
renderLegend()
|
renderLegend()
|
||||||
|
|
||||||
if ((await vm.status()) !== "running") {
|
if (!vmRunning) {
|
||||||
console.log(`\n${red}VM is not running.${reset}`)
|
console.log(`\n${red}VM is not running.${reset}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -158,58 +175,29 @@ async function actionAll(opts: { json?: boolean }) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine status for each session in parallel, keyed by repo+branch
|
|
||||||
const vmRunning = (await vm.status()) === "running"
|
const vmRunning = (await vm.status()) === "running"
|
||||||
const staleReviews: { repoRoot: string; branch: string }[] = []
|
|
||||||
const statusEntries = await Promise.all(
|
await backfillPrompts(sessions, vmRunning)
|
||||||
sessions.map(async (s): Promise<[string, string]> => {
|
|
||||||
const key = `${s.repo}/${s.branch}`
|
const results = await Promise.all(
|
||||||
if (!vmRunning) return [key, "idle"]
|
sessions.map(s => resolveStatus(s, `${s.repo}/${s.branch}`, vmRunning))
|
||||||
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"]
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
const statuses = Object.fromEntries(statusEntries)
|
const statuses = Object.fromEntries(results.map(r => [r.key, r.status]))
|
||||||
|
|
||||||
// Clear stale in_review flags grouped by repo
|
const staleReviews: { repoRoot: string; branch: string }[] = []
|
||||||
if (staleReviews.length > 0) {
|
for (let i = 0; i < results.length; i++) {
|
||||||
const byRepo = new Map<string, string[]>()
|
if (results[i].staleReview) {
|
||||||
for (const { repoRoot, branch } of staleReviews) {
|
sessions[i].in_review = false
|
||||||
const list = byRepo.get(repoRoot) ?? []
|
staleReviews.push({ repoRoot: sessions[i].repoRoot, branch: sessions[i].branch })
|
||||||
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 {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await clearStaleReviews(staleReviews)
|
||||||
|
|
||||||
// Group by repo
|
const byRepo = Map.groupBy(sessions, s => s.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [repo, repoSessions] of byRepo) {
|
for (const [repo, repoSessions] of byRepo) {
|
||||||
console.log(`\n${dim}── ${reset}${repo}${dim} ──${reset}`)
|
console.log(`\n${dim}── ${reset}${repo}${dim} ──${reset}`)
|
||||||
const displaySessions = repoSessions.map(s => ({ ...s, key: `${s.repo}/${s.branch}` }))
|
renderSessions(repoSessions, statuses, s => `${repo}/${s.branch}`)
|
||||||
renderSessions(displaySessions, statuses)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderLegend()
|
renderLegend()
|
||||||
|
|
|
||||||
89
src/state.ts
89
src/state.ts
|
|
@ -1,5 +1,5 @@
|
||||||
import { join, basename, resolve } from "path"
|
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"
|
import { homedir } from "os"
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
|
|
@ -61,17 +61,35 @@ interface GlobalState {
|
||||||
}
|
}
|
||||||
|
|
||||||
const GLOBAL_DIR = join(homedir(), ".sandlot")
|
const GLOBAL_DIR = join(homedir(), ".sandlot")
|
||||||
|
const GLOBAL_STATE_PATH = join(GLOBAL_DIR, "registry.json")
|
||||||
|
|
||||||
function globalStatePath(): string {
|
async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
return join(GLOBAL_DIR, "registry.json")
|
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> {
|
async function loadGlobal(): Promise<GlobalState> {
|
||||||
const file = Bun.file(globalStatePath())
|
const file = Bun.file(GLOBAL_STATE_PATH)
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
try {
|
try {
|
||||||
const data = await file.json()
|
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 {}
|
} catch {}
|
||||||
}
|
}
|
||||||
return { projects: [] }
|
return { projects: [] }
|
||||||
|
|
@ -79,10 +97,9 @@ async function loadGlobal(): Promise<GlobalState> {
|
||||||
|
|
||||||
async function saveGlobal(gs: GlobalState): Promise<void> {
|
async function saveGlobal(gs: GlobalState): Promise<void> {
|
||||||
await mkdir(GLOBAL_DIR, { recursive: true })
|
await mkdir(GLOBAL_DIR, { recursive: true })
|
||||||
const path = globalStatePath()
|
const tmpPath = GLOBAL_STATE_PATH + ".tmp"
|
||||||
const tmpPath = path + ".tmp"
|
|
||||||
await Bun.write(tmpPath, JSON.stringify(gs, null, 2) + "\n")
|
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 {
|
function normalizePath(dir: string): string {
|
||||||
|
|
@ -92,30 +109,35 @@ function normalizePath(dir: string): string {
|
||||||
/** Register a project directory in the global state. */
|
/** Register a project directory in the global state. */
|
||||||
export async function registerProject(repoRoot: string): Promise<void> {
|
export async function registerProject(repoRoot: string): Promise<void> {
|
||||||
const normalized = normalizePath(repoRoot)
|
const normalized = normalizePath(repoRoot)
|
||||||
const gs = await loadGlobal()
|
await withGlobalLock(async () => {
|
||||||
if (!gs.projects.includes(normalized)) {
|
const gs = await loadGlobal()
|
||||||
gs.projects.push(normalized)
|
if (!gs.projects.includes(normalized)) {
|
||||||
await saveGlobal(gs)
|
gs.projects.push(normalized)
|
||||||
}
|
await saveGlobal(gs)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a project directory from the global state. */
|
/** Remove a project directory from the global state. */
|
||||||
export async function unregisterProject(dir: string): Promise<boolean> {
|
export async function unregisterProject(dir: string): Promise<boolean> {
|
||||||
const target = normalizePath(dir)
|
const target = normalizePath(dir)
|
||||||
const gs = await loadGlobal()
|
return withGlobalLock(async () => {
|
||||||
const idx = gs.projects.indexOf(target)
|
const gs = await loadGlobal()
|
||||||
if (idx === -1) return false
|
const idx = gs.projects.indexOf(target)
|
||||||
gs.projects.splice(idx, 1)
|
if (idx === -1) return false
|
||||||
await saveGlobal(gs)
|
gs.projects.splice(idx, 1)
|
||||||
return true
|
await saveGlobal(gs)
|
||||||
|
return true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recursively scan a directory for .sandlot dirs and register their parent projects. */
|
/** 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 root = normalizePath(dir)
|
||||||
const found: string[] = []
|
const found: string[] = []
|
||||||
|
|
||||||
async function walk(d: string) {
|
async function walk(d: string, depth: number) {
|
||||||
|
if (depth > maxDepth) return
|
||||||
let entries
|
let entries
|
||||||
try {
|
try {
|
||||||
entries = await readdir(d, { withFileTypes: true })
|
entries = await readdir(d, { withFileTypes: true })
|
||||||
|
|
@ -127,27 +149,28 @@ export async function scanAndRegister(dir: string): Promise<string[]> {
|
||||||
if (hasSandlot) {
|
if (hasSandlot) {
|
||||||
const stateFile = Bun.file(join(d, ".sandlot", "state.json"))
|
const stateFile = Bun.file(join(d, ".sandlot", "state.json"))
|
||||||
if (await stateFile.exists()) {
|
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")
|
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) {
|
if (found.length > 0) {
|
||||||
const gs = await loadGlobal()
|
await withGlobalLock(async () => {
|
||||||
let changed = false
|
const gs = await loadGlobal()
|
||||||
for (const p of found) {
|
let changed = false
|
||||||
if (!gs.projects.includes(p)) {
|
for (const p of found) {
|
||||||
gs.projects.push(p)
|
if (!gs.projects.includes(p)) {
|
||||||
changed = true
|
gs.projects.push(p)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (changed) await saveGlobal(gs)
|
||||||
if (changed) await saveGlobal(gs)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return found
|
return found
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user