From 2e24586acdbbb3a96e0cc78e3164fdb90b7c82d7 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:45:48 -0400 Subject: [PATCH 01/38] add queue project readiness API --- src/app/api/queue/readiness/route.ts | 53 +++++++++ src/lib/queue-project-readiness.ts | 171 +++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 src/app/api/queue/readiness/route.ts create mode 100644 src/lib/queue-project-readiness.ts diff --git a/src/app/api/queue/readiness/route.ts b/src/app/api/queue/readiness/route.ts new file mode 100644 index 000000000..2feeec3dd --- /dev/null +++ b/src/app/api/queue/readiness/route.ts @@ -0,0 +1,53 @@ +import path from "node:path"; + +import { NextResponse } from "next/server"; + +import { runBdCommand } from "@/lib/server/beads-cli"; +import { rejectNonLocalRequest } from "@/lib/server/api-security"; +import { queueProjectReadiness, selectQueueProject } from "@/lib/queue-project-readiness"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +type Body = { action?: "select" | "generate"; projectId?: string }; + +export async function GET(req: Request) { + const denied = rejectNonLocalRequest(req); + if (denied) return denied; + return NextResponse.json({ ok: true, readiness: await queueProjectReadiness() }); +} + +export async function POST(req: Request) { + const denied = rejectNonLocalRequest(req); + if (denied) return denied; + let body: Body; + try { + body = await req.json() as Body; + } catch { + return NextResponse.json({ ok: false, error: "invalid JSON" }, { status: 400 }); + } + + if (body.action === "select") { + const projectId = body.projectId?.trim(); + if (!projectId) return NextResponse.json({ ok: false, error: "projectId is required" }, { status: 400 }); + const project = await selectQueueProject(projectId); + if (!project) return NextResponse.json({ ok: false, error: "project not found" }, { status: 404 }); + return NextResponse.json({ ok: true, readiness: await queueProjectReadiness() }); + } + + if (body.action === "generate") { + const readiness = await queueProjectReadiness(); + if (!readiness.canGenerate || !readiness.project) { + return NextResponse.json({ ok: false, error: readiness.message, readiness }, { status: 409 }); + } + // Explicit user action only: `bd init` receives the selected repository's + // root and cannot create files in the bundled sidecar/runtime cwd. + const result = await runBdCommand(readiness.project.root, path.join(readiness.project.root, ".beads"), ["init"]); + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error, readiness }, { status: result.status }); + } + return NextResponse.json({ ok: true, readiness: await queueProjectReadiness() }); + } + + return NextResponse.json({ ok: false, error: "unsupported action" }, { status: 400 }); +} diff --git a/src/lib/queue-project-readiness.ts b/src/lib/queue-project-readiness.ts new file mode 100644 index 000000000..7e6539a99 --- /dev/null +++ b/src/lib/queue-project-readiness.ts @@ -0,0 +1,171 @@ +import { execFile } from "node:child_process"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { loadProjects, projectById } from "@/lib/cave-projects"; +import type { CaveProject } from "@/lib/cave-projects-types"; +import { caveHome } from "@/lib/coven-paths"; +import { isAllowedNewProjectRoot, validateCaveProjectRoot } from "@/lib/server/project-paths"; + +const execFileAsync = promisify(execFile); +const GIT_TIMEOUT_MS = 10_000; + +type QueueProjectFile = { + version: 1; + projectId: string; +}; + +export type QueueProjectReadinessCode = + | "ready" + | "needs-beads" + | "no-project" + | "project-missing" + | "project-not-allowed" + | "not-git-repository"; + +export type QueueProjectReadiness = { + ok: boolean; + code: QueueProjectReadinessCode; + message: string; + project: Pick | null; + /** A valid repository can be generated into when it has no Beads workspace. */ + canGenerate: boolean; +}; + +function queueProjectFilePath(): string { + return process.env.CAVE_QUEUE_PROJECT_PATH_OVERRIDE ?? path.join(caveHome(), "queue-project.json"); +} + +async function readSelectedProjectId(): Promise { + try { + const raw = await readFile(queueProjectFilePath(), "utf8"); + const value = JSON.parse(raw) as Partial; + return typeof value.projectId === "string" && value.projectId.trim() ? value.projectId.trim() : null; + } catch { + return null; + } +} + +export async function selectQueueProject(projectId: string): Promise { + const project = projectById(projectId, await loadProjects()); + if (!project) return null; + const file = queueProjectFilePath(); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify({ version: 1, projectId: project.id } satisfies QueueProjectFile, null, 2), "utf8"); + return project; +} + +async function isDirectory(value: string): Promise { + try { + return (await stat(value)).isDirectory(); + } catch { + return false; + } +} + +async function gitTopLevel(root: string): Promise { + try { + const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { + cwd: root, + timeout: GIT_TIMEOUT_MS, + }); + const top = stdout.trim(); + return top || null; + } catch { + return null; + } +} + +function projectShape(project: CaveProject): Pick { + return { id: project.id, name: project.name, root: project.root }; +} + +/** + * The Queue has a deliberately separate default project. A packaged sidecar's + * cwd is application data, never a user workspace; callers must use this + * readiness result instead of falling back to process.cwd(). + */ +export async function queueProjectReadiness(): Promise { + const projectId = await readSelectedProjectId(); + if (!projectId) { + return { + ok: false, + code: "no-project", + message: "Choose a Git project for the Queue before loading work.", + project: null, + canGenerate: false, + }; + } + + const project = projectById(projectId, await loadProjects()); + if (!project) { + return { + ok: false, + code: "project-missing", + message: "The Queue project is no longer registered. Choose a project again.", + project: null, + canGenerate: false, + }; + } + const selected = projectShape(project); + + // A project created on another OS can be syntactically non-absolute on this + // host (for example C:\\work on Linux). Treat it as stale rather than feeding + // it to git, so the UI can offer a real recovery path. + if (!path.isAbsolute(project.root) || !(await isDirectory(project.root))) { + return { + ok: false, + code: "project-missing", + message: `The Queue project path is unavailable on this computer: ${project.root}. Choose a project again.`, + project: selected, + canGenerate: false, + }; + } + if (!isAllowedNewProjectRoot(project.root)) { + return { + ok: false, + code: "project-not-allowed", + message: "The Queue project is no longer an allowed project folder. Choose a specific project folder again.", + project: selected, + canGenerate: false, + }; + } + const validated = validateCaveProjectRoot(project.root); + if (!validated.ok) { + return { + ok: false, + code: "project-missing", + message: "The Queue project folder is unavailable. Choose a project again.", + project: selected, + canGenerate: false, + }; + } + const repoRoot = await gitTopLevel(validated.root); + if (!repoRoot) { + return { + ok: false, + code: "not-git-repository", + message: `The selected Queue project is not a Git repository: ${validated.root}. Choose a Git project.`, + project: selected, + canGenerate: false, + }; + } + + if (!(await isDirectory(path.join(repoRoot, ".beads")))) { + return { + ok: false, + code: "needs-beads", + message: `Queue is ready to generate in ${repoRoot}. Generate will initialize its local Beads workspace.`, + project: { ...selected, root: repoRoot }, + canGenerate: true, + }; + } + return { + ok: true, + code: "ready", + message: "Queue project is ready.", + project: { ...selected, root: repoRoot }, + canGenerate: false, + }; +} From 1779c8873ed171d8ea7f22f145f1496960e06c3a Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:48:44 -0400 Subject: [PATCH 02/38] scope queue requests to selected project --- src/components/familiar-work-queue-view.tsx | 82 ++++++++++++++++++--- src/lib/surface-warmup-registry.ts | 7 +- 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/src/components/familiar-work-queue-view.tsx b/src/components/familiar-work-queue-view.tsx index 1dd244673..e2bb42ea5 100644 --- a/src/components/familiar-work-queue-view.tsx +++ b/src/components/familiar-work-queue-view.tsx @@ -10,7 +10,6 @@ import { SearchInput } from "@/components/ui/search-input"; import { Modal } from "@/components/ui/modal"; import { useAnnouncer } from "@/components/ui/live-region"; import { usePausablePoll } from "@/lib/use-pausable-poll"; -import { invalidateSurfaceResources, readSurfaceResource } from "@/lib/surface-warmup-registry"; import { useMinuteTick } from "@/lib/use-minute-tick"; import { relativeTime } from "@/lib/relative-time"; import type { ResolvedFamiliar } from "@/lib/familiar-resolve"; @@ -109,15 +108,32 @@ type FetchedQueue = { }; type QueueSource = { ok?: boolean; data?: ReadyBead[]; open?: PullRequestSummary[]; merged?: MergedPrRef[]; error?: string }; +type QueueReadiness = { + ok: boolean; + message: string; + canGenerate: boolean; + project: { id: string; name: string; root: string } | null; +}; // Either source alone still renders a useful queue, so a single failing // adapter DEGRADES the surface (with a truthful banner) instead of failing the // whole load: beads-only when the gh PR bridge is down, PRs-only when the // beads adapter is down. Only both failing rejects — then there is genuinely // nothing to show. -async function fetchQueue(signal: AbortSignal, force = false): Promise { +async function fetchQueue(projectRoot: string, signal: AbortSignal): Promise { if (signal.aborted) throw new DOMException("Aborted", "AbortError"); - const { data } = await readSurfaceResource[]>("tasks:queue", force); + const query = `projectRoot=${encodeURIComponent(projectRoot)}`; + const readJson = async (url: string): Promise => { + const response = await fetch(url, { cache: "no-store", signal }); + return response.json() as Promise; + }; + // The Queue always supplies its explicitly selected project. In particular, + // do not let a packaged desktop sidecar substitute its application-resource + // cwd for the user's repository. + const data = await Promise.allSettled([ + readJson(`/api/beads?mode=ready&${query}`), + readJson(`/api/beads/prs?${query}`), + ]); if (signal.aborted) throw new DOMException("Aborted", "AbortError"); const [beadsSettled, prsSettled] = data; @@ -161,6 +177,8 @@ function sameQueue(a: WorkQueue, b: WorkQueue): boolean { export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = false, activeFamiliarId }: Props) { const { announce } = useAnnouncer(); const [queue, setQueue] = useState(null); + const [readiness, setReadiness] = useState(null); + const [generating, setGenerating] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); const [error, setError] = useState(null); const [beadsDegraded, setBeadsDegraded] = useState(false); @@ -222,7 +240,20 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa const ctrl = new AbortController(); abortRef.current = ctrl; try { - const { queue: next, beadsOk, prsOk, prsError } = await fetchQueue(ctrl.signal, force); + const readinessResponse = await fetch("/api/queue/readiness", { cache: "no-store", signal: ctrl.signal }); + const readinessJson = (await readinessResponse.json()) as { ok?: boolean; readiness?: QueueReadiness; error?: string }; + const nextReadiness = readinessJson.readiness; + if (!readinessResponse.ok || !readinessJson.ok || !nextReadiness) { + throw new Error(readinessJson.error || "Couldn't check the Queue project"); + } + if (seq !== loadSeq.current) return; + setReadiness(nextReadiness); + if (!nextReadiness.ok || !nextReadiness.project) { + setQueue(null); + setError(nextReadiness.message); + return; + } + const { queue: next, beadsOk, prsOk, prsError } = await fetchQueue(nextReadiness.project.root, ctrl.signal); if (seq !== loadSeq.current) return; // a newer load won if (!prsOk && hadPrDataRef.current) { // The bridge worked before and just failed — keep earlier data on @@ -247,6 +278,25 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa } }, []); + const generateQueue = useCallback(async () => { + if (generating) return; + setGenerating(true); + try { + const response = await fetch("/api/queue/readiness", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "generate" }), + }); + const json = (await response.json().catch(() => null)) as { ok?: boolean; error?: string } | null; + if (!response.ok || !json?.ok) throw new Error(json?.error || "Couldn't generate the Queue workspace"); + await load(true); + } catch (err) { + setError(err instanceof Error ? err.message : "Couldn't generate the Queue workspace"); + } finally { + setGenerating(false); + } + }, [generating, load]); + useEffect(() => { void load(); return () => abortRef.current?.abort(); @@ -291,7 +341,6 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa const json = await res.json(); if (!json.ok) throw new Error(json.error || `${action} failed`); announce(action === "claim" ? `Claimed ${id}.` : `Closed ${id}.`); - invalidateSurfaceResources("tasks:queue"); await load(true); } catch (err) { announce(err instanceof Error ? err.message : `Could not ${action} ${id}`, "assertive"); @@ -321,7 +370,6 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa if (!json.ok) throw new Error(json.error || "comment failed"); setEvidenceAdded((prev) => new Set(prev).add(id.toLowerCase())); announce(`Handoff note added to ${id}.`); - invalidateSurfaceResources("tasks:queue"); await load(true); return true; } catch (err) { @@ -351,7 +399,6 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa const json = await res.json(); if (!json.ok) throw new Error(json.error || "claim failed"); announce(`Claimed ${id} for ${familiar.display_name}.`); - invalidateSurfaceResources("tasks:queue"); await load(true); } catch (err) { announce(err instanceof Error ? err.message : `Could not claim ${id}`, "assertive"); @@ -385,7 +432,6 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa if (!json.ok) throw new Error(json.error || "create failed"); const beadId = (json.data as { id?: string } | null)?.id; announce(beadId ? `Filed ${beadId} for PR #${pr.number}.` : `Filed a bead for PR #${pr.number}.`); - invalidateSurfaceResources("tasks:queue"); await load(true); return true; } catch (err) { @@ -469,17 +515,29 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa } if (error && !queue) { + const canGenerate = readiness?.canGenerate === true; return (
void load(true)}> - Retry - +
+ {canGenerate ? ( + + ) : ( + + )} + +
} />
diff --git a/src/lib/surface-warmup-registry.ts b/src/lib/surface-warmup-registry.ts index 8629feb1f..be6ca1788 100644 --- a/src/lib/surface-warmup-registry.ts +++ b/src/lib/surface-warmup-registry.ts @@ -18,7 +18,7 @@ class SurfaceWarmupBackpressureError extends Error { export const surfaceWarmupResources = { github: ["github:pat", "github:activity", "github:familiars", "board:cards"], marketplace: ["marketplace:catalog", "marketplace:skills"], - board: ["board:cards", "tasks:queue"], + board: ["board:cards"], schedules: ["schedules:inbox", "schedules:automations"], grimoire: ["grimoire:knowledge", "grimoire:collections", "memory:list", "grimoire:journal"], agents: ["agents:coven-memory", "memory:list"], @@ -72,11 +72,6 @@ defineResource("grimoire:collections", (signal) => json(signal, "/api/knowledge/ defineResource("memory:list", (signal) => json(signal, "/api/memory"), 30_000); defineResource("grimoire:journal", (signal) => json(signal, "/api/journal"), 45_000); defineResource("agents:coven-memory", (signal) => json(signal, "/api/coven-memory"), 30_000); -defineResource("tasks:queue", async (signal) => { - // Work Queue intentionally degrades to either source when the other is - // unavailable. Preserve that landing behaviour in the shared resource. - return Promise.allSettled([json(signal, "/api/beads?mode=ready"), json(signal, "/api/beads/prs")]); -}, 30_000); export async function readSurfaceResource(key: string, force = false): Promise> { const result = await read(key, { force }); From b443dc638cd715600581c727aca20e8a02a83d68 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:49:32 -0400 Subject: [PATCH 03/38] require a queue project during onboarding --- src/app/api/onboarding/status/route.ts | 15 +++- src/components/onboarding-model.ts | 1 + src/components/onboarding-overlay.tsx | 107 +++++++++++++++++++++++++ src/components/workspace.tsx | 8 +- src/lib/onboarding-gate.ts | 2 +- 5 files changed, 124 insertions(+), 9 deletions(-) diff --git a/src/app/api/onboarding/status/route.ts b/src/app/api/onboarding/status/route.ts index 6eccb37a8..8268064a7 100644 --- a/src/app/api/onboarding/status/route.ts +++ b/src/app/api/onboarding/status/route.ts @@ -19,6 +19,7 @@ import { type AdapterReport, type CovenAdapterSummary, } from "@/lib/harness-adapters"; +import { queueProjectReadiness } from "@/lib/queue-project-readiness"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -290,12 +291,13 @@ async function checkBinding( export async function GET() { const openclawAgentCount = await countOpenClawAgents(); - const [openCovenTools, covenHome, git, daemon, familiarsRes] = await Promise.all([ + const [openCovenTools, covenHome, git, daemon, familiarsRes, queueProject] = await Promise.all([ openCovenToolReadinessStatuses(), checkCovenHome(), checkGit(), checkDaemon(), checkFamiliars(), + queueProjectReadiness(), ]); const covenCli = checkCovenCli( openCovenTools.find((tool) => tool.id === "coven-cli"), @@ -311,6 +313,13 @@ export async function GET() { const steps = { covenCli, covenHome, + // A Git project is mandatory for the Queue. An otherwise-valid repository + // without Beads is ready for the explicit Generate action on the Queue + // page, so it satisfies project selection during onboarding. + project: { + ok: queueProject.ok || queueProject.canGenerate, + detail: queueProject.message, + }, git, adapters: adapters.step, daemon, @@ -321,8 +330,8 @@ export async function GET() { binding: { ...binding, optional: true }, }; // Optional steps (git, familiars, binding) surface in the checklist but - // never gate completion — `complete` means the INFRASTRUCTURE is ready - // (CLI, home, runtime, daemon); the first familiar is summoned in-app. + // never gate completion — `complete` means the required infrastructure and + // Queue project are ready; the first familiar is summoned in-app. const complete = Object.values(steps).every((s) => s.ok || s.optional); return NextResponse.json({ ok: true, complete, steps, tools: openCovenTools }); diff --git a/src/components/onboarding-model.ts b/src/components/onboarding-model.ts index 363e0c995..8900f9f03 100644 --- a/src/components/onboarding-model.ts +++ b/src/components/onboarding-model.ts @@ -16,6 +16,7 @@ export type OnboardingStatus = { steps: { covenCli: Step; covenHome: Step; + project: Step; git?: Step; adapters: Step; daemon: Step; diff --git a/src/components/onboarding-overlay.tsx b/src/components/onboarding-overlay.tsx index 4fdfa33ed..aaced1879 100644 --- a/src/components/onboarding-overlay.tsx +++ b/src/components/onboarding-overlay.tsx @@ -13,6 +13,9 @@ import type { IconName } from "@/lib/icon"; import { useFocusTrap } from "@/lib/use-focus-trap"; import { useAnnouncer } from "@/components/ui/live-region"; import { Button } from "@/components/ui/button"; +import { ProjectPicker } from "@/components/project-picker"; +import { NO_PROJECT_ID } from "@/lib/chat-projects"; +import { useProjects } from "@/lib/use-projects"; import { SalemPathfinderEntry } from "@/components/salem/salem-pathfinder-entry"; import type { SalemPathfinderRequest } from "@/lib/salem/pathfinder-types"; import { openExternalUrl } from "@/lib/open-external"; @@ -916,6 +919,13 @@ export function OnboardingOverlay({ detail: s?.daemon.detail ?? s?.daemon.hint ?? "checking…", icon: "ph:plug", }, + { + key: "project", + title: "Choose your Queue project", + ok: !!s?.project.ok, + detail: s?.project.detail ?? s?.project.hint ?? "checking…", + icon: "ph:folder-simple-dashed", + }, { key: "git", title: "Find Git (recommended)", @@ -1457,6 +1467,8 @@ export function OnboardingOverlay({ ) : null}
+ ) : step.key === "project" ? ( + void refresh()} /> ) : step.key === "git" ? (

@@ -1525,6 +1537,101 @@ export function OnboardingOverlay({ // ── Step bodies ─────────────────────────────────────────────────────────────── +type QueueReadinessView = { + ok: boolean; + message: string; + canGenerate: boolean; + project: { id: string; name: string; root: string } | null; +}; + +/** The Queue always starts with a concrete, host-native project selection. + * This component deliberately uses the same project picker as the rest of + * Cave, including the native folder chooser used to register a new root. */ +function QueueProjectSetup({ onSelected }: { onSelected: () => void }) { + const { projects, loading, createProject } = useProjects(); + const [readiness, setReadiness] = useState(null); + const [selecting, setSelecting] = useState(false); + const [error, setError] = useState(null); + + const refreshReadiness = useCallback(async () => { + try { + const response = await fetch("/api/queue/readiness", { cache: "no-store" }); + const body = (await response.json()) as { + ok?: boolean; + readiness?: QueueReadinessView; + error?: string; + }; + if (!response.ok || !body.ok || !body.readiness) { + throw new Error(body.error ?? "Couldn’t check the Queue project."); + } + setReadiness(body.readiness); + setError(null); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Couldn’t check the Queue project."); + } + }, []); + + useEffect(() => { + void refreshReadiness(); + }, [refreshReadiness]); + + const selectProject = async (projectId: string) => { + setSelecting(true); + setError(null); + try { + const response = await fetch("/api/queue/readiness", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "select", projectId }), + }); + const body = (await response.json()) as { + ok?: boolean; + readiness?: QueueReadinessView; + error?: string; + }; + if (!response.ok || !body.ok || !body.readiness) { + throw new Error(body.error ?? "Couldn’t save the Queue project."); + } + setReadiness(body.readiness); + onSelected(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Couldn’t save the Queue project."); + } finally { + setSelecting(false); + } + }; + + return ( +

+

+ Choose the local Git repository Cave should use for Queue work. Cave stores this + host-native path and never substitutes the app’s own runtime folder. +

+ void selectProject(projectId)} + createProject={createProject} + disabled={loading || selecting} + ariaLabel="Choose Queue project" + /> + {readiness ? ( +

+ {readiness.message} +

+ ) : null} + {error ? ( +
+ {error} + +
+ ) : null} +
+ ); +} + /** The three-beat first-run journey: infrastructure (this wizard) → summon a * familiar (the in-app circle) → first chat. Orientation only — beats light * up as phases complete but are not interactive. The chat beat has no "done" diff --git a/src/components/workspace.tsx b/src/components/workspace.tsx index fcaf49b55..65e734f96 100644 --- a/src/components/workspace.tsx +++ b/src/components/workspace.tsx @@ -1421,21 +1421,19 @@ export function Workspace() { let cancelled = false; const skipped = typeof window !== "undefined" && window.localStorage.getItem("cave:onboarding:dismissed") === "1"; - if (skipped) { - setOnboardingResolved(true); - return; - } void (async () => { try { const res = await fetch("/api/onboarding/status", { cache: "no-store" }); if (!res.ok || cancelled) return; const json = (await res.json()) as OnboardingStatusPayload; + const queueProjectNeedsRepair = json.steps?.project?.ok === false; if ( shouldApplyStartupOnboardingStatus({ status: json, cancelled, manuallyOpened: manualOnboardingOpenedRef.current, - }) + }) && + (!skipped || queueProjectNeedsRepair) ) { setAutoFinishOnboarding(true); setOnboardingOpen(true); diff --git a/src/lib/onboarding-gate.ts b/src/lib/onboarding-gate.ts index 6c157dfe0..853d650af 100644 --- a/src/lib/onboarding-gate.ts +++ b/src/lib/onboarding-gate.ts @@ -52,7 +52,7 @@ export function shouldAutoOpenOnboarding(payload: OnboardingStatusPayload): bool if (payload.complete) return false; const step = (key: string) => payload.steps?.[key]?.ok === true; const structuralMissing = - !step("covenCli") || !step("covenHome") || !step("adapters"); + !step("covenCli") || !step("covenHome") || !step("adapters") || !step("project"); const daemonUpButUnfinished = step("daemon"); return structuralMissing || daemonUpButUnfinished; } From 1151f707497e83be8d78942f7a2d33e89ca2f48d Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:49:50 -0400 Subject: [PATCH 04/38] cover queue project readiness --- scripts/run-tests.mjs | 1 + src/app/api/api-contracts.test.ts | 1 + .../familiar-work-queue-view.test.ts | 11 ++- src/lib/onboarding-gate.test.ts | 8 ++ src/lib/queue-project-readiness.test.ts | 81 +++++++++++++++++++ 5 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 src/lib/queue-project-readiness.test.ts diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index cdb5f298c..1aae16ca4 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -118,6 +118,7 @@ export const SUITES = { "src/lib/no-builtin-familiar-roster.test.ts", "src/lib/familiar-roster-guard.test.ts", "src/lib/cave-projects.test.ts", + "src/lib/queue-project-readiness.test.ts", "src/lib/cave-home-project-store-reconciliation.test.ts", "src/lib/cave-inbox-create.test.ts", "src/lib/cave-inbox-prefs.test.ts", diff --git a/src/app/api/api-contracts.test.ts b/src/app/api/api-contracts.test.ts index a231639d4..f748ee010 100644 --- a/src/app/api/api-contracts.test.ts +++ b/src/app/api/api-contracts.test.ts @@ -157,6 +157,7 @@ const contracts: RouteContract[] = [ { route: "/onboarding/ssh-check", methods: ["POST"], kind: "json", readsJson: true, invalidJson: "guarded" }, { route: "/onboarding/status", methods: ["GET"], kind: "json" }, { route: "/onboarding/update", methods: ["GET", "POST"], kind: "json" }, + { route: "/queue/readiness", methods: ["GET", "POST"], kind: "json", readsJson: true, invalidJson: "guarded", localOriginGuard: true }, { route: "/opencoven/executions", methods: ["POST"], kind: "json", readsJson: true, invalidJson: "guarded" }, { route: "/opencoven/submissions", methods: ["GET", "POST"], kind: "json", readsJson: true, invalidJson: "guarded" }, { route: "/openclaw-agents", methods: ["GET"], kind: "json" }, diff --git a/src/components/familiar-work-queue-view.test.ts b/src/components/familiar-work-queue-view.test.ts index 9b5ac8731..6c7438e8b 100644 --- a/src/components/familiar-work-queue-view.test.ts +++ b/src/components/familiar-work-queue-view.test.ts @@ -142,7 +142,16 @@ assert.match(view, /description: `Filed from unlinked PR #\$\{pr\.number\} — \ assert.match(view, /externalRef: `gh-\$\{pr\.number\}`/, "externalRef uses the gh- form"); assert.match(view, /labels: \["from-pr"\]/); assert.match(view, /`Filed \$\{beadId\} for PR #\$\{pr\.number\}\.`/, "success announces the new bead id"); -assert.match(view, /invalidateSurfaceResources\("tasks:queue"\);\s*await load\(true\)/, "queue mutations invalidate an in-flight warm before reloading"); +assert.match(view, /await load\(true\)/, "queue mutations reload the explicitly selected project"); + +// Queue readiness is explicit: load its selected root first, include it in +// both bridge calls, and offer the requested Generate recovery if Beads is +// absent. It must not warm anonymous Queue requests in the background. +assert.match(view, /fetch\("\/api\/queue\/readiness"/, "Queue checks readiness before reading work"); +assert.match(view, /projectRoot=\$\{encodeURIComponent\(projectRoot\)\}/, "both Queue sources receive the selected root"); +assert.match(view, /headline=\{canGenerate \? "Generate your Queue" : "Queue needs a project"\}/); +assert.match(view, />\s*Generate\s*<\/Button>/, "empty Queue state offers Generate"); +assert.doesNotMatch(view, /readSurfaceResource\("tasks:queue"/, "Queue no longer consumes an unscoped warm cache"); // ── cave-p63a: forward-to-familiar menu (redesign) ─────────────────────────── // The split control's picker is now a custom dropdown (design's "Forward to diff --git a/src/lib/onboarding-gate.test.ts b/src/lib/onboarding-gate.test.ts index a67727a2e..0bbc8191d 100644 --- a/src/lib/onboarding-gate.test.ts +++ b/src/lib/onboarding-gate.test.ts @@ -19,6 +19,7 @@ const allStepsOk = { covenCli: { ok: true }, covenHome: { ok: true }, adapters: { ok: true }, + project: { ok: true }, daemon: { ok: true }, binding: { ok: true }, familiars: { ok: true }, @@ -42,6 +43,13 @@ assert.equal( false, "server complete → no auto-open", ); +assert.equal( + shouldAutoOpenOnboarding( + payload({ complete: false, steps: { ...allStepsOk, project: { ok: false }, daemon: { ok: false } } }), + ), + true, + "an unavailable Queue project reopens onboarding even when the daemon is down", +); // ── Coven Code is not a setup requirement (the cave-219 AND-gate is gone) ──── // A payload may still carry a tools[] array (the status route reports every diff --git a/src/lib/queue-project-readiness.test.ts b/src/lib/queue-project-readiness.test.ts new file mode 100644 index 000000000..dfecd4620 --- /dev/null +++ b/src/lib/queue-project-readiness.test.ts @@ -0,0 +1,81 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const tempDir = await mkdtemp(path.join(os.tmpdir(), "cave-queue-project-")); +const projectRoot = path.join(tempDir, "project"); +const projectsPath = path.join(tempDir, "projects.json"); +const queueProjectPath = path.join(tempDir, "queue-project.json"); +const previousProjectsPath = process.env.CAVE_PROJECTS_PATH_OVERRIDE; +const previousQueuePath = process.env.CAVE_QUEUE_PROJECT_PATH_OVERRIDE; + +process.env.CAVE_PROJECTS_PATH_OVERRIDE = projectsPath; +process.env.CAVE_QUEUE_PROJECT_PATH_OVERRIDE = queueProjectPath; + +try { + await mkdir(projectRoot); + execFileSync("git", ["init", "-q"], { cwd: projectRoot }); + await writeFile( + projectsPath, + JSON.stringify({ + version: 1, + projects: [{ + id: "queue-project", + name: "Queue project", + root: projectRoot, + createdAt: "2026-07-23T00:00:00.000Z", + updatedAt: "2026-07-23T00:00:00.000Z", + }], + }), + ); + + const { queueProjectReadiness, selectQueueProject } = await import("./queue-project-readiness.ts"); + + assert.equal((await queueProjectReadiness()).code, "no-project", "Queue never falls back to the app cwd"); + assert.equal((await selectQueueProject("queue-project"))?.root, projectRoot, "selection persists a registered project"); + + const needsBeads = await queueProjectReadiness(); + assert.equal(needsBeads.code, "needs-beads"); + assert.equal(needsBeads.canGenerate, true, "only a selected Git repository can offer Generate"); + assert.equal(needsBeads.project?.root, projectRoot, "the selected repository remains the command root"); + + await mkdir(path.join(projectRoot, ".beads")); + const ready = await queueProjectReadiness(); + assert.equal(ready.code, "ready"); + assert.equal(ready.ok, true); + + await writeFile( + projectsPath, + JSON.stringify({ + version: 1, + projects: [{ + id: "queue-project", + name: "Stale project", + root: "not-an-absolute-host-path", + createdAt: "2026-07-23T00:00:00.000Z", + updatedAt: "2026-07-23T00:00:00.000Z", + }], + }), + ); + const stale = await queueProjectReadiness(); + assert.equal(stale.code, "project-missing", "a path from another host is remediated before invoking Git"); + assert.match(stale.message, /Choose a project again/); + + const route = await (await import("node:fs/promises")).readFile( + new URL("../app/api/queue/readiness/route.ts", import.meta.url), + "utf8", + ); + assert.match(route, /rejectNonLocalRequest/, "selection and generation are loopback-only"); + assert.match(route, /runBdCommand\(readiness\.project\.root, path\.join\(readiness\.project\.root, "\.beads"\), \["init"\]\)/, "Generate runs only in the selected repository"); +} finally { + if (previousProjectsPath === undefined) delete process.env.CAVE_PROJECTS_PATH_OVERRIDE; + else process.env.CAVE_PROJECTS_PATH_OVERRIDE = previousProjectsPath; + if (previousQueuePath === undefined) delete process.env.CAVE_QUEUE_PROJECT_PATH_OVERRIDE; + else process.env.CAVE_QUEUE_PROJECT_PATH_OVERRIDE = previousQueuePath; + await rm(tempDir, { recursive: true, force: true }); +} + +console.log("queue-project-readiness.test.ts: ok"); From 40b99f686d0691aaa010fea684bd54d0105dc307 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:50:34 -0400 Subject: [PATCH 05/38] type onboarding queue status steps --- src/app/api/onboarding/status/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/onboarding/status/route.ts b/src/app/api/onboarding/status/route.ts index 8268064a7..07f9d6abe 100644 --- a/src/app/api/onboarding/status/route.ts +++ b/src/app/api/onboarding/status/route.ts @@ -310,7 +310,7 @@ export async function GET() { openclawAgentCount, ); - const steps = { + const steps: Record = { covenCli, covenHome, // A Git project is mandatory for the Queue. An otherwise-valid repository From 8cdee85c78f9080a5bbafddccc08a74309f2823e Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:52:49 -0400 Subject: [PATCH 06/38] load queue readiness test aliases --- scripts/run-tests.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 1aae16ca4..0a3bc9163 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1270,6 +1270,7 @@ const ALIAS_LOADER = new Set([ "src/lib/omnigent/ward-preflight.test.ts", "src/lib/cave-inbox-bulk.test.ts", "src/lib/cave-inbox-create.test.ts", + "src/lib/queue-project-readiness.test.ts", "src/app/api/chat/stream/route.test.ts", "src/app/api/proposals-flow-e2e.test.ts", "src/app/api/prompts/route.test.ts", From 98b826487b1b93a31105587477d5d308efe04069 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:05:41 -0400 Subject: [PATCH 07/38] exclude queue paths from standalone tracing --- src/app/api/queue/readiness/route.ts | 6 +++++- src/lib/queue-project-readiness.test.ts | 2 +- src/lib/queue-project-readiness.ts | 7 +++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/app/api/queue/readiness/route.ts b/src/app/api/queue/readiness/route.ts index 2feeec3dd..b865a545f 100644 --- a/src/app/api/queue/readiness/route.ts +++ b/src/app/api/queue/readiness/route.ts @@ -42,7 +42,11 @@ export async function POST(req: Request) { } // Explicit user action only: `bd init` receives the selected repository's // root and cannot create files in the bundled sidecar/runtime cwd. - const result = await runBdCommand(readiness.project.root, path.join(readiness.project.root, ".beads"), ["init"]); + const result = await runBdCommand( + readiness.project.root, + path.join(/* turbopackIgnore: true */ readiness.project.root, ".beads"), + ["init"], + ); if (!result.ok) { return NextResponse.json({ ok: false, error: result.error, readiness }, { status: result.status }); } diff --git a/src/lib/queue-project-readiness.test.ts b/src/lib/queue-project-readiness.test.ts index dfecd4620..2b8852727 100644 --- a/src/lib/queue-project-readiness.test.ts +++ b/src/lib/queue-project-readiness.test.ts @@ -69,7 +69,7 @@ try { "utf8", ); assert.match(route, /rejectNonLocalRequest/, "selection and generation are loopback-only"); - assert.match(route, /runBdCommand\(readiness\.project\.root, path\.join\(readiness\.project\.root, "\.beads"\), \["init"\]\)/, "Generate runs only in the selected repository"); + assert.match(route, /runBdCommand\(\s*readiness\.project\.root,\s*path\.join\([^)]*readiness\.project\.root, "\.beads"\),\s*\["init"\],\s*\)/, "Generate runs only in the selected repository"); } finally { if (previousProjectsPath === undefined) delete process.env.CAVE_PROJECTS_PATH_OVERRIDE; else process.env.CAVE_PROJECTS_PATH_OVERRIDE = previousProjectsPath; diff --git a/src/lib/queue-project-readiness.ts b/src/lib/queue-project-readiness.ts index 7e6539a99..2167ff109 100644 --- a/src/lib/queue-project-readiness.ts +++ b/src/lib/queue-project-readiness.ts @@ -34,7 +34,10 @@ export type QueueProjectReadiness = { }; function queueProjectFilePath(): string { - return process.env.CAVE_QUEUE_PROJECT_PATH_OVERRIDE ?? path.join(caveHome(), "queue-project.json"); + // The data directory is chosen at runtime. Keep it out of Next's output + // tracing so a packaged Queue check does not pull the checkout into the + // sidecar archive. + return process.env.CAVE_QUEUE_PROJECT_PATH_OVERRIDE ?? path.join(/* turbopackIgnore: true */ caveHome(), "queue-project.json"); } async function readSelectedProjectId(): Promise { @@ -152,7 +155,7 @@ export async function queueProjectReadiness(): Promise { }; } - if (!(await isDirectory(path.join(repoRoot, ".beads")))) { + if (!(await isDirectory(path.join(/* turbopackIgnore: true */ repoRoot, ".beads")))) { return { ok: false, code: "needs-beads", From 91410b8cf88e22d7b0109272bf134c81afb76506 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:07:44 -0400 Subject: [PATCH 08/38] ignore queue readiness runtime file access --- src/lib/queue-project-readiness.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/lib/queue-project-readiness.ts b/src/lib/queue-project-readiness.ts index 2167ff109..5f8fce1c9 100644 --- a/src/lib/queue-project-readiness.ts +++ b/src/lib/queue-project-readiness.ts @@ -42,7 +42,7 @@ function queueProjectFilePath(): string { async function readSelectedProjectId(): Promise { try { - const raw = await readFile(queueProjectFilePath(), "utf8"); + const raw = await readFile(/* turbopackIgnore: true */ queueProjectFilePath(), "utf8"); const value = JSON.parse(raw) as Partial; return typeof value.projectId === "string" && value.projectId.trim() ? value.projectId.trim() : null; } catch { @@ -54,14 +54,18 @@ export async function selectQueueProject(projectId: string): Promise { try { - return (await stat(value)).isDirectory(); + return (await stat(/* turbopackIgnore: true */ value)).isDirectory(); } catch { return false; } @@ -70,7 +74,7 @@ async function isDirectory(value: string): Promise { async function gitTopLevel(root: string): Promise { try { const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { - cwd: root, + cwd: /* turbopackIgnore: true */ root, timeout: GIT_TIMEOUT_MS, }); const top = stdout.trim(); From 96405a504900aff8f5b3f78a29ff61eee32dea3d Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:10:44 -0400 Subject: [PATCH 09/38] raise sidecar closure budget for queue readiness --- scripts/sidecar-bundle-deps.test.mjs | 4 ++-- scripts/sidecar-runtime-closure.mjs | 5 ++++- scripts/sidecar-runtime-closure.test.mjs | 4 ++-- scripts/sidecar-runtime-smoke.mjs | 2 +- src-tauri/src/sidecar_archive_manifest.rs | 2 +- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/scripts/sidecar-bundle-deps.test.mjs b/scripts/sidecar-bundle-deps.test.mjs index 71c534a7f..7ac778bdd 100644 --- a/scripts/sidecar-bundle-deps.test.mjs +++ b/scripts/sidecar-bundle-deps.test.mjs @@ -68,7 +68,7 @@ for (const forbiddenRoot of [ ]) { assert.match(closureSource, new RegExp(forbiddenRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `runtime verifier must exclude ${forbiddenRoot}`); } -assert.match(closureSource, /fileCount: 5_667/, "runtime closure must retain combined cross-platform headroom"); +assert.match(closureSource, /fileCount: 5_681/, "runtime closure must retain combined cross-platform headroom"); assert.match(closureSource, /unpackedBytes: 200 \* 1024 \* 1024 - 1/, "runtime closure must stay strictly below 200 MiB expanded"); // App-size: runtime bundles must drop test/dev packages and metadata that are @@ -171,7 +171,7 @@ assert.match( ); assert.match( rustArchiveSource, - /const MAX_FILE_COUNT: u64 = 5_667;/, + /const MAX_FILE_COUNT: u64 = 5_681;/, "Windows archive extractor must accept the shared runtime file-count budget", ); assert.match(manifestSource, /isSymbolicLink\(\)/, "archive input must reject symlinks"); diff --git a/scripts/sidecar-runtime-closure.mjs b/scripts/sidecar-runtime-closure.mjs index 49529f4ab..5d4b0268b 100644 --- a/scripts/sidecar-runtime-closure.mjs +++ b/scripts/sidecar-runtime-closure.mjs @@ -120,7 +120,10 @@ export const SIDECAR_RUNTIME_BUDGETS = Object.freeze({ // and endpoint-validators chunks; CI measured 5,655 on Ubuntu and 5,657 on // Windows. Retain ten files of headroom over the measured maximum without // relaxing the byte ceiling. - fileCount: 5_667, + // 2026-07-24 (Queue project readiness): the selected-project readiness + // route and its server helpers trace four more files. Native Windows + // packaging measured 5,671 files; retain the established ten-file buffer. + fileCount: 5_681, unpackedBytes: 200 * 1024 * 1024 - 1, }); diff --git a/scripts/sidecar-runtime-closure.test.mjs b/scripts/sidecar-runtime-closure.test.mjs index d8dee36a0..216f98a95 100644 --- a/scripts/sidecar-runtime-closure.test.mjs +++ b/scripts/sidecar-runtime-closure.test.mjs @@ -97,10 +97,10 @@ try { await assembleSidecarRuntime(projectRoot, standaloneRoot, dependencyRoot, destination); const metrics = await verifySidecarRuntime(destination); - assert.ok(metrics.fileCount <= 5_667); + assert.ok(metrics.fileCount <= 5_681); assert.ok(metrics.unpackedBytes < 200 * 1024 * 1024); assert.deepEqual(SIDECAR_RUNTIME_BUDGETS, { - fileCount: 5_667, + fileCount: 5_681, unpackedBytes: 200 * 1024 * 1024 - 1, }); diff --git a/scripts/sidecar-runtime-smoke.mjs b/scripts/sidecar-runtime-smoke.mjs index 6b60e0aff..7c59ada67 100644 --- a/scripts/sidecar-runtime-smoke.mjs +++ b/scripts/sidecar-runtime-smoke.mjs @@ -148,7 +148,7 @@ async function main() { assert.match(manifest.payloadSha256, /^[a-f0-9]{64}$/); assert.match(manifest.treeSha256, /^[a-f0-9]{64}$/); assert.match(manifest.archiveSha256, /^[a-f0-9]{64}$/); - assert.ok(manifest.fileCount > 0 && manifest.fileCount <= 5_667); + assert.ok(manifest.fileCount > 0 && manifest.fileCount <= 5_681); assert.ok(manifest.archiveBytes > 0 && manifest.archiveBytes <= 80 * 1024 * 1024); assert.ok(manifest.unpackedBytes > 0 && manifest.unpackedBytes < 200 * 1024 * 1024); extractedSidecarRoot = await mkdtemp(path.join(os.tmpdir(), "coven-cave-sidecar-archive-")); diff --git a/src-tauri/src/sidecar_archive_manifest.rs b/src-tauri/src/sidecar_archive_manifest.rs index 0ca16d8a8..5ffbc6bc7 100644 --- a/src-tauri/src/sidecar_archive_manifest.rs +++ b/src-tauri/src/sidecar_archive_manifest.rs @@ -6,7 +6,7 @@ pub(super) const MANIFEST_SCHEMA_VERSION: u32 = 3; pub(super) const ARCHIVE_FORMAT: &str = "tar.zst"; pub(super) const MAX_ARCHIVE_BYTES: u64 = 80 * 1024 * 1024; pub(super) const MAX_UNPACKED_BYTES: u64 = 200 * 1024 * 1024 - 1; -pub(super) const MAX_FILE_COUNT: u64 = 5_667; +pub(super) const MAX_FILE_COUNT: u64 = 5_681; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] From 11596ed52cfcf70d497dc48f833dcacdf58d2425 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:12:15 -0400 Subject: [PATCH 10/38] fix(queue): scope operations to selected project --- src/app/api/beads/route.ts | 6 +- src/components/asana-queue-strip.tsx | 8 ++- .../familiar-work-queue-sections.tsx | 9 ++- .../familiar-work-queue-view.test.ts | 11 ++- src/components/familiar-work-queue-view.tsx | 70 ++++++++++++++----- 5 files changed, 79 insertions(+), 25 deletions(-) diff --git a/src/app/api/beads/route.ts b/src/app/api/beads/route.ts index 164a0e0a5..1c2dff6e7 100644 --- a/src/app/api/beads/route.ts +++ b/src/app/api/beads/route.ts @@ -39,7 +39,11 @@ function jsonFromStdout(stdout: string): unknown { } async function resolveProjectRoot(projectRoot: string | null) { - const root = await resolveRepoRoot(projectRoot || process.cwd()); + // This adapter mutates user workspaces. A packaged Cave runtime has no + // meaningful workspace cwd, so every caller must name the project it means + // to inspect or change rather than silently falling back to process.cwd(). + if (!projectRoot) return { ok: false as const, status: 400, error: "projectRoot is required" }; + const root = await resolveRepoRoot(projectRoot); if (!root.ok) return root; const beadsDir = path.join(root.repoRoot, ".beads"); try { diff --git a/src/components/asana-queue-strip.tsx b/src/components/asana-queue-strip.tsx index a8b12f38d..f904cae05 100644 --- a/src/components/asana-queue-strip.tsx +++ b/src/components/asana-queue-strip.tsx @@ -29,6 +29,8 @@ type Props = { /** Scope the strip to one agent: shows only the Asana tasks that familiar is * assigned to work with, and hides entirely when the agent is opted out. */ familiarId?: string | null; + /** The Queue's explicitly selected repository for filing Beads. */ + projectRoot?: string; }; /** @@ -39,7 +41,7 @@ type Props = { * task can be opened in Asana, added to the board, or filed as a bead (entering * the ready queue via --external-ref). */ -export function AsanaQueueStrip({ onOpenUrl, onFiledBead, familiarId }: Props) { +export function AsanaQueueStrip({ onOpenUrl, onFiledBead, familiarId, projectRoot }: Props) { const { announce } = useAnnouncer(); const [items, setItems] = useState([]); const [configured, setConfigured] = useState(false); @@ -111,7 +113,7 @@ export function AsanaQueueStrip({ onOpenUrl, onFiledBead, familiarId }: Props) { async (item: AsanaItem) => { setBusyGid(item.gid); try { - const res = await fileAsanaItemAsBead(item); + const res = await fileAsanaItemAsBead(item, projectRoot); if (res.ok) { setFiled((prev) => new Set(prev).add(item.gid)); announce(res.beadId ? `Filed ${res.beadId} from "${item.title}".` : `Filed a bead from "${item.title}".`); @@ -123,7 +125,7 @@ export function AsanaQueueStrip({ onOpenUrl, onFiledBead, familiarId }: Props) { setBusyGid(null); } }, - [announce, onFiledBead], + [announce, onFiledBead, projectRoot], ); if (patInvalid) { diff --git a/src/components/familiar-work-queue-sections.tsx b/src/components/familiar-work-queue-sections.tsx index 9191a17e8..fcab00f30 100644 --- a/src/components/familiar-work-queue-sections.tsx +++ b/src/components/familiar-work-queue-sections.tsx @@ -19,10 +19,12 @@ import type { PullRequestSummary } from "@/lib/beads-pr-management"; */ export function BeadDetailModal({ id, + projectRoot, onClose, onClaim, }: { id: string; + projectRoot: string; onClose: () => void; onClaim: () => void; }) { @@ -48,7 +50,10 @@ export function BeadDetailModal({ let alive = true; setDetail(null); setDetailError(null); - fetch(`/api/beads?mode=show&id=${encodeURIComponent(id)}`, { cache: "no-store" }) + fetch( + `/api/beads?mode=show&id=${encodeURIComponent(id)}&projectRoot=${encodeURIComponent(projectRoot)}`, + { cache: "no-store" }, + ) .then((res) => res.json()) .then((json) => { if (!alive) return; @@ -63,7 +68,7 @@ export function BeadDetailModal({ return () => { alive = false; }; - }, [id]); + }, [id, projectRoot]); return ( diff --git a/src/components/familiar-work-queue-view.test.ts b/src/components/familiar-work-queue-view.test.ts index 6c7438e8b..bea7bf0ff 100644 --- a/src/components/familiar-work-queue-view.test.ts +++ b/src/components/familiar-work-queue-view.test.ts @@ -90,8 +90,8 @@ assert.match( // ── Bead inspector (cave-u2p1) ─────────────────────────────────────────────── // Bead titles open a focus-trapped dialog over the existing show contract. -assert.match(view, /className="fwq-row-name fwq-row-name--link focus-ring-inset"/); -assert.match(view, /\/api\/beads\?mode=show&id=\$\{encodeURIComponent\(id\)\}/, "drawer reads bd show --json"); +assert.match(view, /className="fwq-card-name fwq-card-name--link focus-ring-inset"/); +assert.match(view, /\/api\/beads\?mode=show&id=\$\{encodeURIComponent\(id\)\}&projectRoot=\$\{encodeURIComponent\(projectRoot\)\}/, "drawer reads the selected project's bead"); assert.match(view, /import \{ Modal \} from "@\/components\/ui\/modal"/, "reuses the focus-trapped house dialog"); assert.match(view, /breadcrumb=\{\["Queue", id\]\}/); assert.match(view, /import\("@\/lib\/clipboard"\)/, "copy-id uses the shared clipboard helper"); @@ -149,7 +149,11 @@ assert.match(view, /await load\(true\)/, "queue mutations reload the explicitly // absent. It must not warm anonymous Queue requests in the background. assert.match(view, /fetch\("\/api\/queue\/readiness"/, "Queue checks readiness before reading work"); assert.match(view, /projectRoot=\$\{encodeURIComponent\(projectRoot\)\}/, "both Queue sources receive the selected root"); -assert.match(view, /headline=\{canGenerate \? "Generate your Queue" : "Queue needs a project"\}/); +assert.match(view, /action, id, projectRoot/, "claim and close mutations receive the selected root"); +assert.match(view, /action: "comment", id, comment, projectRoot/, "handoff comments receive the selected root"); +assert.match(view, /action: "claim", id, assignee: familiar\.id, projectRoot/, "claim-for receives the selected root"); +assert.match(view, /projectRoot=\{readiness\?\.project\?\.root\}/, "Asana filing receives the selected root"); +assert.match(view, /headline=\{readinessUnavailable \? "Queue check unavailable" : canGenerate \? "Generate your Queue" : "Queue needs a project"\}/); assert.match(view, />\s*Generate\s*<\/Button>/, "empty Queue state offers Generate"); assert.doesNotMatch(view, /readSurfaceResource\("tasks:queue"/, "Queue no longer consumes an unscoped warm cache"); @@ -167,6 +171,7 @@ assert.match(css, /\.fwq-forward-menu \{/, "the picker menu has real styles"); // Claim stays the default (connected user); the menu claims for a familiar. assert.match(view, /className="fwq-act fwq-act--claim"/, "Claim is the redesigned row action"); assert.match(view, /\{familiars\.length > 0 \? \(\s*(null); const [readiness, setReadiness] = useState(null); + const [readinessFailure, setReadinessFailure] = useState(null); const [generating, setGenerating] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); const [error, setError] = useState(null); @@ -213,6 +214,7 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa // then a refresh failure (keep the richer on-screen picture + inline retry // banner) rather than a degradation (which would silently drop PR lanes). const hadPrDataRef = useRef(false); + const activeProjectRootRef = useRef(null); // Re-render ~once a minute so the header freshness and per-card ages stay // truthful between polls (the equality guard below keeps queue state stable, // so nothing else would tick them). @@ -239,6 +241,7 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; + let readinessResolved = false; try { const readinessResponse = await fetch("/api/queue/readiness", { cache: "no-store", signal: ctrl.signal }); const readinessJson = (await readinessResponse.json()) as { ok?: boolean; readiness?: QueueReadiness; error?: string }; @@ -247,6 +250,21 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa throw new Error(readinessJson.error || "Couldn't check the Queue project"); } if (seq !== loadSeq.current) return; + setReadinessFailure(null); + readinessResolved = true; + const nextRoot = nextReadiness.project?.root ?? null; + if (activeProjectRootRef.current !== nextRoot) { + // A selected repository is an isolation boundary. Do not retain cards, + // failure state, detail selection, or evidence from the prior project. + activeProjectRootRef.current = nextRoot; + hadPrDataRef.current = false; + setQueue(null); + setBeadsDegraded(false); + setPrsDegraded(null); + setLastUpdated(null); + setDetailId(null); + setEvidenceAdded(new Set()); + } setReadiness(nextReadiness); if (!nextReadiness.ok || !nextReadiness.project) { setQueue(null); @@ -273,6 +291,7 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa // Keep whatever data is on screen — the render picks between the // full-surface empty state (no data yet) and the inline refresh banner. setError(err instanceof Error ? err.message : "Failed to load the queue"); + if (!readinessResolved) setReadinessFailure(err instanceof Error ? err.message : "Couldn't check the Queue project"); } finally { if (seq === loadSeq.current) setHasLoaded(true); } @@ -285,7 +304,7 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa const response = await fetch("/api/queue/readiness", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ action: "generate" }), + body: JSON.stringify({ action: "generate", projectId: readiness?.project?.id }), }); const json = (await response.json().catch(() => null)) as { ok?: boolean; error?: string } | null; if (!response.ok || !json?.ok) throw new Error(json?.error || "Couldn't generate the Queue workspace"); @@ -295,13 +314,19 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa } finally { setGenerating(false); } - }, [generating, load]); + }, [generating, load, readiness?.project?.id]); useEffect(() => { void load(); return () => abortRef.current?.abort(); }, [load]); + useEffect(() => { + const onProjectSelected = () => { void load(true); }; + window.addEventListener("cave:queue-project-selected", onProjectSelected); + return () => window.removeEventListener("cave:queue-project-selected", onProjectSelected); + }, [load]); + // Announce the actionable count once the first load settles. const announcedRef = useRef(false); useEffect(() => { @@ -328,10 +353,11 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa const runAction = useCallback( async (item: WorkQueueItem, action: "claim" | "close") => { const id = item.bead?.id; - if (!id) return; + const projectRoot = readiness?.project?.root; + if (!id || !projectRoot) return; setBusyId(item.key); try { - const body: Record = { action, id }; + const body: Record = { action, id, projectRoot }; if (action === "close") body.reason = item.merged ? `Merged in PR #${item.merged.number}` : "Completed"; const res = await fetch("/api/beads", { method: "POST", @@ -348,7 +374,7 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa setBusyId(null); } }, - [announce, load], + [announce, load, readiness?.project?.root], ); // Handoff note: appends a comment to the bead (the recorded verification @@ -358,13 +384,14 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa async (item: WorkQueueItem, text: string): Promise => { const id = item.bead?.id; const comment = text.trim(); - if (!id || !comment) return false; + const projectRoot = readiness?.project?.root; + if (!id || !comment || !projectRoot) return false; setBusyId(item.key); try { const res = await fetch("/api/beads", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ action: "comment", id, comment }), + body: JSON.stringify({ action: "comment", id, comment, projectRoot }), }); const json = await res.json(); if (!json.ok) throw new Error(json.error || "comment failed"); @@ -379,7 +406,7 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa setBusyId(null); } }, - [announce, load], + [announce, load, readiness?.project?.root], ); // Claim-for-familiar: same claim action, but the bead lands on the picked @@ -388,13 +415,14 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa const runClaimFor = useCallback( async (item: WorkQueueItem, familiar: ResolvedFamiliar) => { const id = item.bead?.id; - if (!id) return; + const projectRoot = readiness?.project?.root; + if (!id || !projectRoot) return; setBusyId(item.key); try { const res = await fetch("/api/beads", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ action: "claim", id, assignee: familiar.id }), + body: JSON.stringify({ action: "claim", id, assignee: familiar.id, projectRoot }), }); const json = await res.json(); if (!json.ok) throw new Error(json.error || "claim failed"); @@ -406,7 +434,7 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa setBusyId(null); } }, - [announce, load], + [announce, load, readiness?.project?.root], ); // File a bead for an unlinked attention-strip PR: bd create with the PR's @@ -416,6 +444,8 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa // can drop its busy state truthfully (cave-p63a). const runFileBead = useCallback( async (pr: PullRequestSummary): Promise => { + const projectRoot = readiness?.project?.root; + if (!projectRoot) return false; try { const res = await fetch("/api/beads", { method: "POST", @@ -426,6 +456,7 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa description: `Filed from unlinked PR #${pr.number} — ${pr.url}`, externalRef: `gh-${pr.number}`, labels: ["from-pr"], + projectRoot, }), }); const json = await res.json(); @@ -442,7 +473,7 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa return false; } }, - [announce, load], + [announce, load, readiness?.project?.root], ); // Search matches title, bead id, and PR number; priority bands map P2+ @@ -516,16 +547,17 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa if (error && !queue) { const canGenerate = readiness?.canGenerate === true; + const readinessUnavailable = readinessFailure !== null; return (
- {canGenerate ? ( + {readinessUnavailable ? null : canGenerate ? ( @@ -710,7 +742,12 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa ) : null} - void load(true)} familiarId={activeFamiliarId} /> + void load(true)} + familiarId={activeFamiliarId} + projectRoot={readiness?.project?.root} + />
{q.total === 0 ? ( @@ -825,6 +862,7 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa {detailId ? ( setDetailId(null)} onClaim={() => { const item = q.lanes.flatMap((l) => l.items).find((i) => i.bead?.id === detailId); From c8fa34df35b5eb61b5cafd5416707fbc1a203642 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:12:18 -0400 Subject: [PATCH 11/38] fix(board): stop invalidating removed queue cache --- src/components/board-view.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/board-view.tsx b/src/components/board-view.tsx index 29d3d804f..be4c7fd42 100644 --- a/src/components/board-view.tsx +++ b/src/components/board-view.tsx @@ -270,7 +270,7 @@ export function BoardView({ // External create paths dispatch `cave:board:reload` after POST so the board // picks up the new card without a full surface remount. useEffect(() => { - const onReload = () => { invalidateSurfaceResources("board:cards", "tasks:queue"); void load(); }; + const onReload = () => { invalidateSurfaceResources("board:cards"); void load(); }; window.addEventListener("cave:board:reload", onReload); return () => window.removeEventListener("cave:board:reload", onReload); }, [load]); @@ -521,7 +521,7 @@ export function BoardView({ setActionError(json.error ? `Couldn't save changes — ${json.error}` : "Couldn't save changes — reverted to the server copy."); reloadWhenPatchesSettleRef.current = true; } else { - invalidateSurfaceResources("board:cards", "tasks:queue"); + invalidateSurfaceResources("board:cards"); saved = true; setActionError(null); if (json.card) setCards((prev) => prev.map((c) => (c.id === id ? (json.card as Card) : c))); @@ -572,7 +572,7 @@ export function BoardView({ // silently dropping the failure). const json = await res.json().catch(() => ({ ok: false, error: "the server returned an unreadable response" })); if (!json.ok) throw new Error(json.error ?? "create failed"); - invalidateSurfaceResources("board:cards", "tasks:queue"); + invalidateSurfaceResources("board:cards"); setActionError(null); announce(`Created task '${draft.title.trim()}'.`); await load(); @@ -629,7 +629,7 @@ export function BoardView({ async () => { // Commit: drop from local state, then fire the DELETEs. Both the unhide // (pending → null) and this removal batch, so the cards never flash back. - invalidateSurfaceResources("board:cards", "tasks:queue"); + invalidateSurfaceResources("board:cards"); setCards((prev) => prev.filter((c) => !idSet.has(c.id))); const results = await Promise.all( toRemove.map(async (c) => { @@ -739,7 +739,7 @@ export function BoardView({ setClearConfirm(false); if (snapshot.length === 0) return; const ids = new Set(snapshot.map((c) => c.id)); - invalidateSurfaceResources("board:cards", "tasks:queue"); + invalidateSurfaceResources("board:cards"); // Optimistic remove + drop selection if it pointed at a cleared card. setCards((prev) => prev.filter((c) => !ids.has(c.id))); if (selectedCardId && ids.has(selectedCardId)) setSelectedCardId(null); @@ -809,7 +809,7 @@ export function BoardView({ } catch { setActionError("Couldn't restore all cleared tasks — reload to check."); } - invalidateSurfaceResources("board:cards", "tasks:queue"); + invalidateSurfaceResources("board:cards"); await load({ force: true }); }; From e93d41cbb5a7e79a2d423988ea0e68da4dfcb070 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:12:23 -0400 Subject: [PATCH 12/38] fix(queue): bind and validate project readiness --- src/app/api/queue/readiness/route.ts | 67 ++++++++++----- src/lib/queue-project-readiness.test.ts | 31 ++++++- src/lib/queue-project-readiness.ts | 106 +++++++++++++++++++----- 3 files changed, 163 insertions(+), 41 deletions(-) diff --git a/src/app/api/queue/readiness/route.ts b/src/app/api/queue/readiness/route.ts index b865a545f..275f2df40 100644 --- a/src/app/api/queue/readiness/route.ts +++ b/src/app/api/queue/readiness/route.ts @@ -3,13 +3,30 @@ import path from "node:path"; import { NextResponse } from "next/server"; import { runBdCommand } from "@/lib/server/beads-cli"; -import { rejectNonLocalRequest } from "@/lib/server/api-security"; +import { readJsonBody, rejectNonLocalRequest } from "@/lib/server/api-security"; import { queueProjectReadiness, selectQueueProject } from "@/lib/queue-project-readiness"; +import { MAX_SESSION_JSON_BYTES } from "@/lib/server/session-security"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -type Body = { action?: "select" | "generate"; projectId?: string }; +type Body = { action?: unknown; projectId?: unknown }; +const MAX_PROJECT_ID_LENGTH = 200; +const generationLocks = new Map>(); + +async function withGenerationLock(root: string, action: () => Promise): Promise { + let release!: () => void; + const next = new Promise((resolve) => { release = resolve; }); + const previous = generationLocks.get(root) ?? Promise.resolve(); + generationLocks.set(root, next); + await previous; + try { + return await action(); + } finally { + release(); + if (generationLocks.get(root) === next) generationLocks.delete(root); + } +} export async function GET(req: Request) { const denied = rejectNonLocalRequest(req); @@ -20,16 +37,18 @@ export async function GET(req: Request) { export async function POST(req: Request) { const denied = rejectNonLocalRequest(req); if (denied) return denied; - let body: Body; - try { - body = await req.json() as Body; - } catch { - return NextResponse.json({ ok: false, error: "invalid JSON" }, { status: 400 }); + const parsed = await readJsonBody(req, MAX_SESSION_JSON_BYTES); + if (!parsed.ok) return parsed.response; + const body = parsed.body; + if (body.action !== "select" && body.action !== "generate") { + return NextResponse.json({ ok: false, error: "unsupported action" }, { status: 400 }); + } + if (typeof body.projectId !== "string" || !body.projectId.trim() || body.projectId.length > MAX_PROJECT_ID_LENGTH) { + return NextResponse.json({ ok: false, error: "projectId is required" }, { status: 400 }); } + const projectId = body.projectId.trim(); if (body.action === "select") { - const projectId = body.projectId?.trim(); - if (!projectId) return NextResponse.json({ ok: false, error: "projectId is required" }, { status: 400 }); const project = await selectQueueProject(projectId); if (!project) return NextResponse.json({ ok: false, error: "project not found" }, { status: 404 }); return NextResponse.json({ ok: true, readiness: await queueProjectReadiness() }); @@ -37,20 +56,26 @@ export async function POST(req: Request) { if (body.action === "generate") { const readiness = await queueProjectReadiness(); - if (!readiness.canGenerate || !readiness.project) { + if (!readiness.canGenerate || !readiness.project || readiness.project.id !== projectId) { return NextResponse.json({ ok: false, error: readiness.message, readiness }, { status: 409 }); } - // Explicit user action only: `bd init` receives the selected repository's - // root and cannot create files in the bundled sidecar/runtime cwd. - const result = await runBdCommand( - readiness.project.root, - path.join(/* turbopackIgnore: true */ readiness.project.root, ".beads"), - ["init"], - ); - if (!result.ok) { - return NextResponse.json({ ok: false, error: result.error, readiness }, { status: result.status }); - } - return NextResponse.json({ ok: true, readiness: await queueProjectReadiness() }); + return withGenerationLock(readiness.project.root, async () => { + // Re-read the persisted selection after the per-repository lock: another + // window may have selected a different project while this request waited. + const current = await queueProjectReadiness(); + if (!current.canGenerate || !current.project || current.project.id !== projectId || current.project.root !== readiness.project?.root) { + return NextResponse.json({ ok: false, error: "Queue project changed; choose Generate again for the current project.", readiness: current }, { status: 409 }); + } + const result = await runBdCommand( + current.project.root, + path.join(/* turbopackIgnore: true */ current.project.root, ".beads"), + ["init"], + ); + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error, readiness: current }, { status: result.status }); + } + return NextResponse.json({ ok: true, readiness: await queueProjectReadiness() }); + }); } return NextResponse.json({ ok: false, error: "unsupported action" }, { status: 400 }); diff --git a/src/lib/queue-project-readiness.test.ts b/src/lib/queue-project-readiness.test.ts index 2b8852727..8e9c50f96 100644 --- a/src/lib/queue-project-readiness.test.ts +++ b/src/lib/queue-project-readiness.test.ts @@ -43,10 +43,37 @@ try { assert.equal(needsBeads.project?.root, projectRoot, "the selected repository remains the command root"); await mkdir(path.join(projectRoot, ".beads")); + const partial = await queueProjectReadiness(); + assert.equal(partial.code, "needs-beads", "an empty .beads directory remains repairable"); + assert.equal(partial.canGenerate, true); + await rm(path.join(projectRoot, ".beads"), { recursive: true, force: true }); + execFileSync("bd", ["init"], { cwd: projectRoot, stdio: "pipe" }); const ready = await queueProjectReadiness(); assert.equal(ready.code, "ready"); assert.equal(ready.ok, true); + const nestedRoot = path.join(projectRoot, "nested"); + await mkdir(nestedRoot); + await writeFile( + projectsPath, + JSON.stringify({ + version: 1, + projects: [{ + id: "nested-project", + name: "Nested project", + root: nestedRoot, + createdAt: "2026-07-23T00:00:00.000Z", + updatedAt: "2026-07-23T00:00:00.000Z", + }], + }), + ); + await selectQueueProject("nested-project"); + assert.equal( + (await queueProjectReadiness()).code, + "project-not-git-root", + "a selected subdirectory never authorizes its Git parent", + ); + await writeFile( projectsPath, JSON.stringify({ @@ -60,6 +87,7 @@ try { }], }), ); + await selectQueueProject("queue-project"); const stale = await queueProjectReadiness(); assert.equal(stale.code, "project-missing", "a path from another host is remediated before invoking Git"); assert.match(stale.message, /Choose a project again/); @@ -69,7 +97,8 @@ try { "utf8", ); assert.match(route, /rejectNonLocalRequest/, "selection and generation are loopback-only"); - assert.match(route, /runBdCommand\(\s*readiness\.project\.root,\s*path\.join\([^)]*readiness\.project\.root, "\.beads"\),\s*\["init"\],\s*\)/, "Generate runs only in the selected repository"); + assert.match(route, /projectId is required/, "Generate is bound to an explicit project identity"); + assert.match(route, /withGenerationLock/, "Generate serializes initialization per repository"); } finally { if (previousProjectsPath === undefined) delete process.env.CAVE_PROJECTS_PATH_OVERRIDE; else process.env.CAVE_PROJECTS_PATH_OVERRIDE = previousProjectsPath; diff --git a/src/lib/queue-project-readiness.ts b/src/lib/queue-project-readiness.ts index 5f8fce1c9..dcfc7b515 100644 --- a/src/lib/queue-project-readiness.ts +++ b/src/lib/queue-project-readiness.ts @@ -1,12 +1,14 @@ import { execFile } from "node:child_process"; -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { lstat, mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; import { loadProjects, projectById } from "@/lib/cave-projects"; import type { CaveProject } from "@/lib/cave-projects-types"; import { caveHome } from "@/lib/coven-paths"; import { isAllowedNewProjectRoot, validateCaveProjectRoot } from "@/lib/server/project-paths"; +import { runBdCommand } from "@/lib/server/beads-cli"; const execFileAsync = promisify(execFile); const GIT_TIMEOUT_MS = 10_000; @@ -22,7 +24,10 @@ export type QueueProjectReadinessCode = | "no-project" | "project-missing" | "project-not-allowed" - | "not-git-repository"; + | "not-git-repository" + | "git-unavailable" + | "git-error" + | "project-not-git-root"; export type QueueProjectReadiness = { ok: boolean; @@ -40,11 +45,36 @@ function queueProjectFilePath(): string { return process.env.CAVE_QUEUE_PROJECT_PATH_OVERRIDE ?? path.join(/* turbopackIgnore: true */ caveHome(), "queue-project.json"); } +let selectionWriteTail: Promise = Promise.resolve(); + +async function writeSelectedProjectId(file: string, projectId: string): Promise { + let release!: () => void; + const next = new Promise((resolve) => { release = resolve; }); + const previous = selectionWriteTail; + selectionWriteTail = next; + await previous; + const temporary = `${file}.${randomUUID()}.tmp`; + try { + await writeFile( + /* turbopackIgnore: true */ temporary, + JSON.stringify({ version: 1, projectId } satisfies QueueProjectFile, null, 2), + "utf8", + ); + // Rename is atomic within the Cave home directory: concurrent readers see + // either the old valid selection or the complete new selection. + await rename(/* turbopackIgnore: true */ temporary, file); + } finally { + release(); + } +} + async function readSelectedProjectId(): Promise { try { const raw = await readFile(/* turbopackIgnore: true */ queueProjectFilePath(), "utf8"); const value = JSON.parse(raw) as Partial; - return typeof value.projectId === "string" && value.projectId.trim() ? value.projectId.trim() : null; + return value.version === 1 && typeof value.projectId === "string" && value.projectId.trim() + ? value.projectId.trim() + : null; } catch { return null; } @@ -55,11 +85,7 @@ export async function selectQueueProject(projectId: string): Promise { } } -async function gitTopLevel(root: string): Promise { +type GitTopLevel = + | { ok: true; root: string } + | { ok: false; code: "not-git-repository" | "git-unavailable" | "git-error"; message: string }; + +async function gitTopLevel(root: string): Promise { try { const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd: /* turbopackIgnore: true */ root, timeout: GIT_TIMEOUT_MS, }); const top = stdout.trim(); - return top || null; + if (!top) return { ok: false, code: "not-git-repository", message: "The selected Queue project is not a Git repository." }; + return { ok: true, root: await realpath(/* turbopackIgnore: true */ top) }; + } catch (cause) { + const error = cause as NodeJS.ErrnoException; + if (error.code === "ENOENT") { + return { ok: false, code: "git-unavailable", message: "Git is required to use the Queue project. Install Git and try again." }; + } + return { + ok: false, + code: "git-error", + message: "Git could not validate the Queue project. Check Git permissions and repository trust, then try again.", + }; + } +} + +async function hasUsableBeadsWorkspace(repoRoot: string): Promise { + const beadsDir = path.join(/* turbopackIgnore: true */ repoRoot, ".beads"); + try { + const entry = await lstat(/* turbopackIgnore: true */ beadsDir); + if (!entry.isDirectory() || entry.isSymbolicLink()) return false; + const canonicalBeads = await realpath(/* turbopackIgnore: true */ beadsDir); + if (canonicalBeads !== beadsDir && !canonicalBeads.startsWith(repoRoot + path.sep)) return false; } catch { - return null; + return false; } + // Directory presence alone is not a workspace: a failed bd init can leave an + // empty .beads behind. A read-only probe keeps Generate available to repair it. + return (await runBdCommand(repoRoot, beadsDir, ["ready", "--json"])).ok; } function projectShape(project: CaveProject): Pick { @@ -148,23 +202,37 @@ export async function queueProjectReadiness(): Promise { canGenerate: false, }; } - const repoRoot = await gitTopLevel(validated.root); - if (!repoRoot) { + const gitRoot = await gitTopLevel(validated.root); + if (!gitRoot.ok) { + return { + ok: false, + code: gitRoot.code, + message: gitRoot.code === "not-git-repository" + ? `The selected Queue project is not a Git repository: ${validated.root}. Choose a Git project.` + : gitRoot.message, + project: selected, + canGenerate: false, + }; + } + // Project selection is intentionally a repository boundary, not a loose + // subdirectory hint. Never replace a selected/authorized path with a Git + // parent that the project registry has not approved. + if (gitRoot.root !== validated.root) { return { ok: false, - code: "not-git-repository", - message: `The selected Queue project is not a Git repository: ${validated.root}. Choose a Git project.`, + code: "project-not-git-root", + message: `Choose the Git repository root for Queue work, not a subdirectory: ${gitRoot.root}.`, project: selected, canGenerate: false, }; } - if (!(await isDirectory(path.join(/* turbopackIgnore: true */ repoRoot, ".beads")))) { + if (!(await hasUsableBeadsWorkspace(gitRoot.root))) { return { ok: false, code: "needs-beads", - message: `Queue is ready to generate in ${repoRoot}. Generate will initialize its local Beads workspace.`, - project: { ...selected, root: repoRoot }, + message: `Queue is ready to generate in ${gitRoot.root}. Generate will initialize or repair its local Beads workspace.`, + project: { ...selected, root: gitRoot.root }, canGenerate: true, }; } @@ -172,7 +240,7 @@ export async function queueProjectReadiness(): Promise { ok: true, code: "ready", message: "Queue project is ready.", - project: { ...selected, root: repoRoot }, + project: { ...selected, root: gitRoot.root }, canGenerate: false, }; } From cb9097943eea6466e48aab9a370eece351a8ddb8 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:12:27 -0400 Subject: [PATCH 13/38] test(onboarding): model Queue project readiness --- src/components/onboarding-overlay.tsx | 20 ++++++++++++++------ tests/familiar-work-queue.spec.ts | 16 ++++++++++++++-- tests/onboarding-wizard.spec.ts | 3 +++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/components/onboarding-overlay.tsx b/src/components/onboarding-overlay.tsx index aaced1879..2bdacd940 100644 --- a/src/components/onboarding-overlay.tsx +++ b/src/components/onboarding-overlay.tsx @@ -922,8 +922,8 @@ export function OnboardingOverlay({ { key: "project", title: "Choose your Queue project", - ok: !!s?.project.ok, - detail: s?.project.detail ?? s?.project.hint ?? "checking…", + ok: !!s?.project?.ok, + detail: s?.project?.detail ?? s?.project?.hint ?? "checking…", icon: "ph:folder-simple-dashed", }, { @@ -1552,8 +1552,10 @@ function QueueProjectSetup({ onSelected }: { onSelected: () => void }) { const [readiness, setReadiness] = useState(null); const [selecting, setSelecting] = useState(false); const [error, setError] = useState(null); + const requestGeneration = useRef(0); const refreshReadiness = useCallback(async () => { + const generation = ++requestGeneration.current; try { const response = await fetch("/api/queue/readiness", { cache: "no-store" }); const body = (await response.json()) as { @@ -1564,10 +1566,14 @@ function QueueProjectSetup({ onSelected }: { onSelected: () => void }) { if (!response.ok || !body.ok || !body.readiness) { throw new Error(body.error ?? "Couldn’t check the Queue project."); } - setReadiness(body.readiness); - setError(null); + if (generation === requestGeneration.current) { + setReadiness(body.readiness); + setError(null); + } } catch (cause) { - setError(cause instanceof Error ? cause.message : "Couldn’t check the Queue project."); + if (generation === requestGeneration.current) { + setError(cause instanceof Error ? cause.message : "Couldn’t check the Queue project."); + } } }, []); @@ -1576,6 +1582,7 @@ function QueueProjectSetup({ onSelected }: { onSelected: () => void }) { }, [refreshReadiness]); const selectProject = async (projectId: string) => { + ++requestGeneration.current; setSelecting(true); setError(null); try { @@ -1593,6 +1600,7 @@ function QueueProjectSetup({ onSelected }: { onSelected: () => void }) { throw new Error(body.error ?? "Couldn’t save the Queue project."); } setReadiness(body.readiness); + window.dispatchEvent(new CustomEvent("cave:queue-project-selected", { detail: { project: body.readiness.project } })); onSelected(); } catch (cause) { setError(cause instanceof Error ? cause.message : "Couldn’t save the Queue project."); @@ -1616,7 +1624,7 @@ function QueueProjectSetup({ onSelected }: { onSelected: () => void }) { ariaLabel="Choose Queue project" /> {readiness ? ( -

+

{readiness.message}

) : null} diff --git a/tests/familiar-work-queue.spec.ts b/tests/familiar-work-queue.spec.ts index 68349cdb6..9bc55700c 100644 --- a/tests/familiar-work-queue.spec.ts +++ b/tests/familiar-work-queue.spec.ts @@ -29,6 +29,17 @@ const OPEN_PRS = [ const MERGED_PRS = [ { number: 90, title: "Landed change", url: "https://gh/pull/90", beadIds: ["cave-open"], mergedAt: iso(1) }, ]; +const QUEUE_PROJECT = { id: "queue-test-project", name: "Queue test project", root: "/tmp/coven-cave-queue-test-project" }; +const QUEUE_READINESS = { ok: true, message: "Queue project is ready.", canGenerate: false, project: QUEUE_PROJECT }; + +test.beforeEach(async ({ page }) => { + await page.route("**/api/queue/readiness", (route) => + route.fulfill({ json: { ok: true, readiness: QUEUE_READINESS } }), + ); + await page.route("**/api/onboarding/status**", (route) => + route.fulfill({ json: { ok: true, complete: true, steps: { project: { ok: true } }, tools: [] } }), + ); +}); async function gotoWorkQueue(page: Page) { await page.addInitScript(() => { @@ -130,7 +141,7 @@ test.describe("familiar work queue (PR control tower)", () => { const noPr = page.locator(".fwq").getByRole("region", { name: "No open PR" }); await noPr.getByRole("button", { name: "Claim", exact: true }).click(); - await expect.poll(() => claimBody).toEqual({ action: "claim", id: "cave-bb2" }); + await expect.poll(() => claimBody).toEqual({ action: "claim", id: "cave-bb2", projectRoot: QUEUE_PROJECT.root }); }); test("claiming for a familiar posts the selected assignee", async ({ page }) => { @@ -148,7 +159,7 @@ test.describe("familiar work queue (PR control tower)", () => { const noPr = page.locator(".fwq").getByRole("region", { name: "No open PR" }); await noPr.getByRole("button", { name: "Claim for familiar…" }).click(); await page.getByRole("menuitemradio", { name: "Kitty" }).click(); - await expect.poll(() => claimBody).toEqual({ action: "claim", id: "cave-bb2", assignee: "kitty" }); + await expect.poll(() => claimBody).toEqual({ action: "claim", id: "cave-bb2", assignee: "kitty", projectRoot: QUEUE_PROJECT.root }); }); test("cleanup Close is gated on a handoff note; adding one posts a comment and unlocks it", async ({ page }) => { @@ -188,6 +199,7 @@ test.describe("familiar work queue (PR control tower)", () => { action: "comment", id: "cave-open", comment: "Verified: lanes render, close gated.", + projectRoot: QUEUE_PROJECT.root, }); // …and Close unlocks (optimistic, without waiting for a re-read). await expect(cleanup.getByRole("button", { name: "Close bead" })).toBeEnabled(); diff --git a/tests/onboarding-wizard.spec.ts b/tests/onboarding-wizard.spec.ts index d4e524abb..6349d6411 100644 --- a/tests/onboarding-wizard.spec.ts +++ b/tests/onboarding-wizard.spec.ts @@ -19,6 +19,7 @@ const FRESH_STATUS = { git: { ok: true, optional: true, detail: "/usr/bin/git" }, adapters: { ok: false, detail: "no adapters detected" }, daemon: { ok: false, detail: "daemon socket not reachable" }, + project: { ok: false, detail: "Choose a Queue project" }, // Advisory since familiar creation moved to the in-app summoning circle. familiars: { ok: false, optional: true, detail: "no familiars" }, binding: { ok: false, optional: true, detail: "no binding configured" }, @@ -37,6 +38,7 @@ const DAEMON_DOWN_VETERAN_STATUS = { git: { ok: true, optional: true, detail: "/usr/bin/git" }, adapters: { ok: true, detail: "Codex" }, daemon: { ok: false, detail: "daemon socket not reachable" }, + project: { ok: true, detail: "Queue project is ready." }, familiars: { ok: false, optional: true, detail: "daemon offline" }, binding: { ok: false, optional: true, detail: "Waiting for the daemon" }, }, @@ -56,6 +58,7 @@ const COMPLETE_NO_FAMILIARS_STATUS = { git: { ok: true, optional: true, detail: "/usr/bin/git" }, adapters: { ok: true, detail: "Codex" }, daemon: { ok: true, detail: "running" }, + project: { ok: true, detail: "Queue project is ready." }, familiars: { ok: false, optional: true, detail: "no familiars" }, binding: { ok: false, optional: true, detail: "no binding configured" }, }, From 7b3d7e6cc9f4a690a7518bd84dded401ab3e04b2 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:16:48 -0400 Subject: [PATCH 14/38] test(onboarding): preserve required project gate --- tests/onboarding-wizard.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/onboarding-wizard.spec.ts b/tests/onboarding-wizard.spec.ts index 6349d6411..da89820b4 100644 --- a/tests/onboarding-wizard.spec.ts +++ b/tests/onboarding-wizard.spec.ts @@ -146,7 +146,10 @@ test.describe("onboarding wizard", () => { }); test("stays hidden once dismissed", async ({ page }) => { - await gotoApp(page, FRESH_STATUS, { dismissed: true }); + // A persisted dismissal is not permitted to bypass a missing required + // Queue project. Use a genuinely complete setup to exercise the stored + // dismissal behavior without masking a prerequisite regression. + await gotoApp(page, COMPLETE_NO_FAMILIARS_STATUS, { dismissed: true }); await page.getByRole("searchbox").first().waitFor({ state: "visible", timeout: 30_000 }); await page.waitForTimeout(1_000); await expect(wizard(page)).toHaveCount(0); From 402640637e9b1f02f48c48c2f641e511e9b795e5 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:34:52 -0400 Subject: [PATCH 15/38] fix(queue): harden project readiness --- src/app/api/beads/prs/route.ts | 4 +- src/app/api/beads/route.ts | 3 +- src/app/api/queue/readiness/route.ts | 20 +++++++-- src/lib/queue-project-readiness.test.ts | 21 ++++++++- src/lib/queue-project-readiness.ts | 60 ++++++++++++++++++++----- 5 files changed, 89 insertions(+), 19 deletions(-) diff --git a/src/app/api/beads/prs/route.ts b/src/app/api/beads/prs/route.ts index 0719fcab7..538709fb7 100644 --- a/src/app/api/beads/prs/route.ts +++ b/src/app/api/beads/prs/route.ts @@ -61,7 +61,9 @@ export async function GET(req: Request) { if (forbidden) return forbidden; const url = new URL(req.url); - const root = await resolveRepoRoot(url.searchParams.get("projectRoot") || process.cwd()); + const projectRoot = url.searchParams.get("projectRoot"); + if (!projectRoot) return NextResponse.json({ ok: false, error: "projectRoot is required" }, { status: 400 }); + const root = await resolveRepoRoot(projectRoot); if (!root.ok) { if ((root.error || "path not allowed") === "path not allowed") { return NextResponse.json({ ok: false, error: "path not allowed" }, { status: 403 }); diff --git a/src/app/api/beads/route.ts b/src/app/api/beads/route.ts index 1c2dff6e7..ce288b2ab 100644 --- a/src/app/api/beads/route.ts +++ b/src/app/api/beads/route.ts @@ -5,6 +5,7 @@ import { readJsonBody, rejectNonLocalRequest } from "@/lib/server/api-security"; import { runBdCommand } from "@/lib/server/beads-cli"; import { MAX_SESSION_JSON_BYTES } from "@/lib/server/session-security"; import { resolveRepoRoot } from "@/lib/server/issue-worktree-provision"; +import { takeQueueReadyProbe } from "@/lib/queue-project-readiness"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -89,7 +90,7 @@ export async function GET(req: Request) { default: return NextResponse.json({ ok: false, error: "unsupported mode" }, { status: 400 }); } - const result = await runBdCommand(root.repoRoot, root.beadsDir, args); + const result = mode === "ready" ? takeQueueReadyProbe(root.repoRoot) ?? await runBdCommand(root.repoRoot, root.beadsDir, args) : await runBdCommand(root.repoRoot, root.beadsDir, args); if (!result.ok) { return NextResponse.json( { ok: false, error: result.error, stdout: result.stdout, stderr: result.stderr }, diff --git a/src/app/api/queue/readiness/route.ts b/src/app/api/queue/readiness/route.ts index 275f2df40..a2dda3f23 100644 --- a/src/app/api/queue/readiness/route.ts +++ b/src/app/api/queue/readiness/route.ts @@ -4,7 +4,7 @@ import { NextResponse } from "next/server"; import { runBdCommand } from "@/lib/server/beads-cli"; import { readJsonBody, rejectNonLocalRequest } from "@/lib/server/api-security"; -import { queueProjectReadiness, selectQueueProject } from "@/lib/queue-project-readiness"; +import { QueueProjectStorageError, queueProjectReadiness, selectQueueProject } from "@/lib/queue-project-readiness"; import { MAX_SESSION_JSON_BYTES } from "@/lib/server/session-security"; export const dynamic = "force-dynamic"; @@ -49,7 +49,15 @@ export async function POST(req: Request) { const projectId = body.projectId.trim(); if (body.action === "select") { - const project = await selectQueueProject(projectId); + let project; + try { + project = await selectQueueProject(projectId); + } catch (cause) { + const error = cause instanceof QueueProjectStorageError || cause instanceof Error + ? cause.message + : "Couldn’t save the Queue project selection."; + return NextResponse.json({ ok: false, error }, { status: 503 }); + } if (!project) return NextResponse.json({ ok: false, error: "project not found" }, { status: 404 }); return NextResponse.json({ ok: true, readiness: await queueProjectReadiness() }); } @@ -63,7 +71,13 @@ export async function POST(req: Request) { // Re-read the persisted selection after the per-repository lock: another // window may have selected a different project while this request waited. const current = await queueProjectReadiness(); - if (!current.canGenerate || !current.project || current.project.id !== projectId || current.project.root !== readiness.project?.root) { + const identityMatches = current.project?.id === projectId && current.project.root === readiness.project?.root; + if (current.ok && identityMatches) { + // Another window completed the same initialization while this caller + // waited for the lock. Generate is idempotent for that identity. + return NextResponse.json({ ok: true, readiness: current }); + } + if (!current.canGenerate || !current.project || !identityMatches) { return NextResponse.json({ ok: false, error: "Queue project changed; choose Generate again for the current project.", readiness: current }, { status: 409 }); } const result = await runBdCommand( diff --git a/src/lib/queue-project-readiness.test.ts b/src/lib/queue-project-readiness.test.ts index 8e9c50f96..2f14156b1 100644 --- a/src/lib/queue-project-readiness.test.ts +++ b/src/lib/queue-project-readiness.test.ts @@ -47,8 +47,10 @@ try { assert.equal(partial.code, "needs-beads", "an empty .beads directory remains repairable"); assert.equal(partial.canGenerate, true); await rm(path.join(projectRoot, ".beads"), { recursive: true, force: true }); - execFileSync("bd", ["init"], { cwd: projectRoot, stdio: "pipe" }); - const ready = await queueProjectReadiness(); + await mkdir(path.join(projectRoot, ".beads")); + const ready = await queueProjectReadiness({ + beadsProbe: async () => ({ ok: true, stdout: "[]", stderr: "" }), + }); assert.equal(ready.code, "ready"); assert.equal(ready.ok, true); @@ -92,6 +94,13 @@ try { assert.equal(stale.code, "project-missing", "a path from another host is remediated before invoking Git"); assert.match(stale.message, /Choose a project again/); + await writeFile(queueProjectPath, "{ not valid JSON", "utf8"); + assert.equal( + (await queueProjectReadiness()).code, + "project-storage-error", + "a corrupt selection reports storage remediation instead of pretending no project was selected", + ); + const route = await (await import("node:fs/promises")).readFile( new URL("../app/api/queue/readiness/route.ts", import.meta.url), "utf8", @@ -99,6 +108,14 @@ try { assert.match(route, /rejectNonLocalRequest/, "selection and generation are loopback-only"); assert.match(route, /projectId is required/, "Generate is bound to an explicit project identity"); assert.match(route, /withGenerationLock/, "Generate serializes initialization per repository"); + assert.match(route, /current\.ok && identityMatches/, "a matching concurrent Generate succeeds idempotently"); + + const prBridgeRoute = await (await import("node:fs/promises")).readFile( + new URL("../app/api/beads/prs/route.ts", import.meta.url), + "utf8", + ); + assert.match(prBridgeRoute, /projectRoot is required/, "the PR bridge rejects anonymous Queue requests"); + assert.doesNotMatch(prBridgeRoute, /projectRoot\) \|\| process\.cwd\(\)/, "the PR bridge cannot use the app cwd as a project fallback"); } finally { if (previousProjectsPath === undefined) delete process.env.CAVE_PROJECTS_PATH_OVERRIDE; else process.env.CAVE_PROJECTS_PATH_OVERRIDE = previousProjectsPath; diff --git a/src/lib/queue-project-readiness.ts b/src/lib/queue-project-readiness.ts index dcfc7b515..438748d66 100644 --- a/src/lib/queue-project-readiness.ts +++ b/src/lib/queue-project-readiness.ts @@ -8,7 +8,7 @@ import { loadProjects, projectById } from "@/lib/cave-projects"; import type { CaveProject } from "@/lib/cave-projects-types"; import { caveHome } from "@/lib/coven-paths"; import { isAllowedNewProjectRoot, validateCaveProjectRoot } from "@/lib/server/project-paths"; -import { runBdCommand } from "@/lib/server/beads-cli"; +import { runBdCommand, type BdResult } from "@/lib/server/beads-cli"; const execFileAsync = promisify(execFile); const GIT_TIMEOUT_MS = 10_000; @@ -27,7 +27,8 @@ export type QueueProjectReadinessCode = | "not-git-repository" | "git-unavailable" | "git-error" - | "project-not-git-root"; + | "project-not-git-root" + | "project-storage-error"; export type QueueProjectReadiness = { ok: boolean; @@ -38,6 +39,18 @@ export type QueueProjectReadiness = { canGenerate: boolean; }; +type BeadsProbe = (repoRoot: string, beadsDir: string, args: string[]) => Promise; +type QueueProjectReadinessOptions = { beadsProbe?: BeadsProbe }; +const READY_PROBE_TTL_MS = 2_000; +const readyProbeCache = new Map(); + +export class QueueProjectStorageError extends Error { + constructor(message = "Cave could not read or save the Queue project selection.") { + super(message); + this.name = "QueueProjectStorageError"; + } +} + function queueProjectFilePath(): string { // The data directory is chosen at runtime. Keep it out of Next's output // tracing so a packaged Queue check does not pull the checkout into the @@ -72,11 +85,15 @@ async function readSelectedProjectId(): Promise { try { const raw = await readFile(/* turbopackIgnore: true */ queueProjectFilePath(), "utf8"); const value = JSON.parse(raw) as Partial; - return value.version === 1 && typeof value.projectId === "string" && value.projectId.trim() - ? value.projectId.trim() - : null; - } catch { - return null; + if (value.version !== 1 || typeof value.projectId !== "string" || !value.projectId.trim()) { + throw new QueueProjectStorageError("The saved Queue project selection is invalid. Choose the project again."); + } + return value.projectId.trim(); + } catch (cause) { + const error = cause as NodeJS.ErrnoException; + if (error.code === "ENOENT") return null; + if (cause instanceof QueueProjectStorageError) throw cause; + throw new QueueProjectStorageError("Cave could not read the saved Queue project selection. Check Cave home permissions and try again."); } } @@ -123,7 +140,7 @@ async function gitTopLevel(root: string): Promise { } } -async function hasUsableBeadsWorkspace(repoRoot: string): Promise { +async function hasUsableBeadsWorkspace(repoRoot: string, probe: BeadsProbe): Promise { const beadsDir = path.join(/* turbopackIgnore: true */ repoRoot, ".beads"); try { const entry = await lstat(/* turbopackIgnore: true */ beadsDir); @@ -135,7 +152,20 @@ async function hasUsableBeadsWorkspace(repoRoot: string): Promise { } // Directory presence alone is not a workspace: a failed bd init can leave an // empty .beads behind. A read-only probe keeps Generate available to repair it. - return (await runBdCommand(repoRoot, beadsDir, ["ready", "--json"])).ok; + const result = await probe(repoRoot, beadsDir, ["ready", "--json"]); + if (result.ok) readyProbeCache.set(repoRoot, { expiresAt: Date.now() + READY_PROBE_TTL_MS, result }); + return result.ok; +} + +/** Let the immediately-following Queue list read reuse its verified bd ready output. */ +export function takeQueueReadyProbe(repoRoot: string): BdResult | null { + const cached = readyProbeCache.get(repoRoot); + if (!cached || cached.expiresAt < Date.now()) { + readyProbeCache.delete(repoRoot); + return null; + } + readyProbeCache.delete(repoRoot); + return cached.result; } function projectShape(project: CaveProject): Pick { @@ -147,8 +177,14 @@ function projectShape(project: CaveProject): Pick { - const projectId = await readSelectedProjectId(); +export async function queueProjectReadiness(options: QueueProjectReadinessOptions = {}): Promise { + let projectId: string | null; + try { + projectId = await readSelectedProjectId(); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Cave could not read the Queue project selection."; + return { ok: false, code: "project-storage-error", message, project: null, canGenerate: false }; + } if (!projectId) { return { ok: false, @@ -227,7 +263,7 @@ export async function queueProjectReadiness(): Promise { }; } - if (!(await hasUsableBeadsWorkspace(gitRoot.root))) { + if (!(await hasUsableBeadsWorkspace(gitRoot.root, options.beadsProbe ?? runBdCommand))) { return { ok: false, code: "needs-beads", From 7ad56d12b7a71a627cd4a263c94599b6bbc1dc43 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:34:53 -0400 Subject: [PATCH 16/38] fix(queue): clarify source recovery --- src/components/familiar-work-queue-view.test.ts | 4 +++- src/components/familiar-work-queue-view.tsx | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/familiar-work-queue-view.test.ts b/src/components/familiar-work-queue-view.test.ts index bea7bf0ff..a82866900 100644 --- a/src/components/familiar-work-queue-view.test.ts +++ b/src/components/familiar-work-queue-view.test.ts @@ -153,7 +153,9 @@ assert.match(view, /action, id, projectRoot/, "claim and close mutations receive assert.match(view, /action: "comment", id, comment, projectRoot/, "handoff comments receive the selected root"); assert.match(view, /action: "claim", id, assignee: familiar\.id, projectRoot/, "claim-for receives the selected root"); assert.match(view, /projectRoot=\{readiness\?\.project\?\.root\}/, "Asana filing receives the selected root"); -assert.match(view, /headline=\{readinessUnavailable \? "Queue check unavailable" : canGenerate \? "Generate your Queue" : "Queue needs a project"\}/); +assert.match(view, /const sourcesUnavailable = !readinessUnavailable && readiness\?\.ok === true && readiness\.project !== null/, "a ready project with failing adapters is not treated as unselected"); +assert.match(view, /headline=\{readinessUnavailable \? "Queue check unavailable" : sourcesUnavailable \? "Queue sources unavailable" : canGenerate \? "Generate your Queue" : "Queue needs a project"\}/); +assert.match(view, /readinessUnavailable \|\| sourcesUnavailable \? null/, "adapter failures offer Retry rather than project selection"); assert.match(view, />\s*Generate\s*<\/Button>/, "empty Queue state offers Generate"); assert.doesNotMatch(view, /readSurfaceResource\("tasks:queue"/, "Queue no longer consumes an unscoped warm cache"); diff --git a/src/components/familiar-work-queue-view.tsx b/src/components/familiar-work-queue-view.tsx index 58a0872f8..19b38e946 100644 --- a/src/components/familiar-work-queue-view.tsx +++ b/src/components/familiar-work-queue-view.tsx @@ -548,16 +548,17 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa if (error && !queue) { const canGenerate = readiness?.canGenerate === true; const readinessUnavailable = readinessFailure !== null; + const sourcesUnavailable = !readinessUnavailable && readiness?.ok === true && readiness.project !== null; return (
- {readinessUnavailable ? null : canGenerate ? ( + {readinessUnavailable || sourcesUnavailable ? null : canGenerate ? ( From 32ee3f90abd45420ae4f95f93a1947099509c257 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:34:53 -0400 Subject: [PATCH 17/38] fix(queue): reset Asana filing per project --- src/components/asana-queue-strip.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/asana-queue-strip.tsx b/src/components/asana-queue-strip.tsx index f904cae05..4b1cca49f 100644 --- a/src/components/asana-queue-strip.tsx +++ b/src/components/asana-queue-strip.tsx @@ -51,6 +51,12 @@ export function AsanaQueueStrip({ onOpenUrl, onFiledBead, familiarId, projectRoo const [filed, setFiled] = useState>(() => new Set()); const abortRef = useRef(null); + // Filed is a per-Queue-project fact. The same Asana item may validly be + // filed into a different selected repository after a project switch. + useEffect(() => { + setFiled(new Set()); + }, [projectRoot]); + const load = useCallback(async () => { abortRef.current?.abort(); const ctrl = new AbortController(); From 71c943ab8c3017b7aed763f04899aa14c8d6b247 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:34:54 -0400 Subject: [PATCH 18/38] fix(onboarding): require Git for Queue projects --- src/app/api/onboarding/status/route.test.ts | 20 ++++++++++---------- src/app/api/onboarding/status/route.ts | 16 +++++++--------- src/components/onboarding-overlay.tsx | 9 ++++----- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/app/api/onboarding/status/route.test.ts b/src/app/api/onboarding/status/route.test.ts index 9bbe02021..2692db128 100644 --- a/src/app/api/onboarding/status/route.test.ts +++ b/src/app/api/onboarding/status/route.test.ts @@ -94,18 +94,18 @@ assert.doesNotMatch( "onboarding status should not return stale repo-source CLI install guidance", ); -// Dependency coverage: machines without git still complete onboarding, but -// the checklist must surface git as a recommended install with a hint. +// Queue project selection is a Git-repository boundary, so missing Git blocks +// onboarding with an actionable installation hint. assert.match(source, /async function checkGit\(\): Promise/, "preflight checks for git"); assert.match( source, - /optional: true/, - "git is an advisory step — its absence must not gate onboarding", + /Git is required to select and use a Queue project/, + "git failure explains the required Queue prerequisite", ); -assert.match( +assert.doesNotMatch( source, - /s\.ok \|\| s\.optional/, - "complete treats optional steps as non-blocking", + /if \(found\) return \{ ok: true, optional: true, detail: found \}/, + "git is not marked optional when Queue project selection is required", ); // Familiar creation lives in the in-app Summoning Circle now: the familiars @@ -128,8 +128,8 @@ assert.match( ); assert.match( source, - /changes panel, project files, and checkpoints need Git/, - "git hint names the features that need it", + /Git is required to select and use a Queue project/, + "git hint names the required Queue feature", ); assert.match( source, @@ -146,7 +146,7 @@ const onboardingModel = readFileSync( "utf8", ); assert.match(onboardingModel, /git\?: Step/, "onboarding model accepts the git step"); -assert.match(overlay, /Find Git \(recommended\)/, "overlay renders the git checklist row"); +assert.match(overlay, /title: "Find Git"/, "overlay renders the required git checklist row"); const projectFiles = readFileSync( new URL("../../project/files/route.ts", import.meta.url), diff --git a/src/app/api/onboarding/status/route.ts b/src/app/api/onboarding/status/route.ts index 07f9d6abe..7155fcca5 100644 --- a/src/app/api/onboarding/status/route.ts +++ b/src/app/api/onboarding/status/route.ts @@ -40,17 +40,16 @@ function gitInstallHint(): string { } /** - * Advisory: Cave boots and chats without git (Node ships inside the app - * bundle), but the changes panel, project file tree, and checkpoints all - * shell out to it. Missing git never blocks onboarding completion. + * Queue project selection is a required Git-repository boundary. Cave can + * render some surfaces without Git, but it cannot safely initialize or load + * the selected Queue project until Git is available. */ async function checkGit(): Promise { const found = await commandPath("git"); - if (found) return { ok: true, optional: true, detail: found }; + if (found) return { ok: true, detail: found }; return { ok: false, - optional: true, - hint: `Chat works without it, but the changes panel, project files, and checkpoints need Git. ${gitInstallHint()}`, + hint: `Git is required to select and use a Queue project. ${gitInstallHint()}`, }; } @@ -329,9 +328,8 @@ export async function GET() { // detail stays informative for the checklist and diagnostics only. binding: { ...binding, optional: true }, }; - // Optional steps (git, familiars, binding) surface in the checklist but - // never gate completion — `complete` means the required infrastructure and - // Queue project are ready; the first familiar is summoned in-app. + // Optional familiar and binding steps surface in the checklist but never + // gate completion. Git and the Queue project remain required boundaries. const complete = Object.values(steps).every((s) => s.ok || s.optional); return NextResponse.json({ ok: true, complete, steps, tools: openCovenTools }); diff --git a/src/components/onboarding-overlay.tsx b/src/components/onboarding-overlay.tsx index 2bdacd940..66bb86191 100644 --- a/src/components/onboarding-overlay.tsx +++ b/src/components/onboarding-overlay.tsx @@ -928,14 +928,13 @@ export function OnboardingOverlay({ }, { key: "git", - title: "Find Git (recommended)", - optional: true, - // Advisory: absence never blocks setup; treat "not reported" as fine. - ok: s?.git ? s.git.ok : true, + title: "Find Git", + // Queue project selection is a required Git-repository boundary. + ok: !!s?.git?.ok, detail: s?.git?.detail ?? s?.git?.hint ?? - "Powers the changes panel, project files, and checkpoints.", + "Git is required to select and use a Queue project.", icon: "ph:git-branch-bold", }, ]; From d449c2897711196f97eb8a1fd5a878d57119e785 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:56:54 -0400 Subject: [PATCH 19/38] fix(queue): distinguish Beads recovery states --- src/app/api/onboarding/status/route.test.ts | 6 ++ src/app/api/onboarding/status/route.ts | 4 +- src/app/api/queue/readiness/route.ts | 3 +- .../familiar-work-queue-view.test.ts | 5 +- src/components/familiar-work-queue-view.tsx | 5 +- src/lib/queue-project-readiness.test.ts | 32 +++++++- src/lib/queue-project-readiness.ts | 78 ++++++++++++++++--- 7 files changed, 113 insertions(+), 20 deletions(-) diff --git a/src/app/api/onboarding/status/route.test.ts b/src/app/api/onboarding/status/route.test.ts index 2692db128..359c9caf9 100644 --- a/src/app/api/onboarding/status/route.test.ts +++ b/src/app/api/onboarding/status/route.test.ts @@ -76,6 +76,12 @@ assert.doesNotMatch( "the frequently-polled status route never invokes package-registry discovery", ); +assert.match( + source, + /cachedQueueProjectReadiness\(\)/, + "the onboarding heartbeat reuses the short Queue readiness cache instead of spawning Git and bd every poll", +); + assert.match( source, /openCovenTools\.find\(\(tool\) => tool\.id === "coven-cli"\)/, diff --git a/src/app/api/onboarding/status/route.ts b/src/app/api/onboarding/status/route.ts index 7155fcca5..dd9d0b838 100644 --- a/src/app/api/onboarding/status/route.ts +++ b/src/app/api/onboarding/status/route.ts @@ -19,7 +19,7 @@ import { type AdapterReport, type CovenAdapterSummary, } from "@/lib/harness-adapters"; -import { queueProjectReadiness } from "@/lib/queue-project-readiness"; +import { cachedQueueProjectReadiness } from "@/lib/queue-project-readiness"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -296,7 +296,7 @@ export async function GET() { checkGit(), checkDaemon(), checkFamiliars(), - queueProjectReadiness(), + cachedQueueProjectReadiness(), ]); const covenCli = checkCovenCli( openCovenTools.find((tool) => tool.id === "coven-cli"), diff --git a/src/app/api/queue/readiness/route.ts b/src/app/api/queue/readiness/route.ts index a2dda3f23..b73dcdad0 100644 --- a/src/app/api/queue/readiness/route.ts +++ b/src/app/api/queue/readiness/route.ts @@ -4,7 +4,7 @@ import { NextResponse } from "next/server"; import { runBdCommand } from "@/lib/server/beads-cli"; import { readJsonBody, rejectNonLocalRequest } from "@/lib/server/api-security"; -import { QueueProjectStorageError, queueProjectReadiness, selectQueueProject } from "@/lib/queue-project-readiness"; +import { invalidateQueueProjectReadinessCache, QueueProjectStorageError, queueProjectReadiness, selectQueueProject } from "@/lib/queue-project-readiness"; import { MAX_SESSION_JSON_BYTES } from "@/lib/server/session-security"; export const dynamic = "force-dynamic"; @@ -88,6 +88,7 @@ export async function POST(req: Request) { if (!result.ok) { return NextResponse.json({ ok: false, error: result.error, readiness: current }, { status: result.status }); } + invalidateQueueProjectReadinessCache(); return NextResponse.json({ ok: true, readiness: await queueProjectReadiness() }); }); } diff --git a/src/components/familiar-work-queue-view.test.ts b/src/components/familiar-work-queue-view.test.ts index a82866900..026123e36 100644 --- a/src/components/familiar-work-queue-view.test.ts +++ b/src/components/familiar-work-queue-view.test.ts @@ -154,8 +154,9 @@ assert.match(view, /action: "comment", id, comment, projectRoot/, "handoff comme assert.match(view, /action: "claim", id, assignee: familiar\.id, projectRoot/, "claim-for receives the selected root"); assert.match(view, /projectRoot=\{readiness\?\.project\?\.root\}/, "Asana filing receives the selected root"); assert.match(view, /const sourcesUnavailable = !readinessUnavailable && readiness\?\.ok === true && readiness\.project !== null/, "a ready project with failing adapters is not treated as unselected"); -assert.match(view, /headline=\{readinessUnavailable \? "Queue check unavailable" : sourcesUnavailable \? "Queue sources unavailable" : canGenerate \? "Generate your Queue" : "Queue needs a project"\}/); -assert.match(view, /readinessUnavailable \|\| sourcesUnavailable \? null/, "adapter failures offer Retry rather than project selection"); +assert.match(view, /const projectUnavailable = !readinessUnavailable && !sourcesUnavailable && readiness\?\.project !== null/, "a selected but unusable Beads project has its own remediation state"); +assert.match(view, /headline=\{readinessUnavailable \? "Queue check unavailable" : sourcesUnavailable \? "Queue sources unavailable" : projectUnavailable \? "Queue project needs attention" : canGenerate \? "Generate your Queue" : "Queue needs a project"\}/); +assert.match(view, /readinessUnavailable \|\| sourcesUnavailable \|\| projectUnavailable \? null/, "adapter and Beads failures offer Retry rather than project selection"); assert.match(view, />\s*Generate\s*<\/Button>/, "empty Queue state offers Generate"); assert.doesNotMatch(view, /readSurfaceResource\("tasks:queue"/, "Queue no longer consumes an unscoped warm cache"); diff --git a/src/components/familiar-work-queue-view.tsx b/src/components/familiar-work-queue-view.tsx index 19b38e946..852adeca4 100644 --- a/src/components/familiar-work-queue-view.tsx +++ b/src/components/familiar-work-queue-view.tsx @@ -549,16 +549,17 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa const canGenerate = readiness?.canGenerate === true; const readinessUnavailable = readinessFailure !== null; const sourcesUnavailable = !readinessUnavailable && readiness?.ok === true && readiness.project !== null; + const projectUnavailable = !readinessUnavailable && !sourcesUnavailable && readiness?.project !== null; return (
- {readinessUnavailable || sourcesUnavailable ? null : canGenerate ? ( + {readinessUnavailable || sourcesUnavailable || projectUnavailable ? null : canGenerate ? ( diff --git a/src/lib/queue-project-readiness.test.ts b/src/lib/queue-project-readiness.test.ts index 2f14156b1..6c1013357 100644 --- a/src/lib/queue-project-readiness.test.ts +++ b/src/lib/queue-project-readiness.test.ts @@ -32,7 +32,12 @@ try { }), ); - const { queueProjectReadiness, selectQueueProject } = await import("./queue-project-readiness.ts"); + const { + cachedQueueProjectReadiness, + invalidateQueueProjectReadinessCache, + queueProjectReadiness, + selectQueueProject, + } = await import("./queue-project-readiness.ts"); assert.equal((await queueProjectReadiness()).code, "no-project", "Queue never falls back to the app cwd"); assert.equal((await selectQueueProject("queue-project"))?.root, projectRoot, "selection persists a registered project"); @@ -43,9 +48,12 @@ try { assert.equal(needsBeads.project?.root, projectRoot, "the selected repository remains the command root"); await mkdir(path.join(projectRoot, ".beads")); - const partial = await queueProjectReadiness(); - assert.equal(partial.code, "needs-beads", "an empty .beads directory remains repairable"); - assert.equal(partial.canGenerate, true); + const unavailable = await queueProjectReadiness({ + beadsProbe: async () => ({ ok: false, status: 503, error: "bd unavailable", stdout: "", stderr: "" }), + }); + assert.equal(unavailable.code, "beads-unavailable", "an unavailable bd CLI is not presented as a generatable workspace"); + assert.equal(unavailable.canGenerate, false); + assert.match(unavailable.message, /Install or repair the bd CLI/); await rm(path.join(projectRoot, ".beads"), { recursive: true, force: true }); await mkdir(path.join(projectRoot, ".beads")); const ready = await queueProjectReadiness({ @@ -54,6 +62,22 @@ try { assert.equal(ready.code, "ready"); assert.equal(ready.ok, true); + let probeCalls = 0; + const cachedProbe = async () => { + probeCalls += 1; + return { ok: true, stdout: "[]", stderr: "" }; + }; + invalidateQueueProjectReadinessCache(); + await Promise.all([ + cachedQueueProjectReadiness({ beadsProbe: cachedProbe }), + cachedQueueProjectReadiness({ beadsProbe: cachedProbe }), + ]); + await cachedQueueProjectReadiness({ beadsProbe: cachedProbe }); + assert.equal(probeCalls, 1, "concurrent onboarding heartbeats share one readiness probe inside the cache window"); + invalidateQueueProjectReadinessCache(); + await cachedQueueProjectReadiness({ beadsProbe: cachedProbe }); + assert.equal(probeCalls, 2, "selection or Generate invalidation refreshes readiness immediately"); + const nestedRoot = path.join(projectRoot, "nested"); await mkdir(nestedRoot); await writeFile( diff --git a/src/lib/queue-project-readiness.ts b/src/lib/queue-project-readiness.ts index 438748d66..cb8fc09f2 100644 --- a/src/lib/queue-project-readiness.ts +++ b/src/lib/queue-project-readiness.ts @@ -28,7 +28,9 @@ export type QueueProjectReadinessCode = | "git-unavailable" | "git-error" | "project-not-git-root" - | "project-storage-error"; + | "project-storage-error" + | "beads-unavailable" + | "beads-error"; export type QueueProjectReadiness = { ok: boolean; @@ -43,6 +45,9 @@ type BeadsProbe = (repoRoot: string, beadsDir: string, args: string[]) => Promis type QueueProjectReadinessOptions = { beadsProbe?: BeadsProbe }; const READY_PROBE_TTL_MS = 2_000; const readyProbeCache = new Map(); +const ONBOARDING_READINESS_TTL_MS = 5_000; +let cachedOnboardingReadiness: { expiresAt: number; readiness: QueueProjectReadiness; probe?: BeadsProbe } | null = null; +let onboardingReadinessInFlight: { probe?: BeadsProbe; pending: Promise } | null = null; export class QueueProjectStorageError extends Error { constructor(message = "Cave could not read or save the Queue project selection.") { @@ -103,6 +108,7 @@ export async function selectQueueProject(projectId: string): Promise { } } -async function hasUsableBeadsWorkspace(repoRoot: string, probe: BeadsProbe): Promise { +type BeadsWorkspaceStatus = + | { kind: "missing" } + | { kind: "ready" } + | { kind: "unavailable"; message: string } + | { kind: "error"; message: string }; + +async function beadsWorkspaceStatus(repoRoot: string, probe: BeadsProbe): Promise { const beadsDir = path.join(/* turbopackIgnore: true */ repoRoot, ".beads"); try { const entry = await lstat(/* turbopackIgnore: true */ beadsDir); - if (!entry.isDirectory() || entry.isSymbolicLink()) return false; + if (!entry.isDirectory() || entry.isSymbolicLink()) { + return { kind: "error", message: "The Queue Beads workspace is invalid. Repair it before loading Queue work." }; + } const canonicalBeads = await realpath(/* turbopackIgnore: true */ beadsDir); - if (canonicalBeads !== beadsDir && !canonicalBeads.startsWith(repoRoot + path.sep)) return false; - } catch { - return false; + if (canonicalBeads !== beadsDir && !canonicalBeads.startsWith(repoRoot + path.sep)) { + return { kind: "error", message: "The Queue Beads workspace points outside the selected project. Repair it before loading Queue work." }; + } + } catch (cause) { + const error = cause as NodeJS.ErrnoException; + if (error.code === "ENOENT") return { kind: "missing" }; + return { kind: "error", message: "Cave could not inspect the Queue Beads workspace. Check project permissions and try again." }; } // Directory presence alone is not a workspace: a failed bd init can leave an // empty .beads behind. A read-only probe keeps Generate available to repair it. const result = await probe(repoRoot, beadsDir, ["ready", "--json"]); - if (result.ok) readyProbeCache.set(repoRoot, { expiresAt: Date.now() + READY_PROBE_TTL_MS, result }); - return result.ok; + if (result.ok) { + readyProbeCache.set(repoRoot, { expiresAt: Date.now() + READY_PROBE_TTL_MS, result }); + return { kind: "ready" }; + } + if (result.status === 503 || /\bbd unavailable\b/i.test(result.error)) { + return { kind: "unavailable", message: "Beads is required to use the Queue project. Install or repair the bd CLI, then retry." }; + } + return { kind: "error", message: `Cave could not verify the Queue Beads workspace: ${result.error || "bd ready failed"}. Repair Beads, then retry.` }; } /** Let the immediately-following Queue list read reuse its verified bd ready output. */ @@ -168,6 +192,32 @@ export function takeQueueReadyProbe(repoRoot: string): BdResult | null { return cached.result; } +/** Clear the short onboarding cache whenever selection or generation changes it. */ +export function invalidateQueueProjectReadinessCache(): void { + cachedOnboardingReadiness = null; + onboardingReadinessInFlight = null; +} + +/** + * The onboarding heartbeat runs every two seconds. Cache its expensive Git and + * Beads probes briefly, while coalescing simultaneous status requests. + */ +export async function cachedQueueProjectReadiness(options: QueueProjectReadinessOptions = {}): Promise { + if (cachedOnboardingReadiness && cachedOnboardingReadiness.probe === options.beadsProbe && cachedOnboardingReadiness.expiresAt > Date.now()) { + return cachedOnboardingReadiness.readiness; + } + const inFlight = onboardingReadinessInFlight; + if (inFlight && inFlight.probe === options.beadsProbe) return inFlight.pending; + const pending = queueProjectReadiness(options).then((readiness) => { + cachedOnboardingReadiness = { expiresAt: Date.now() + ONBOARDING_READINESS_TTL_MS, readiness, probe: options.beadsProbe }; + return readiness; + }).finally(() => { + if (onboardingReadinessInFlight?.pending === pending) onboardingReadinessInFlight = null; + }); + onboardingReadinessInFlight = { probe: options.beadsProbe, pending }; + return pending; +} + function projectShape(project: CaveProject): Pick { return { id: project.id, name: project.name, root: project.root }; } @@ -263,7 +313,8 @@ export async function queueProjectReadiness(options: QueueProjectReadinessOption }; } - if (!(await hasUsableBeadsWorkspace(gitRoot.root, options.beadsProbe ?? runBdCommand))) { + const beads = await beadsWorkspaceStatus(gitRoot.root, options.beadsProbe ?? runBdCommand); + if (beads.kind === "missing") { return { ok: false, code: "needs-beads", @@ -272,6 +323,15 @@ export async function queueProjectReadiness(options: QueueProjectReadinessOption canGenerate: true, }; } + if (beads.kind === "unavailable" || beads.kind === "error") { + return { + ok: false, + code: beads.kind === "unavailable" ? "beads-unavailable" : "beads-error", + message: beads.message, + project: { ...selected, root: gitRoot.root }, + canGenerate: false, + }; + } return { ok: true, code: "ready", From c97ee0bddd0292638c987ed3b6ed70abab007ded Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:56:55 -0400 Subject: [PATCH 20/38] fix(onboarding): ignore stale project selections --- src/components/onboarding-guided-steps.test.ts | 6 ++++++ src/components/onboarding-overlay.tsx | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/onboarding-guided-steps.test.ts b/src/components/onboarding-guided-steps.test.ts index 18d767956..b2abaa447 100644 --- a/src/components/onboarding-guided-steps.test.ts +++ b/src/components/onboarding-guided-steps.test.ts @@ -119,6 +119,12 @@ assert.match( "Node.js setup instructions render inline when npm is missing", ); +assert.match( + source, + /const generation = \+\+requestGeneration\.current;[\s\S]{0,900}if \(generation !== requestGeneration\.current\) return;[\s\S]{0,700}if \(generation === requestGeneration\.current\) setSelecting\(false\);/, + "out-of-order Queue-project selections cannot overwrite or unlock the latest selection", +); + // ── Cross-platform instructions ───────────────────────────────────────────── for (const platform of ["windows", "linux", "mac"]) { diff --git a/src/components/onboarding-overlay.tsx b/src/components/onboarding-overlay.tsx index 66bb86191..3cc1abe6a 100644 --- a/src/components/onboarding-overlay.tsx +++ b/src/components/onboarding-overlay.tsx @@ -1581,7 +1581,7 @@ function QueueProjectSetup({ onSelected }: { onSelected: () => void }) { }, [refreshReadiness]); const selectProject = async (projectId: string) => { - ++requestGeneration.current; + const generation = ++requestGeneration.current; setSelecting(true); setError(null); try { @@ -1598,13 +1598,16 @@ function QueueProjectSetup({ onSelected }: { onSelected: () => void }) { if (!response.ok || !body.ok || !body.readiness) { throw new Error(body.error ?? "Couldn’t save the Queue project."); } + if (generation !== requestGeneration.current) return; setReadiness(body.readiness); window.dispatchEvent(new CustomEvent("cave:queue-project-selected", { detail: { project: body.readiness.project } })); onSelected(); } catch (cause) { - setError(cause instanceof Error ? cause.message : "Couldn’t save the Queue project."); + if (generation === requestGeneration.current) { + setError(cause instanceof Error ? cause.message : "Couldn’t save the Queue project."); + } } finally { - setSelecting(false); + if (generation === requestGeneration.current) setSelecting(false); } }; From 116c2b79126a6c0e70159c6638a2d85992fbe7cc Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:56:56 -0400 Subject: [PATCH 21/38] test(queue): cover selected root reads --- tests/familiar-work-queue.spec.ts | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/tests/familiar-work-queue.spec.ts b/tests/familiar-work-queue.spec.ts index 9bc55700c..29f6eaae2 100644 --- a/tests/familiar-work-queue.spec.ts +++ b/tests/familiar-work-queue.spec.ts @@ -41,7 +41,7 @@ test.beforeEach(async ({ page }) => { ); }); -async function gotoWorkQueue(page: Page) { +async function gotoWorkQueue(page: Page, onQueueRequest?: (request: import("@playwright/test").Request) => void) { await page.addInitScript(() => { window.localStorage.setItem("cave:onboarding:dismissed", "1"); window.localStorage.setItem("cave:active-familiar", "kitty"); @@ -61,10 +61,16 @@ async function gotoWorkQueue(page: Page) { // Regex matchers (not globs): glob `?` matches any char, so `/api/beads?…` // would also catch `/api/beads/prs`. These are unambiguous — /prs vs the // ?-queried ready list. - await page.route(/\/api\/beads\/prs/, (route) => - route.fulfill({ json: { ok: true, open: OPEN_PRS, merged: MERGED_PRS } }), - ); - await page.route(/\/api\/beads\?/, (route) => route.fulfill({ json: { ok: true, data: READY_BEADS } })); + await page.route(/\/api\/beads\/prs/, (route) => { + onQueueRequest?.(route.request()); + return route.fulfill({ json: { ok: true, open: OPEN_PRS, merged: MERGED_PRS } }); + }); + await page.route(/\/api\/beads\?/, (route) => { + const request = route.request(); + onQueueRequest?.(request); + if (new URL(request.url()).searchParams.get("mode") !== "ready") return route.fallback(); + return route.fulfill({ json: { ok: true, data: READY_BEADS } }); + }); await page.goto("/"); // The shell must be mounted before the mode-switch listener exists; dispatch @@ -144,6 +150,21 @@ test.describe("familiar work queue (PR control tower)", () => { await expect.poll(() => claimBody).toEqual({ action: "claim", id: "cave-bb2", projectRoot: QUEUE_PROJECT.root }); }); + test("selected Queue root scopes both list reads and detail", async ({ page }) => { + const readUrls: string[] = []; + await gotoWorkQueue(page, (request) => readUrls.push(request.url())); + + await expect.poll(() => readUrls.filter((url) => url.includes("/api/beads") || url.includes("/api/beads/prs")).length).toBeGreaterThanOrEqual(2); + for (const url of readUrls.filter((url) => url.includes("/api/beads") || url.includes("/api/beads/prs"))) { + expect(new URL(url).searchParams.get("projectRoot")).toBe(QUEUE_PROJECT.root); + } + + const fwq = page.locator(".fwq"); + await fwq.getByRole("region", { name: "No open PR" }).getByRole("button", { name: "iOS profile avatar" }).click(); + await expect(page.getByRole("dialog", { name: "Queue cave-bb2" })).toBeVisible(); + await expect.poll(() => readUrls.some((url) => new URL(url).searchParams.get("mode") === "show")).toBe(true); + }); + test("claiming for a familiar posts the selected assignee", async ({ page }) => { let claimBody: unknown = null; await page.route("**/api/beads", async (route) => { From bacc880e94456306e0c80723e36769727f4c3790 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:21:47 -0400 Subject: [PATCH 22/38] fix(queue): harden Beads recovery --- src/app/api/beads/route.ts | 15 ++---- .../familiar-work-queue-view.test.ts | 8 +-- src/components/familiar-work-queue-view.tsx | 11 ++++- src/lib/queue-project-readiness.test.ts | 39 ++++++++++++++- src/lib/queue-project-readiness.ts | 49 ++++++++++++++----- src/lib/server/beads-workspace.test.ts | 26 ++++++++++ src/lib/server/beads-workspace.ts | 27 ++++++++++ 7 files changed, 147 insertions(+), 28 deletions(-) create mode 100644 src/lib/server/beads-workspace.test.ts create mode 100644 src/lib/server/beads-workspace.ts diff --git a/src/app/api/beads/route.ts b/src/app/api/beads/route.ts index ce288b2ab..dd5193d9c 100644 --- a/src/app/api/beads/route.ts +++ b/src/app/api/beads/route.ts @@ -1,10 +1,9 @@ -import fs from "node:fs"; -import path from "node:path"; import { NextResponse } from "next/server"; import { readJsonBody, rejectNonLocalRequest } from "@/lib/server/api-security"; import { runBdCommand } from "@/lib/server/beads-cli"; import { MAX_SESSION_JSON_BYTES } from "@/lib/server/session-security"; import { resolveRepoRoot } from "@/lib/server/issue-worktree-provision"; +import { resolveSafeBeadsWorkspace } from "@/lib/server/beads-workspace"; import { takeQueueReadyProbe } from "@/lib/queue-project-readiness"; export const dynamic = "force-dynamic"; @@ -46,15 +45,9 @@ async function resolveProjectRoot(projectRoot: string | null) { if (!projectRoot) return { ok: false as const, status: 400, error: "projectRoot is required" }; const root = await resolveRepoRoot(projectRoot); if (!root.ok) return root; - const beadsDir = path.join(root.repoRoot, ".beads"); - try { - if (!fs.statSync(beadsDir).isDirectory()) { - return { ok: false as const, status: 422, error: "not a Beads workspace" }; - } - } catch { - return { ok: false as const, status: 422, error: "not a Beads workspace" }; - } - return { ok: true as const, repoRoot: root.repoRoot, beadsDir }; + const workspace = resolveSafeBeadsWorkspace(root.repoRoot); + if (!workspace.ok) return { ok: false as const, status: 422, error: workspace.error }; + return { ok: true as const, repoRoot: root.repoRoot, beadsDir: workspace.beadsDir }; } function projectRootErrorResponse(root: { status: number; error: string }) { diff --git a/src/components/familiar-work-queue-view.test.ts b/src/components/familiar-work-queue-view.test.ts index 026123e36..ce5af416b 100644 --- a/src/components/familiar-work-queue-view.test.ts +++ b/src/components/familiar-work-queue-view.test.ts @@ -154,9 +154,11 @@ assert.match(view, /action: "comment", id, comment, projectRoot/, "handoff comme assert.match(view, /action: "claim", id, assignee: familiar\.id, projectRoot/, "claim-for receives the selected root"); assert.match(view, /projectRoot=\{readiness\?\.project\?\.root\}/, "Asana filing receives the selected root"); assert.match(view, /const sourcesUnavailable = !readinessUnavailable && readiness\?\.ok === true && readiness\.project !== null/, "a ready project with failing adapters is not treated as unselected"); -assert.match(view, /const projectUnavailable = !readinessUnavailable && !sourcesUnavailable && readiness\?\.project !== null/, "a selected but unusable Beads project has its own remediation state"); -assert.match(view, /headline=\{readinessUnavailable \? "Queue check unavailable" : sourcesUnavailable \? "Queue sources unavailable" : projectUnavailable \? "Queue project needs attention" : canGenerate \? "Generate your Queue" : "Queue needs a project"\}/); -assert.match(view, /readinessUnavailable \|\| sourcesUnavailable \|\| projectUnavailable \? null/, "adapter and Beads failures offer Retry rather than project selection"); +assert.match(view, /code\?: string/, "Queue preserves readiness remediation codes from the server"); +assert.match(view, /const selectionRemediable = readiness\?\.code === "no-project"/, "stale and invalid selections retain a Choose-project recovery"); +assert.match(view, /const projectUnavailable = !readinessUnavailable && !sourcesUnavailable && !canGenerate && !selectionRemediable && readiness\?\.project !== null/, "only non-repairable selected projects suppress Generate"); +assert.match(view, /headline=\{readinessUnavailable \? "Queue check unavailable" : sourcesUnavailable \? "Queue sources unavailable" : canGenerate \? "Generate your Queue" : projectUnavailable \? "Queue project needs attention" : "Queue needs a project"\}/); +assert.match(view, /readinessUnavailable \|\| sourcesUnavailable \|\| projectUnavailable \? null : canGenerate \? \(/, "Generate wins before a selected-project warning and stale roots offer Choose project"); assert.match(view, />\s*Generate\s*<\/Button>/, "empty Queue state offers Generate"); assert.doesNotMatch(view, /readSurfaceResource\("tasks:queue"/, "Queue no longer consumes an unscoped warm cache"); diff --git a/src/components/familiar-work-queue-view.tsx b/src/components/familiar-work-queue-view.tsx index 852adeca4..28c20a64f 100644 --- a/src/components/familiar-work-queue-view.tsx +++ b/src/components/familiar-work-queue-view.tsx @@ -110,6 +110,7 @@ type FetchedQueue = { type QueueSource = { ok?: boolean; data?: ReadyBead[]; open?: PullRequestSummary[]; merged?: MergedPrRef[]; error?: string }; type QueueReadiness = { ok: boolean; + code?: string; message: string; canGenerate: boolean; project: { id: string; name: string; root: string } | null; @@ -549,13 +550,19 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa const canGenerate = readiness?.canGenerate === true; const readinessUnavailable = readinessFailure !== null; const sourcesUnavailable = !readinessUnavailable && readiness?.ok === true && readiness.project !== null; - const projectUnavailable = !readinessUnavailable && !sourcesUnavailable && readiness?.project !== null; + const selectionRemediable = readiness?.code === "no-project" + || readiness?.code === "project-missing" + || readiness?.code === "project-not-allowed" + || readiness?.code === "not-git-repository" + || readiness?.code === "project-not-git-root" + || readiness?.code === "project-storage-error"; + const projectUnavailable = !readinessUnavailable && !sourcesUnavailable && !canGenerate && !selectionRemediable && readiness?.project !== null; return (
diff --git a/src/lib/queue-project-readiness.test.ts b/src/lib/queue-project-readiness.test.ts index 6c1013357..2a15a9165 100644 --- a/src/lib/queue-project-readiness.test.ts +++ b/src/lib/queue-project-readiness.test.ts @@ -42,7 +42,15 @@ try { assert.equal((await queueProjectReadiness()).code, "no-project", "Queue never falls back to the app cwd"); assert.equal((await selectQueueProject("queue-project"))?.root, projectRoot, "selection persists a registered project"); - const needsBeads = await queueProjectReadiness(); + const unavailableWithoutWorkspace = await queueProjectReadiness({ + beadsProbe: async () => ({ ok: false, status: 503, error: "bd unavailable", stdout: "", stderr: "" }), + }); + assert.equal(unavailableWithoutWorkspace.code, "beads-unavailable", "missing .beads does not promise Generate when bd is unavailable"); + assert.equal(unavailableWithoutWorkspace.canGenerate, false); + + const needsBeads = await queueProjectReadiness({ + beadsProbe: async () => ({ ok: true, stdout: "bd 0.1.0", stderr: "" }), + }); assert.equal(needsBeads.code, "needs-beads"); assert.equal(needsBeads.canGenerate, true, "only a selected Git repository can offer Generate"); assert.equal(needsBeads.project?.root, projectRoot, "the selected repository remains the command root"); @@ -54,6 +62,12 @@ try { assert.equal(unavailable.code, "beads-unavailable", "an unavailable bd CLI is not presented as a generatable workspace"); assert.equal(unavailable.canGenerate, false); assert.match(unavailable.message, /Install or repair the bd CLI/); + + const partial = await queueProjectReadiness({ + beadsProbe: async () => ({ ok: false, status: 502, error: "workspace incomplete", stdout: "", stderr: "" }), + }); + assert.equal(partial.code, "needs-beads", "a partial workspace remains repairable through Generate"); + assert.equal(partial.canGenerate, true); await rm(path.join(projectRoot, ".beads"), { recursive: true, force: true }); await mkdir(path.join(projectRoot, ".beads")); const ready = await queueProjectReadiness({ @@ -78,6 +92,29 @@ try { await cachedQueueProjectReadiness({ beadsProbe: cachedProbe }); assert.equal(probeCalls, 2, "selection or Generate invalidation refreshes readiness immediately"); + let releaseOldProbe!: (result: { ok: true; stdout: string; stderr: string }) => void; + const oldProbe = new Promise<{ ok: true; stdout: string; stderr: string }>((resolve) => { releaseOldProbe = resolve; }); + invalidateQueueProjectReadinessCache(); + const staleA = cachedQueueProjectReadiness({ beadsProbe: async () => oldProbe }); + await writeFile( + projectsPath, + JSON.stringify({ + version: 1, + projects: [ + { id: "queue-project", name: "Queue project", root: projectRoot, createdAt: "2026-07-23T00:00:00.000Z", updatedAt: "2026-07-23T00:00:00.000Z" }, + { id: "invalid-project", name: "Invalid project", root: path.join(tempDir, "missing"), createdAt: "2026-07-23T00:00:00.000Z", updatedAt: "2026-07-23T00:00:00.000Z" }, + ], + }), + ); + await selectQueueProject("invalid-project"); + releaseOldProbe({ ok: true, stdout: "[]", stderr: "" }); + await staleA; + assert.equal( + (await cachedQueueProjectReadiness({ beadsProbe: async () => ({ ok: true, stdout: "[]", stderr: "" }) })).code, + "project-missing", + "a superseded readiness probe cannot repopulate the cache for a new selection", + ); + const nestedRoot = path.join(projectRoot, "nested"); await mkdir(nestedRoot); await writeFile( diff --git a/src/lib/queue-project-readiness.ts b/src/lib/queue-project-readiness.ts index cb8fc09f2..ac6860163 100644 --- a/src/lib/queue-project-readiness.ts +++ b/src/lib/queue-project-readiness.ts @@ -47,7 +47,8 @@ const READY_PROBE_TTL_MS = 2_000; const readyProbeCache = new Map(); const ONBOARDING_READINESS_TTL_MS = 5_000; let cachedOnboardingReadiness: { expiresAt: number; readiness: QueueProjectReadiness; probe?: BeadsProbe } | null = null; -let onboardingReadinessInFlight: { probe?: BeadsProbe; pending: Promise } | null = null; +let onboardingReadinessInFlight: { probe?: BeadsProbe; generation: number; pending: Promise } | null = null; +let onboardingReadinessGeneration = 0; export class QueueProjectStorageError extends Error { constructor(message = "Cave could not read or save the Queue project selection.") { @@ -149,9 +150,14 @@ async function gitTopLevel(root: string): Promise { type BeadsWorkspaceStatus = | { kind: "missing" } | { kind: "ready" } + | { kind: "repairable"; message: string } | { kind: "unavailable"; message: string } | { kind: "error"; message: string }; +function beadsUnavailable(result: BdResult): boolean { + return !result.ok && (result.status === 503 || /\bbd unavailable\b/i.test(result.error)); +} + async function beadsWorkspaceStatus(repoRoot: string, probe: BeadsProbe): Promise { const beadsDir = path.join(/* turbopackIgnore: true */ repoRoot, ".beads"); try { @@ -165,7 +171,18 @@ async function beadsWorkspaceStatus(repoRoot: string, probe: BeadsProbe): Promis } } catch (cause) { const error = cause as NodeJS.ErrnoException; - if (error.code === "ENOENT") return { kind: "missing" }; + if (error.code === "ENOENT") { + // Generate needs the same CLI as Queue reads. Check it before promising + // an initialization action that cannot possibly run. + const cli = await probe(repoRoot, beadsDir, ["--version"]); + if (beadsUnavailable(cli)) { + return { kind: "unavailable", message: "Beads is required to generate this Queue project. Install or repair the bd CLI, then retry." }; + } + if (!cli.ok) { + return { kind: "error", message: `Cave could not verify the bd CLI: ${cli.error || "bd --version failed"}. Repair Beads, then retry.` }; + } + return { kind: "missing" }; + } return { kind: "error", message: "Cave could not inspect the Queue Beads workspace. Check project permissions and try again." }; } // Directory presence alone is not a workspace: a failed bd init can leave an @@ -175,10 +192,13 @@ async function beadsWorkspaceStatus(repoRoot: string, probe: BeadsProbe): Promis readyProbeCache.set(repoRoot, { expiresAt: Date.now() + READY_PROBE_TTL_MS, result }); return { kind: "ready" }; } - if (result.status === 503 || /\bbd unavailable\b/i.test(result.error)) { + if (beadsUnavailable(result)) { return { kind: "unavailable", message: "Beads is required to use the Queue project. Install or repair the bd CLI, then retry." }; } - return { kind: "error", message: `Cave could not verify the Queue Beads workspace: ${result.error || "bd ready failed"}. Repair Beads, then retry.` }; + // `bd init` can leave a local directory before it has completed. It remains + // contained by this project and the CLI is available, so a serialized + // Generate retry is safe and is the intended repair route. + return { kind: "repairable", message: `Queue needs a Beads repair in ${repoRoot}. Generate will retry initialization.` }; } /** Let the immediately-following Queue list read reuse its verified bd ready output. */ @@ -194,8 +214,8 @@ export function takeQueueReadyProbe(repoRoot: string): BdResult | null { /** Clear the short onboarding cache whenever selection or generation changes it. */ export function invalidateQueueProjectReadinessCache(): void { + onboardingReadinessGeneration += 1; cachedOnboardingReadiness = null; - onboardingReadinessInFlight = null; } /** @@ -206,15 +226,20 @@ export async function cachedQueueProjectReadiness(options: QueueProjectReadiness if (cachedOnboardingReadiness && cachedOnboardingReadiness.probe === options.beadsProbe && cachedOnboardingReadiness.expiresAt > Date.now()) { return cachedOnboardingReadiness.readiness; } + const generation = onboardingReadinessGeneration; const inFlight = onboardingReadinessInFlight; - if (inFlight && inFlight.probe === options.beadsProbe) return inFlight.pending; + if (inFlight && inFlight.generation === generation && inFlight.probe === options.beadsProbe) return inFlight.pending; const pending = queueProjectReadiness(options).then((readiness) => { - cachedOnboardingReadiness = { expiresAt: Date.now() + ONBOARDING_READINESS_TTL_MS, readiness, probe: options.beadsProbe }; + if (generation === onboardingReadinessGeneration) { + cachedOnboardingReadiness = { expiresAt: Date.now() + ONBOARDING_READINESS_TTL_MS, readiness, probe: options.beadsProbe }; + } return readiness; }).finally(() => { - if (onboardingReadinessInFlight?.pending === pending) onboardingReadinessInFlight = null; + if (onboardingReadinessInFlight?.pending === pending && onboardingReadinessInFlight.generation === generation) { + onboardingReadinessInFlight = null; + } }); - onboardingReadinessInFlight = { probe: options.beadsProbe, pending }; + onboardingReadinessInFlight = { probe: options.beadsProbe, generation, pending }; return pending; } @@ -314,11 +339,13 @@ export async function queueProjectReadiness(options: QueueProjectReadinessOption } const beads = await beadsWorkspaceStatus(gitRoot.root, options.beadsProbe ?? runBdCommand); - if (beads.kind === "missing") { + if (beads.kind === "missing" || beads.kind === "repairable") { return { ok: false, code: "needs-beads", - message: `Queue is ready to generate in ${gitRoot.root}. Generate will initialize or repair its local Beads workspace.`, + message: beads.kind === "repairable" + ? beads.message + : `Queue is ready to generate in ${gitRoot.root}. Generate will initialize its local Beads workspace.`, project: { ...selected, root: gitRoot.root }, canGenerate: true, }; diff --git a/src/lib/server/beads-workspace.test.ts b/src/lib/server/beads-workspace.test.ts new file mode 100644 index 000000000..98a73e3a8 --- /dev/null +++ b/src/lib/server/beads-workspace.test.ts @@ -0,0 +1,26 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const temp = await mkdtemp(path.join(os.tmpdir(), "cave-beads-workspace-")); +const projectA = path.join(temp, "project-a"); +const projectB = path.join(temp, "project-b"); + +try { + await mkdir(path.join(projectA, ".beads"), { recursive: true }); + await mkdir(path.join(projectB, ".beads"), { recursive: true }); + const { resolveSafeBeadsWorkspace } = await import("./beads-workspace.ts"); + assert.equal(resolveSafeBeadsWorkspace(projectA).ok, true, "a contained .beads directory is safe"); + + await rm(path.join(projectA, ".beads"), { recursive: true, force: true }); + await symlink(path.join(projectB, ".beads"), path.join(projectA, ".beads"), "dir"); + const swapped = resolveSafeBeadsWorkspace(projectA); + assert.equal(swapped.ok, false, "a replaced .beads symlink cannot reuse cached A data or mutate B"); + assert.equal(swapped.error, "unsafe Beads workspace"); +} finally { + await rm(temp, { recursive: true, force: true }); +} + +console.log("beads-workspace.test.ts: ok"); diff --git a/src/lib/server/beads-workspace.ts b/src/lib/server/beads-workspace.ts new file mode 100644 index 000000000..638aecb5b --- /dev/null +++ b/src/lib/server/beads-workspace.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import path from "node:path"; + +export type BeadsWorkspaceResolution = + | { ok: true; beadsDir: string } + | { ok: false; error: "not a Beads workspace" | "unsafe Beads workspace" }; + +/** + * Resolve a workspace only when `.beads` is a real directory under this exact + * canonical project root. Never follow a symlink: BEADS_DIR controls where bd + * writes, so accepting one can turn a displayed project into another project's + * mutation target. + */ +export function resolveSafeBeadsWorkspace(repoRoot: string): BeadsWorkspaceResolution { + const beadsDir = path.join(repoRoot, ".beads"); + try { + const entry = fs.lstatSync(beadsDir); + if (!entry.isDirectory() || entry.isSymbolicLink()) return { ok: false, error: "unsafe Beads workspace" }; + const canonicalBeads = fs.realpathSync(beadsDir); + if (canonicalBeads !== beadsDir || !canonicalBeads.startsWith(repoRoot + path.sep)) { + return { ok: false, error: "unsafe Beads workspace" }; + } + return { ok: true, beadsDir }; + } catch { + return { ok: false, error: "not a Beads workspace" }; + } +} From d8139e342f195cba982d09dd6b5490bec1248f00 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:21:48 -0400 Subject: [PATCH 23/38] fix(backup): preserve Queue project selection --- scripts/run-tests.mjs | 4 ++++ src/lib/server/backup-manifest.test.ts | 26 ++++++++++++++++++++++++++ src/lib/server/backup-manifest.ts | 1 + 3 files changed, 31 insertions(+) create mode 100644 src/lib/server/backup-manifest.test.ts diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 0a3bc9163..e1e2661f4 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1114,6 +1114,8 @@ export const SUITES = { "src/lib/server/preferences-store.test.ts", "src/lib/server/backdrop-store.test.ts", "src/lib/server/beads-cli.test.ts", + "src/lib/server/beads-workspace.test.ts", + "src/lib/server/backup-manifest.test.ts", "src/lib/server/issue-worktree-provision.test.ts", "src/lib/server/theme-store.test.ts", "src/app/api/memory-coven-workspaces.test.ts", @@ -1271,6 +1273,8 @@ const ALIAS_LOADER = new Set([ "src/lib/cave-inbox-bulk.test.ts", "src/lib/cave-inbox-create.test.ts", "src/lib/queue-project-readiness.test.ts", + "src/lib/server/beads-workspace.test.ts", + "src/lib/server/backup-manifest.test.ts", "src/app/api/chat/stream/route.test.ts", "src/app/api/proposals-flow-e2e.test.ts", "src/app/api/prompts/route.test.ts", diff --git a/src/lib/server/backup-manifest.test.ts b/src/lib/server/backup-manifest.test.ts new file mode 100644 index 000000000..187950a6f --- /dev/null +++ b/src/lib/server/backup-manifest.test.ts @@ -0,0 +1,26 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const temp = await mkdtemp(path.join(os.tmpdir(), "cave-backup-manifest-")); +const roots = { cave: path.join(temp, "cave"), coven: path.join(temp, "coven") }; + +try { + await mkdir(roots.cave, { recursive: true }); + await mkdir(roots.coven, { recursive: true }); + await writeFile(path.join(roots.cave, "queue-project.json"), '{"version":1,"projectId":"selected"}'); + + const { isAllowedBackupEntry, listBackupFiles } = await import("./backup-manifest.ts"); + assert.equal(isAllowedBackupEntry("cave", "queue-project.json"), true, "Queue selection is a supported backup entry"); + const files = await listBackupFiles(roots); + assert.ok( + files.some((file) => file.root === "cave" && file.rel === "queue-project.json"), + "backup collection preserves the Queue-specific project selection", + ); +} finally { + await rm(temp, { recursive: true, force: true }); +} + +console.log("backup-manifest.test.ts: ok"); diff --git a/src/lib/server/backup-manifest.ts b/src/lib/server/backup-manifest.ts index 792fd2a93..ea6e245f8 100644 --- a/src/lib/server/backup-manifest.ts +++ b/src/lib/server/backup-manifest.ts @@ -42,6 +42,7 @@ const CAVE_FILES = [ "inbox-prefs.json", "canvas.json", "projects.json", + "queue-project.json", "project-permissions.json", "permission-config.json", "removed-familiars.json", From 45f2f585f1de21a44f8838fd4020c60c0bf6c3e7 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:38:38 -0400 Subject: [PATCH 24/38] fix(queue): isolate failed project switches --- .../familiar-work-queue-view.test.ts | 2 ++ src/components/familiar-work-queue-view.tsx | 25 ++++++++++++++++-- src/lib/queue-project-readiness.test.ts | 18 +++++++++++++ src/lib/queue-project-readiness.ts | 8 +++++- tests/familiar-work-queue.spec.ts | 26 +++++++++++++++++++ 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/components/familiar-work-queue-view.test.ts b/src/components/familiar-work-queue-view.test.ts index ce5af416b..d72930efc 100644 --- a/src/components/familiar-work-queue-view.test.ts +++ b/src/components/familiar-work-queue-view.test.ts @@ -156,7 +156,9 @@ assert.match(view, /projectRoot=\{readiness\?\.project\?\.root\}/, "Asana filing assert.match(view, /const sourcesUnavailable = !readinessUnavailable && readiness\?\.ok === true && readiness\.project !== null/, "a ready project with failing adapters is not treated as unselected"); assert.match(view, /code\?: string/, "Queue preserves readiness remediation codes from the server"); assert.match(view, /const selectionRemediable = readiness\?\.code === "no-project"/, "stale and invalid selections retain a Choose-project recovery"); +assert.match(view, /readiness\?\.code === "not-git-repository"/, "ordinary non-Git projects retain Choose-project recovery"); assert.match(view, /const projectUnavailable = !readinessUnavailable && !sourcesUnavailable && !canGenerate && !selectionRemediable && readiness\?\.project !== null/, "only non-repairable selected projects suppress Generate"); +assert.match(view, /Clear all prior-project controls synchronously/, "selection clears Queue state before the next readiness response"); assert.match(view, /headline=\{readinessUnavailable \? "Queue check unavailable" : sourcesUnavailable \? "Queue sources unavailable" : canGenerate \? "Generate your Queue" : projectUnavailable \? "Queue project needs attention" : "Queue needs a project"\}/); assert.match(view, /readinessUnavailable \|\| sourcesUnavailable \|\| projectUnavailable \? null : canGenerate \? \(/, "Generate wins before a selected-project warning and stale roots offer Choose project"); assert.match(view, />\s*Generate\s*<\/Button>/, "empty Queue state offers Generate"); diff --git a/src/components/familiar-work-queue-view.tsx b/src/components/familiar-work-queue-view.tsx index 28c20a64f..92817e0e2 100644 --- a/src/components/familiar-work-queue-view.tsx +++ b/src/components/familiar-work-queue-view.tsx @@ -323,7 +323,25 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa }, [load]); useEffect(() => { - const onProjectSelected = () => { void load(true); }; + const onProjectSelected = (event: Event) => { + const selected = event as CustomEvent<{ project?: { root?: string } | null }>; + // Selection is an isolation boundary even if its readiness request is + // unavailable. Clear all prior-project controls synchronously so a + // transient B failure cannot leave actionable cards from A on screen. + activeProjectRootRef.current = selected.detail?.project?.root ?? null; + hadPrDataRef.current = false; + setQueue(null); + setReadiness(null); + setReadinessFailure(null); + setError(null); + setBeadsDegraded(false); + setPrsDegraded(null); + setLastUpdated(null); + setBusyId(null); + setDetailId(null); + setEvidenceAdded(new Set()); + void load(true); + }; window.addEventListener("cave:queue-project-selected", onProjectSelected); return () => window.removeEventListener("cave:queue-project-selected", onProjectSelected); }, [load]); @@ -533,7 +551,10 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa return { all: queue.total, unassigned }; }, [queue]); - if (!hasLoaded) { + // A project-selection event intentionally clears the prior Queue before the + // next readiness response arrives. Render a short loading state during that + // gap rather than dereferencing the removed Queue or retaining its controls. + if (!hasLoaded || (!queue && !error)) { return (
diff --git a/src/lib/queue-project-readiness.test.ts b/src/lib/queue-project-readiness.test.ts index 2a15a9165..8539a0fc3 100644 --- a/src/lib/queue-project-readiness.test.ts +++ b/src/lib/queue-project-readiness.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; const tempDir = await mkdtemp(path.join(os.tmpdir(), "cave-queue-project-")); const projectRoot = path.join(tempDir, "project"); +const nonGitRoot = path.join(tempDir, "not-a-git-project"); const projectsPath = path.join(tempDir, "projects.json"); const queueProjectPath = path.join(tempDir, "queue-project.json"); const previousProjectsPath = process.env.CAVE_PROJECTS_PATH_OVERRIDE; @@ -17,6 +18,7 @@ process.env.CAVE_QUEUE_PROJECT_PATH_OVERRIDE = queueProjectPath; try { await mkdir(projectRoot); + await mkdir(nonGitRoot); execFileSync("git", ["init", "-q"], { cwd: projectRoot }); await writeFile( projectsPath, @@ -42,6 +44,22 @@ try { assert.equal((await queueProjectReadiness()).code, "no-project", "Queue never falls back to the app cwd"); assert.equal((await selectQueueProject("queue-project"))?.root, projectRoot, "selection persists a registered project"); + await writeFile( + projectsPath, + JSON.stringify({ + version: 1, + projects: [ + { id: "queue-project", name: "Queue project", root: projectRoot, createdAt: "2026-07-23T00:00:00.000Z", updatedAt: "2026-07-23T00:00:00.000Z" }, + { id: "non-git-project", name: "Not a Git project", root: nonGitRoot, createdAt: "2026-07-23T00:00:00.000Z", updatedAt: "2026-07-23T00:00:00.000Z" }, + ], + }), + ); + await selectQueueProject("non-git-project"); + const nonGit = await queueProjectReadiness(); + assert.equal(nonGit.code, "not-git-repository", "ordinary non-Git directories offer project reselection rather than a Git execution warning"); + assert.equal(nonGit.canGenerate, false); + await selectQueueProject("queue-project"); + const unavailableWithoutWorkspace = await queueProjectReadiness({ beadsProbe: async () => ({ ok: false, status: 503, error: "bd unavailable", stdout: "", stderr: "" }), }); diff --git a/src/lib/queue-project-readiness.ts b/src/lib/queue-project-readiness.ts index ac6860163..9daa4a378 100644 --- a/src/lib/queue-project-readiness.ts +++ b/src/lib/queue-project-readiness.ts @@ -135,10 +135,16 @@ async function gitTopLevel(root: string): Promise { if (!top) return { ok: false, code: "not-git-repository", message: "The selected Queue project is not a Git repository." }; return { ok: true, root: await realpath(/* turbopackIgnore: true */ top) }; } catch (cause) { - const error = cause as NodeJS.ErrnoException; + const error = cause as Error & { code?: string | number; stderr?: string }; if (error.code === "ENOENT") { return { ok: false, code: "git-unavailable", message: "Git is required to use the Queue project. Install Git and try again." }; } + // Git uses exit 128 for the ordinary, recoverable "not a repository" + // result. Keep permission, timeout, and safe-directory failures distinct + // so only the former offers the Choose-project remediation. + if (Number(error.code) === 128 && /not a git repository/i.test(`${error.message}\n${error.stderr ?? ""}`)) { + return { ok: false, code: "not-git-repository", message: "The selected Queue project is not a Git repository." }; + } return { ok: false, code: "git-error", diff --git a/tests/familiar-work-queue.spec.ts b/tests/familiar-work-queue.spec.ts index 29f6eaae2..bb1dd2c96 100644 --- a/tests/familiar-work-queue.spec.ts +++ b/tests/familiar-work-queue.spec.ts @@ -165,6 +165,32 @@ test.describe("familiar work queue (PR control tower)", () => { await expect.poll(() => readUrls.some((url) => new URL(url).searchParams.get("mode") === "show")).toBe(true); }); + test("clears A before a newly selected project's readiness check fails", async ({ page }) => { + let failNextReadiness = false; + let aRootMutation = false; + await page.route("**/api/queue/readiness", (route) => { + if (failNextReadiness) return route.fulfill({ status: 503, json: { ok: false, error: "Queue readiness temporarily unavailable" } }); + return route.fulfill({ json: { ok: true, readiness: QUEUE_READINESS } }); + }); + await page.route("**/api/beads", (route) => { + if (route.request().method() === "POST" && route.request().postDataJSON()?.projectRoot === QUEUE_PROJECT.root) aRootMutation = true; + return route.fallback(); + }); + await gotoWorkQueue(page); + + const fwq = page.locator(".fwq"); + await expect(fwq.getByText("iOS profile avatar")).toBeVisible(); + failNextReadiness = true; + await page.evaluate(() => window.dispatchEvent(new CustomEvent("cave:queue-project-selected", { + detail: { project: { id: "queue-project-b", name: "Queue project B", root: "/tmp/coven-cave-queue-test-project-b" } }, + }))); + + await expect(fwq.getByText("Queue check unavailable")).toBeVisible(); + await expect(fwq.getByText("iOS profile avatar")).toHaveCount(0); + await expect(fwq.getByRole("button", { name: "Claim", exact: true })).toHaveCount(0); + expect(aRootMutation).toBe(false); + }); + test("claiming for a familiar posts the selected assignee", async ({ page }) => { let claimBody: unknown = null; await page.route("**/api/beads", async (route) => { From 6933209ba0d4b570b650a68f92602a119336fa67 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:38:38 -0400 Subject: [PATCH 25/38] test(queue): execute Generate safeguards --- scripts/run-tests.mjs | 2 + src/app/api/queue/readiness/route.test.ts | 133 ++++++++++++++++++++ src/app/api/queue/readiness/route.ts | 143 +++++++++++++--------- 3 files changed, 217 insertions(+), 61 deletions(-) create mode 100644 src/app/api/queue/readiness/route.test.ts diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index e1e2661f4..32e7871be 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -937,6 +937,7 @@ export const SUITES = { "src/lib/server/cave-home-migration-discard-guard.test.ts", "src/lib/server/global-npm-install-lane.test.ts", "src/app/api/cave-home-migration/route.test.ts", + "src/app/api/queue/readiness/route.test.ts", "src/lib/server/agent-attachments.test.ts", "src/lib/server/voice-chat-create.test.ts", "src/app/api/chat/conversation/[id]/route.test.ts", @@ -1275,6 +1276,7 @@ const ALIAS_LOADER = new Set([ "src/lib/queue-project-readiness.test.ts", "src/lib/server/beads-workspace.test.ts", "src/lib/server/backup-manifest.test.ts", + "src/app/api/queue/readiness/route.test.ts", "src/app/api/chat/stream/route.test.ts", "src/app/api/proposals-flow-e2e.test.ts", "src/app/api/prompts/route.test.ts", diff --git a/src/app/api/queue/readiness/route.test.ts b/src/app/api/queue/readiness/route.test.ts new file mode 100644 index 000000000..d5fca9760 --- /dev/null +++ b/src/app/api/queue/readiness/route.test.ts @@ -0,0 +1,133 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const temp = await mkdtemp(path.join(os.tmpdir(), "cave-queue-readiness-route-")); +const projectA = { id: "project-a", name: "Project A", root: path.join(temp, "project-a") }; +const projectB = { id: "project-b", name: "Project B", root: path.join(temp, "project-b") }; +const unrelatedCwd = path.join(temp, "unrelated-cwd"); +const previousToken = process.env.COVEN_CAVE_AUTH_TOKEN; +delete process.env.COVEN_CAVE_AUTH_TOKEN; + +function request(body: unknown, host = "127.0.0.1") { + return new Request("http://127.0.0.1/api/queue/readiness", { + method: "POST", + headers: { "content-type": "application/json", host }, + body: JSON.stringify(body), + }); +} + +try { + await Promise.all([mkdir(projectA.root), mkdir(projectB.root), mkdir(unrelatedCwd)]); + execFileSync("git", ["init", "-q"], { cwd: projectA.root }); + execFileSync("git", ["init", "-q"], { cwd: projectB.root }); + + const { createQueueReadinessPostHandler } = await import("./route.ts"); + let selected = projectA; + let initialized = false; + let readinessCalls = 0; + let observeSecondGenerateReadiness = false; + let enteredSecondGenerateReadiness!: () => void; + let secondGenerateReadinessEntered = new Promise((resolve) => { enteredSecondGenerateReadiness = resolve; }); + let holdSecondReadiness = false; + let enteredSecondReadiness!: () => void; + let releaseSecondReadiness!: () => void; + let secondReadinessEntered = new Promise((resolve) => { enteredSecondReadiness = resolve; }); + let secondReadinessReleased = new Promise((resolve) => { releaseSecondReadiness = resolve; }); + let holdInit = false; + let initStarted!: () => void; + let releaseInit!: () => void; + let initHasStarted = new Promise((resolve) => { initStarted = resolve; }); + let initReleased = new Promise((resolve) => { releaseInit = resolve; }); + const initRoots: string[] = []; + let invalidations = 0; + + const readiness = async () => { + readinessCalls += 1; + if (holdSecondReadiness && readinessCalls === 2) { + enteredSecondReadiness(); + await secondReadinessReleased; + } + // The first Generate probes twice (pre-lock and under-lock); call three + // proves a second caller passed its own pre-lock check before init wins. + if (observeSecondGenerateReadiness && readinessCalls === 3) enteredSecondGenerateReadiness(); + return { + ok: initialized, + code: initialized ? "ready" : "needs-beads", + message: initialized ? "Queue project is ready." : "Queue needs Beads.", + project: selected, + canGenerate: !initialized, + }; + }; + + const POST = createQueueReadinessPostHandler({ + queueProjectReadiness: readiness, + selectQueueProject: async (projectId: string) => { + if (projectId === projectA.id) selected = projectA; + else if (projectId === projectB.id) selected = projectB; + else return null; + return selected; + }, + runBdCommand: async (root: string) => { + initRoots.push(root); + if (holdInit) { + initStarted(); + await initReleased; + } + initialized = true; + return { ok: true, stdout: "initialized", stderr: "" }; + }, + invalidateQueueProjectReadinessCache: () => { invalidations += 1; }, + }); + + const malformed = await POST(request(null)); + assert.equal(malformed.status, 400, "the executable handler rejects malformed JSON roots"); + const nonLocal = await POST(request({ action: "generate", projectId: projectA.id }, "example.test")); + assert.equal(nonLocal.status, 403, "the executable handler keeps the loopback guard"); + const wrongIdentity = await POST(request({ action: "generate", projectId: projectB.id })); + assert.equal(wrongIdentity.status, 409, "Generate is bound to the project identity displayed in the Queue"); + assert.deepEqual(initRoots, [], "a mismatched project identity never initializes the runtime cwd or another project"); + + // A selection changing while Generate waits for its locked re-check cannot + // initialize the newly selected project. + readinessCalls = 0; + holdSecondReadiness = true; + const changedSelection = POST(request({ action: "generate", projectId: projectA.id })); + await secondReadinessEntered; + selected = projectB; + releaseSecondReadiness(); + const changedSelectionResponse = await changedSelection; + assert.equal(changedSelectionResponse.status, 409); + assert.deepEqual(initRoots, [], "a delayed A Generate never runs bd init in B or the unrelated cwd"); + + // Two same-project Generate calls share one serialized init; the second + // returns the first caller's ready result rather than a misleading conflict. + selected = projectA; + initialized = false; + holdSecondReadiness = false; + holdInit = true; + readinessCalls = 0; + observeSecondGenerateReadiness = true; + secondGenerateReadinessEntered = new Promise((resolve) => { enteredSecondGenerateReadiness = resolve; }); + initHasStarted = new Promise((resolve) => { initStarted = resolve; }); + initReleased = new Promise((resolve) => { releaseInit = resolve; }); + const first = POST(request({ action: "generate", projectId: projectA.id })); + await initHasStarted; + const second = POST(request({ action: "generate", projectId: projectA.id })); + await secondGenerateReadinessEntered; + releaseInit(); + const [firstResponse, secondResponse] = await Promise.all([first, second]); + assert.equal(firstResponse.status, 200); + assert.equal(secondResponse.status, 200, "same-project Generate is idempotent after its lock wait"); + assert.deepEqual(initRoots, [projectA.root], "exactly one init runs, scoped to selected project A"); + assert.equal(invalidations, 1, "only the successful initializer invalidates cached readiness"); +} finally { + if (previousToken === undefined) delete process.env.COVEN_CAVE_AUTH_TOKEN; + else process.env.COVEN_CAVE_AUTH_TOKEN = previousToken; + await rm(temp, { recursive: true, force: true }); +} + +console.log("queue readiness route.test.ts: ok"); diff --git a/src/app/api/queue/readiness/route.ts b/src/app/api/queue/readiness/route.ts index b73dcdad0..f79d0f0c1 100644 --- a/src/app/api/queue/readiness/route.ts +++ b/src/app/api/queue/readiness/route.ts @@ -1,6 +1,6 @@ import path from "node:path"; -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server.js"; import { runBdCommand } from "@/lib/server/beads-cli"; import { readJsonBody, rejectNonLocalRequest } from "@/lib/server/api-security"; @@ -12,86 +12,107 @@ export const runtime = "nodejs"; type Body = { action?: unknown; projectId?: unknown }; const MAX_PROJECT_ID_LENGTH = 200; -const generationLocks = new Map>(); -async function withGenerationLock(root: string, action: () => Promise): Promise { +async function withGenerationLock(locks: Map>, root: string, action: () => Promise): Promise { let release!: () => void; const next = new Promise((resolve) => { release = resolve; }); - const previous = generationLocks.get(root) ?? Promise.resolve(); - generationLocks.set(root, next); + const previous = locks.get(root) ?? Promise.resolve(); + locks.set(root, next); await previous; try { return await action(); } finally { release(); - if (generationLocks.get(root) === next) generationLocks.delete(root); + if (locks.get(root) === next) locks.delete(root); } } +type QueueReadinessRouteDependencies = { + runBdCommand: typeof runBdCommand; + queueProjectReadiness: typeof queueProjectReadiness; + selectQueueProject: typeof selectQueueProject; + invalidateQueueProjectReadinessCache: typeof invalidateQueueProjectReadinessCache; +}; + export async function GET(req: Request) { const denied = rejectNonLocalRequest(req); if (denied) return denied; return NextResponse.json({ ok: true, readiness: await queueProjectReadiness() }); } -export async function POST(req: Request) { - const denied = rejectNonLocalRequest(req); - if (denied) return denied; - const parsed = await readJsonBody(req, MAX_SESSION_JSON_BYTES); - if (!parsed.ok) return parsed.response; - const body = parsed.body; - if (body.action !== "select" && body.action !== "generate") { - return NextResponse.json({ ok: false, error: "unsupported action" }, { status: 400 }); - } - if (typeof body.projectId !== "string" || !body.projectId.trim() || body.projectId.length > MAX_PROJECT_ID_LENGTH) { - return NextResponse.json({ ok: false, error: "projectId is required" }, { status: 400 }); - } - const projectId = body.projectId.trim(); - - if (body.action === "select") { - let project; - try { - project = await selectQueueProject(projectId); - } catch (cause) { - const error = cause instanceof QueueProjectStorageError || cause instanceof Error - ? cause.message - : "Couldn’t save the Queue project selection."; - return NextResponse.json({ ok: false, error }, { status: 503 }); +/** A dependency-injectable handler keeps Generate's identity and lock contract executable in tests. */ +export function createQueueReadinessPostHandler(dependencies: QueueReadinessRouteDependencies) { + const generationLocks = new Map>(); + return async function POST(req: Request) { + const denied = rejectNonLocalRequest(req); + if (denied) return denied; + const parsed = await readJsonBody(req, MAX_SESSION_JSON_BYTES); + if (!parsed.ok) return parsed.response; + const body = parsed.body; + if (body.action !== "select" && body.action !== "generate") { + return NextResponse.json({ ok: false, error: "unsupported action" }, { status: 400 }); } - if (!project) return NextResponse.json({ ok: false, error: "project not found" }, { status: 404 }); - return NextResponse.json({ ok: true, readiness: await queueProjectReadiness() }); - } - - if (body.action === "generate") { - const readiness = await queueProjectReadiness(); - if (!readiness.canGenerate || !readiness.project || readiness.project.id !== projectId) { - return NextResponse.json({ ok: false, error: readiness.message, readiness }, { status: 409 }); + if (typeof body.projectId !== "string" || !body.projectId.trim() || body.projectId.length > MAX_PROJECT_ID_LENGTH) { + return NextResponse.json({ ok: false, error: "projectId is required" }, { status: 400 }); } - return withGenerationLock(readiness.project.root, async () => { - // Re-read the persisted selection after the per-repository lock: another - // window may have selected a different project while this request waited. - const current = await queueProjectReadiness(); - const identityMatches = current.project?.id === projectId && current.project.root === readiness.project?.root; - if (current.ok && identityMatches) { - // Another window completed the same initialization while this caller - // waited for the lock. Generate is idempotent for that identity. - return NextResponse.json({ ok: true, readiness: current }); - } - if (!current.canGenerate || !current.project || !identityMatches) { - return NextResponse.json({ ok: false, error: "Queue project changed; choose Generate again for the current project.", readiness: current }, { status: 409 }); + const projectId = body.projectId.trim(); + + if (body.action === "select") { + let project; + try { + project = await dependencies.selectQueueProject(projectId); + } catch (cause) { + const error = cause instanceof QueueProjectStorageError || cause instanceof Error + ? cause.message + : "Couldn’t save the Queue project selection."; + return NextResponse.json({ ok: false, error }, { status: 503 }); } - const result = await runBdCommand( - current.project.root, - path.join(/* turbopackIgnore: true */ current.project.root, ".beads"), - ["init"], - ); - if (!result.ok) { - return NextResponse.json({ ok: false, error: result.error, readiness: current }, { status: result.status }); + if (!project) return NextResponse.json({ ok: false, error: "project not found" }, { status: 404 }); + return NextResponse.json({ ok: true, readiness: await dependencies.queueProjectReadiness() }); + } + + if (body.action === "generate") { + const readiness = await dependencies.queueProjectReadiness(); + if (!readiness.canGenerate || !readiness.project || readiness.project.id !== projectId) { + return NextResponse.json({ ok: false, error: readiness.message, readiness }, { status: 409 }); } - invalidateQueueProjectReadinessCache(); - return NextResponse.json({ ok: true, readiness: await queueProjectReadiness() }); - }); - } + return withGenerationLock(generationLocks, readiness.project.root, async () => { + // Re-read the persisted selection after the per-repository lock: another + // window may have selected a different project while this request waited. + const current = await dependencies.queueProjectReadiness(); + const identityMatches = current.project?.id === projectId && current.project.root === readiness.project?.root; + if (current.ok && identityMatches) { + // Another window completed the same initialization while this caller + // waited for the lock. Generate is idempotent for that identity. + return NextResponse.json({ ok: true, readiness: current }); + } + if (!current.canGenerate || !current.project || !identityMatches) { + return NextResponse.json({ ok: false, error: "Queue project changed; choose Generate again for the current project.", readiness: current }, { status: 409 }); + } + const result = await dependencies.runBdCommand( + current.project.root, + path.join(/* turbopackIgnore: true */ current.project.root, ".beads"), + ["init"], + ); + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error, readiness: current }, { status: result.status }); + } + dependencies.invalidateQueueProjectReadinessCache(); + return NextResponse.json({ ok: true, readiness: await dependencies.queueProjectReadiness() }); + }); + } - return NextResponse.json({ ok: false, error: "unsupported action" }, { status: 400 }); + return NextResponse.json({ ok: false, error: "unsupported action" }, { status: 400 }); + }; +} + +const postHandler = createQueueReadinessPostHandler({ + runBdCommand, + queueProjectReadiness, + selectQueueProject, + invalidateQueueProjectReadinessCache, +}); + +export async function POST(req: Request) { + return postHandler(req); } From c383239faf5985d9880bd9814a7ee8cba98abc6a Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:45:48 -0400 Subject: [PATCH 26/38] test(queue): assert detail root scope --- tests/familiar-work-queue.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/familiar-work-queue.spec.ts b/tests/familiar-work-queue.spec.ts index bb1dd2c96..7cc0594bb 100644 --- a/tests/familiar-work-queue.spec.ts +++ b/tests/familiar-work-queue.spec.ts @@ -163,6 +163,9 @@ test.describe("familiar work queue (PR control tower)", () => { await fwq.getByRole("region", { name: "No open PR" }).getByRole("button", { name: "iOS profile avatar" }).click(); await expect(page.getByRole("dialog", { name: "Queue cave-bb2" })).toBeVisible(); await expect.poll(() => readUrls.some((url) => new URL(url).searchParams.get("mode") === "show")).toBe(true); + const detailUrl = readUrls.find((url) => new URL(url).searchParams.get("mode") === "show"); + expect(detailUrl).toBeDefined(); + expect(new URL(detailUrl!).searchParams.get("projectRoot")).toBe(QUEUE_PROJECT.root); }); test("clears A before a newly selected project's readiness check fails", async ({ page }) => { From 2d263f234a862c8cf1f7cfa5883c96bb6adc84d6 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:17:54 -0400 Subject: [PATCH 27/38] fix(queue): preserve project isolation in E2E --- playwright.config.ts | 7 ++ src/components/familiar-work-queue-view.tsx | 4 + tests/chat-boot-landing.spec.ts | 9 +++ tests/familiar-work-queue.spec.ts | 82 +++++++++++++++++++++ 4 files changed, 102 insertions(+) diff --git a/playwright.config.ts b/playwright.config.ts index 74de9aa12..e14a0d4be 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -23,6 +23,7 @@ const PORT = Number(process.env.PORT ?? 3100); const BASE_URL = `http://127.0.0.1:${PORT}`; const E2E_RUN_ID = randomUUID(); const E2E_PROJECTS_PATH = join(tmpdir(), `cave-e2e-projects-${E2E_RUN_ID}.json`); +const E2E_QUEUE_PROJECT_PATH = join(tmpdir(), `cave-e2e-queue-project-${E2E_RUN_ID}.json`); const PERSISTED_SCREEN_SCALE_TEST = /persisted screen magnification scales the app without window scroll$/; const MOBILE_FOUNDATIONS_SPEC = /mobile\/foundations\.spec\.ts/; @@ -42,6 +43,11 @@ writeFileSync( }], }), ); +// Queue selection is a separate durable preference. Seed it alongside the +// existing project registry so dismissed-onboarding specs remain an already +// configured baseline; dedicated onboarding tests still mock no/stale-project +// responses explicitly. +writeFileSync(E2E_QUEUE_PROJECT_PATH, JSON.stringify({ version: 1, projectId: "e2e-project" })); export default defineConfig({ testDir: "./tests", @@ -127,6 +133,7 @@ export default defineConfig({ // every request in this run. COVEN_PREFERENCES_PATH: join(tmpdir(), `cave-e2e-preferences-${E2E_RUN_ID}.json`), CAVE_PROJECTS_PATH_OVERRIDE: E2E_PROJECTS_PATH, + CAVE_QUEUE_PROJECT_PATH_OVERRIDE: E2E_QUEUE_PROJECT_PATH, COVEN_BACKDROP_PATH: join(tmpdir(), `cave-e2e-backdrop-${E2E_RUN_ID}.jpg`), COVEN_THEME_PATH: join(tmpdir(), `cave-e2e-theme-${E2E_RUN_ID}.json`), }, diff --git a/src/components/familiar-work-queue-view.tsx b/src/components/familiar-work-queue-view.tsx index 92817e0e2..e07493ad5 100644 --- a/src/components/familiar-work-queue-view.tsx +++ b/src/components/familiar-work-queue-view.tsx @@ -414,6 +414,10 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa }); const json = await res.json(); if (!json.ok) throw new Error(json.error || "comment failed"); + // A comment can complete after the user selects a different Queue + // project. Its evidence belongs only to the root that initiated it; + // otherwise an equal bead id in the new project could unlock Close. + if (activeProjectRootRef.current !== projectRoot) return false; setEvidenceAdded((prev) => new Set(prev).add(id.toLowerCase())); announce(`Handoff note added to ${id}.`); await load(true); diff --git a/tests/chat-boot-landing.spec.ts b/tests/chat-boot-landing.spec.ts index f149345df..37873e861 100644 --- a/tests/chat-boot-landing.spec.ts +++ b/tests/chat-boot-landing.spec.ts @@ -71,6 +71,15 @@ async function seed(page: Page) { } test.describe("chat boot landing", () => { + test("dismissed E2E baseline keeps onboarding closed with the seeded Queue project", async ({ page }) => { + await seed(page); + await page.route("**/api/sessions/list**", (route) => route.fulfill({ json: { ok: true, sessions: [] } })); + + await page.goto("/?mode=chat"); + await expect(page.locator(".cave-chat-empty")).toBeVisible({ timeout: 45_000 }); + await expect(page.getByRole("dialog")).toHaveCount(0); + }); + test("compose view paints before the sessions list resolves", async ({ page }) => { await seed(page); // Hold the sessions fetch hostage until the landing has painted — this diff --git a/tests/familiar-work-queue.spec.ts b/tests/familiar-work-queue.spec.ts index 7cc0594bb..c9caa626b 100644 --- a/tests/familiar-work-queue.spec.ts +++ b/tests/familiar-work-queue.spec.ts @@ -30,6 +30,7 @@ const MERGED_PRS = [ { number: 90, title: "Landed change", url: "https://gh/pull/90", beadIds: ["cave-open"], mergedAt: iso(1) }, ]; const QUEUE_PROJECT = { id: "queue-test-project", name: "Queue test project", root: "/tmp/coven-cave-queue-test-project" }; +const QUEUE_PROJECT_B = { id: "queue-test-project-b", name: "Queue test project B", root: "/tmp/coven-cave-queue-test-project-b" }; const QUEUE_READINESS = { ok: true, message: "Queue project is ready.", canGenerate: false, project: QUEUE_PROJECT }; test.beforeEach(async ({ page }) => { @@ -255,6 +256,87 @@ test.describe("familiar work queue (PR control tower)", () => { await expect(cleanup.getByRole("button", { name: "Close bead" })).toBeEnabled(); }); + test("a delayed A handoff cannot unlock an equal bead id after switching to B", async ({ page }) => { + let selectedProject = QUEUE_PROJECT; + let releaseComment!: () => void; + const commentReleased = new Promise((resolve) => { releaseComment = resolve; }); + let commentStarted!: () => void; + const commentPending = new Promise((resolve) => { commentStarted = resolve; }); + const readUrls: string[] = []; + await page.route("**/api/queue/readiness", (route) => + route.fulfill({ json: { ok: true, readiness: { ...QUEUE_READINESS, project: selectedProject } } }), + ); + await page.route("**/api/beads", async (route) => { + if (route.request().method() !== "POST") return route.fallback(); + const body = route.request().postDataJSON(); + if (body.action === "comment") { + commentStarted(); + await commentReleased; + return route.fulfill({ json: { ok: true, data: { id: "cave-open" } } }); + } + return route.fallback(); + }); + await gotoWorkQueue(page, (request) => readUrls.push(request.url())); + + const cleanup = page.locator(".fwq").getByRole("region", { name: "Post-merge cleanup" }); + await cleanup.getByRole("button", { name: /Add a handoff note to cave-open/ }).click(); + await cleanup.getByRole("textbox", { name: /Handoff note for cave-open/ }).fill("Verified in project A."); + await cleanup.getByRole("button", { name: "Add note" }).click(); + await commentPending; + + selectedProject = QUEUE_PROJECT_B; + await page.evaluate((project) => window.dispatchEvent(new CustomEvent("cave:queue-project-selected", { detail: { project } })), QUEUE_PROJECT_B); + await expect.poll(() => readUrls.some((url) => new URL(url).searchParams.get("projectRoot") === QUEUE_PROJECT_B.root)).toBe(true); + await expect(cleanup.getByRole("button", { name: "Close bead" })).toBeDisabled(); + + releaseComment(); + await expect(cleanup.getByRole("button", { name: "Close bead" })).toBeDisabled(); + }); + + test("PR and Asana bead creation use B after a Queue project switch", async ({ page }) => { + let selectedProject = QUEUE_PROJECT; + const createBodies: Array<{ projectRoot?: string; labels?: string[] }> = []; + const readUrls: string[] = []; + await page.route("**/api/queue/readiness", (route) => + route.fulfill({ json: { ok: true, readiness: { ...QUEUE_READINESS, project: selectedProject } } }), + ); + await page.route("**/api/asana/assigned**", (route) => + route.fulfill({ + json: { + ok: true, + configured: true, + assigned: true, + items: [{ gid: "asana-123", title: "Asana queue task", url: "https://app.asana.com/0/123", projectName: "Queue" }], + }, + }), + ); + await page.route("**/api/beads", async (route) => { + if (route.request().method() !== "POST") return route.fallback(); + const body = route.request().postDataJSON(); + if (body.action === "create") { + createBodies.push(body); + return route.fulfill({ json: { ok: true, data: { id: `cave-created-${createBodies.length}` } } }); + } + return route.fallback(); + }); + await gotoWorkQueue(page, (request) => readUrls.push(request.url())); + + selectedProject = QUEUE_PROJECT_B; + await page.evaluate((project) => window.dispatchEvent(new CustomEvent("cave:queue-project-selected", { detail: { project } })), QUEUE_PROJECT_B); + await expect.poll(() => readUrls.filter((url) => new URL(url).searchParams.get("projectRoot") === QUEUE_PROJECT_B.root).length).toBeGreaterThanOrEqual(2); + + const fwq = page.locator(".fwq"); + const attention = fwq.getByRole("region", { name: "PRs needing attention" }); + await attention.locator(".fwq-attention-item", { hasText: "#103" }).getByRole("button", { name: "File bead" }).click(); + await expect.poll(() => createBodies.length).toBe(1); + const asana = fwq.getByRole("region", { name: "Asana tasks assigned to you" }); + await expect(asana).toBeVisible(); + await asana.getByRole("button", { name: "File bead" }).click(); + await expect.poll(() => createBodies.length).toBe(2); + expect(createBodies.map((body) => body.projectRoot)).toEqual([QUEUE_PROJECT_B.root, QUEUE_PROJECT_B.root]); + expect(createBodies.map((body) => body.labels)).toEqual([["from-pr"], ["asana"]]); + }); + test("Attention strip surfaces stale and unlinked open PRs", async ({ page }) => { await gotoWorkQueue(page); const strip = page.locator(".fwq").getByRole("region", { name: "PRs needing attention" }); From 532ba78ee36713bd5e81e3d5475ec2a63887e926 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:27:50 -0400 Subject: [PATCH 28/38] test(queue): execute scoped Beads routes --- scripts/run-tests.mjs | 2 + src/app/api/beads/prs/route.ts | 2 +- src/app/api/beads/route.test.ts | 123 ++++++++++++++++++++++++++++++++ src/app/api/beads/route.ts | 2 +- 4 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 src/app/api/beads/route.test.ts diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 32e7871be..6be236d9d 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -937,6 +937,7 @@ export const SUITES = { "src/lib/server/cave-home-migration-discard-guard.test.ts", "src/lib/server/global-npm-install-lane.test.ts", "src/app/api/cave-home-migration/route.test.ts", + "src/app/api/beads/route.test.ts", "src/app/api/queue/readiness/route.test.ts", "src/lib/server/agent-attachments.test.ts", "src/lib/server/voice-chat-create.test.ts", @@ -1277,6 +1278,7 @@ const ALIAS_LOADER = new Set([ "src/lib/server/beads-workspace.test.ts", "src/lib/server/backup-manifest.test.ts", "src/app/api/queue/readiness/route.test.ts", + "src/app/api/beads/route.test.ts", "src/app/api/chat/stream/route.test.ts", "src/app/api/proposals-flow-e2e.test.ts", "src/app/api/prompts/route.test.ts", diff --git a/src/app/api/beads/prs/route.ts b/src/app/api/beads/prs/route.ts index 538709fb7..d72ecdc28 100644 --- a/src/app/api/beads/prs/route.ts +++ b/src/app/api/beads/prs/route.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server.js"; import { scrubSidecarInternalEnv } from "@/lib/coven-bin"; import { rejectNonLocalRequest } from "@/lib/server/api-security"; import { resolveRepoRoot } from "@/lib/server/issue-worktree-provision"; diff --git a/src/app/api/beads/route.test.ts b/src/app/api/beads/route.test.ts new file mode 100644 index 000000000..b4a17c63a --- /dev/null +++ b/src/app/api/beads/route.test.ts @@ -0,0 +1,123 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const temp = await mkdtemp(path.join(os.tmpdir(), "cave-beads-route-")); +const projectA = path.join(temp, "project-a"); +const projectB = path.join(temp, "project-b"); +const unrelatedCwd = path.join(temp, "unrelated-cwd"); +const fakeBin = path.join(temp, "bin"); +const commandLog = path.join(temp, "commands.jsonl"); +const projectsPath = path.join(temp, "projects.json"); +const previous = { + cwd: process.cwd(), + path: process.env.PATH, + projects: process.env.CAVE_PROJECTS_PATH_OVERRIDE, + commandLog: process.env.CAVE_ROUTE_COMMAND_LOG, + token: process.env.COVEN_CAVE_AUTH_TOKEN, +}; + +function localRequest(url: string, init?: RequestInit) { + return new Request(url, { + ...init, + headers: { host: "127.0.0.1", ...(init?.headers ?? {}) }, + }); +} + +try { + await Promise.all([ + mkdir(path.join(projectA, ".beads"), { recursive: true }), + mkdir(path.join(projectB, ".beads"), { recursive: true }), + mkdir(unrelatedCwd), + mkdir(fakeBin), + ]); + execFileSync("git", ["init", "-q"], { cwd: projectA }); + execFileSync("git", ["init", "-q"], { cwd: projectB }); + await writeFile( + projectsPath, + JSON.stringify({ + version: 1, + projects: [ + { id: "project-a", name: "Project A", root: projectA, createdAt: "2026-07-24T00:00:00.000Z", updatedAt: "2026-07-24T00:00:00.000Z" }, + { id: "project-b", name: "Project B", root: projectB, createdAt: "2026-07-24T00:00:00.000Z", updatedAt: "2026-07-24T00:00:00.000Z" }, + ], + }), + ); + const fakeCommand = `#!/bin/sh +printf '{"command":"%s","cwd":"%s","beadsDir":"%s","args":"%s"}\\n' "$(basename "$0")" "$PWD" "$BEADS_DIR" "$*" >> "$CAVE_ROUTE_COMMAND_LOG" +if [ "$(basename "$0")" = "gh" ]; then + printf '[]\\n' +else + printf '{"id":"cave-test"}\\n' +fi +`; + await Promise.all([writeFile(path.join(fakeBin, "bd"), fakeCommand), writeFile(path.join(fakeBin, "gh"), fakeCommand)]); + await Promise.all([chmod(path.join(fakeBin, "bd"), 0o755), chmod(path.join(fakeBin, "gh"), 0o755)]); + process.env.PATH = `${fakeBin}${path.delimiter}${previous.path ?? ""}`; + process.env.CAVE_PROJECTS_PATH_OVERRIDE = projectsPath; + process.env.CAVE_ROUTE_COMMAND_LOG = commandLog; + delete process.env.COVEN_CAVE_AUTH_TOKEN; + process.chdir(unrelatedCwd); + + const beads = await import("./route.ts"); + const prs = await import("./prs/route.ts"); + const root = encodeURIComponent(projectA); + + for (const url of [ + `http://127.0.0.1/api/beads?mode=ready&projectRoot=${root}`, + `http://127.0.0.1/api/beads?mode=show&id=cave-shared&projectRoot=${root}`, + ]) { + const response = await beads.GET(localRequest(url)); + assert.equal(response.status, 200); + assert.equal((await response.json()).projectRoot, projectA); + } + const prResponse = await prs.GET(localRequest(`http://127.0.0.1/api/beads/prs?projectRoot=${root}`)); + assert.equal(prResponse.status, 200); + assert.equal((await prResponse.json()).projectRoot, projectA); + + const mutations = [ + { action: "claim", id: "cave-shared" }, + { action: "comment", id: "cave-shared", comment: "Verified in project A." }, + { action: "close", id: "cave-shared", reason: "Completed" }, + { action: "create", title: "PR-created bead", description: "PR #7", externalRef: "gh-7", labels: ["from-pr"] }, + { action: "create", title: "Asana-created bead", description: "Asana task", externalRef: "https://app.asana.com/0/7", labels: ["asana"] }, + ]; + for (const body of mutations) { + const response = await beads.POST(localRequest("http://127.0.0.1/api/beads", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...body, projectRoot: projectA }), + })); + assert.equal(response.status, 200, `${body.action} is scoped to selected project A`); + assert.equal((await response.json()).projectRoot, projectA); + } + + const commands = (await readFile(commandLog, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.ok(commands.some((entry) => entry.command === "gh"), "PR bridge invokes gh through the selected repository"); + assert.ok(commands.filter((entry) => entry.command === "bd").length >= 7, "list, detail, and every Queue mutation invoke bd"); + for (const command of commands) { + assert.equal(command.cwd, projectA, `${command.command} never falls back to unrelated process.cwd() or project B`); + if (command.command === "bd") { + assert.equal(command.beadsDir, path.join(projectA, ".beads"), "Beads mutations stay inside selected project A"); + } + } +} finally { + process.chdir(previous.cwd); + if (previous.path === undefined) delete process.env.PATH; + else process.env.PATH = previous.path; + if (previous.projects === undefined) delete process.env.CAVE_PROJECTS_PATH_OVERRIDE; + else process.env.CAVE_PROJECTS_PATH_OVERRIDE = previous.projects; + if (previous.commandLog === undefined) delete process.env.CAVE_ROUTE_COMMAND_LOG; + else process.env.CAVE_ROUTE_COMMAND_LOG = previous.commandLog; + if (previous.token === undefined) delete process.env.COVEN_CAVE_AUTH_TOKEN; + else process.env.COVEN_CAVE_AUTH_TOKEN = previous.token; + await rm(temp, { recursive: true, force: true }); +} + +console.log("beads route.test.ts: ok"); diff --git a/src/app/api/beads/route.ts b/src/app/api/beads/route.ts index dd5193d9c..0f8cf3425 100644 --- a/src/app/api/beads/route.ts +++ b/src/app/api/beads/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server.js"; import { readJsonBody, rejectNonLocalRequest } from "@/lib/server/api-security"; import { runBdCommand } from "@/lib/server/beads-cli"; import { MAX_SESSION_JSON_BYTES } from "@/lib/server/session-security"; From 482c80ca647ff104bb7597889f54b821de31b0f6 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 24 Jul 2026 00:33:14 -0500 Subject: [PATCH 29/38] fix(e2e): satisfy the Queue project step in the daemon-less harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new dismissal override (a stored dismissal must not mask a missing Queue project) force-opened the Onboarding dialog over every surface in CI: the daemon-less e2e run (COVEN_CAVE_E2E=1) never has a selected project, so all 15 pre-existing specs — which follow the documented contract of setting cave:onboarding:dismissed=1 and mocking /api/* — timed out behind the aria-modal overlay ('subtree intercepts pointer events', 25m job timeout). Stub the project step ok in the harness, mirroring the existing COVEN_CAVE_E2E short-circuits (playwright.config.ts, mobile-access- provision.ts). Real runs keep the readiness probe; onboarding-wizard and familiar-work-queue specs mock this route wholesale, so project- readiness coverage is unchanged. Verified locally: route + gate unit tests ok; calendar-month-grid (a previously pointer-blocked legacy spec), all 13 onboarding-wizard and all 14 familiar-work-queue tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/api/onboarding/status/route.ts | 28 ++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/app/api/onboarding/status/route.ts b/src/app/api/onboarding/status/route.ts index dd9d0b838..4221cbfd1 100644 --- a/src/app/api/onboarding/status/route.ts +++ b/src/app/api/onboarding/status/route.ts @@ -288,6 +288,24 @@ async function checkBinding( }; } +/** + * A Git project is mandatory for the Queue. An otherwise-valid repository + * without Beads is ready for the explicit Generate action on the Queue page, + * so it satisfies project selection during onboarding. + * + * The daemon-less e2e harness (COVEN_CAVE_E2E=1, see playwright.config.ts) + * has no selected Queue project by design; report the step satisfied there + * so a stored dismissal keeps the wizard closed, exactly like the daemon + * short-circuit. Specs that exercise project readiness mock this route. + */ +async function checkQueueProject(): Promise { + if (process.env.COVEN_CAVE_E2E === "1") { + return { ok: true, detail: "e2e harness stub — no Queue project in daemon-less runs" }; + } + const readiness = await cachedQueueProjectReadiness(); + return { ok: readiness.ok || readiness.canGenerate, detail: readiness.message }; +} + export async function GET() { const openclawAgentCount = await countOpenClawAgents(); const [openCovenTools, covenHome, git, daemon, familiarsRes, queueProject] = await Promise.all([ @@ -296,7 +314,7 @@ export async function GET() { checkGit(), checkDaemon(), checkFamiliars(), - cachedQueueProjectReadiness(), + checkQueueProject(), ]); const covenCli = checkCovenCli( openCovenTools.find((tool) => tool.id === "coven-cli"), @@ -312,13 +330,7 @@ export async function GET() { const steps: Record = { covenCli, covenHome, - // A Git project is mandatory for the Queue. An otherwise-valid repository - // without Beads is ready for the explicit Generate action on the Queue - // page, so it satisfies project selection during onboarding. - project: { - ok: queueProject.ok || queueProject.canGenerate, - detail: queueProject.message, - }, + project: queueProject, git, adapters: adapters.step, daemon, From bdf5c5a4f8b546e61b7f877e5409828cec11624f Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:49:05 -0400 Subject: [PATCH 30/38] test(queue): align contracts with redesigned controls --- src/components/familiar-work-queue-view.test.ts | 2 +- tests/familiar-work-queue.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/familiar-work-queue-view.test.ts b/src/components/familiar-work-queue-view.test.ts index d72930efc..fde253085 100644 --- a/src/components/familiar-work-queue-view.test.ts +++ b/src/components/familiar-work-queue-view.test.ts @@ -90,7 +90,7 @@ assert.match( // ── Bead inspector (cave-u2p1) ─────────────────────────────────────────────── // Bead titles open a focus-trapped dialog over the existing show contract. -assert.match(view, /className="fwq-card-name fwq-card-name--link focus-ring-inset"/); +assert.match(view, /className="fwq-row-name fwq-row-name--link focus-ring-inset"/); assert.match(view, /\/api\/beads\?mode=show&id=\$\{encodeURIComponent\(id\)\}&projectRoot=\$\{encodeURIComponent\(projectRoot\)\}/, "drawer reads the selected project's bead"); assert.match(view, /import \{ Modal \} from "@\/components\/ui\/modal"/, "reuses the focus-trapped house dialog"); assert.match(view, /breadcrumb=\{\["Queue", id\]\}/); diff --git a/tests/familiar-work-queue.spec.ts b/tests/familiar-work-queue.spec.ts index c9caa626b..47d3e144c 100644 --- a/tests/familiar-work-queue.spec.ts +++ b/tests/familiar-work-queue.spec.ts @@ -281,7 +281,7 @@ test.describe("familiar work queue (PR control tower)", () => { const cleanup = page.locator(".fwq").getByRole("region", { name: "Post-merge cleanup" }); await cleanup.getByRole("button", { name: /Add a handoff note to cave-open/ }).click(); await cleanup.getByRole("textbox", { name: /Handoff note for cave-open/ }).fill("Verified in project A."); - await cleanup.getByRole("button", { name: "Add note" }).click(); + await cleanup.getByRole("button", { name: "Save note" }).click(); await commentPending; selectedProject = QUEUE_PROJECT_B; From c8f42b16d302d91fe6a386a2713b4d206c2cfb15 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:49:06 -0400 Subject: [PATCH 31/38] fix(queue): verify Beads recovery readiness --- src/app/api/queue/readiness/route.test.ts | 17 ++++++++++++++++- src/app/api/queue/readiness/route.ts | 9 ++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/app/api/queue/readiness/route.test.ts b/src/app/api/queue/readiness/route.test.ts index d5fca9760..ef7f91bdb 100644 --- a/src/app/api/queue/readiness/route.test.ts +++ b/src/app/api/queue/readiness/route.test.ts @@ -38,6 +38,7 @@ try { let secondReadinessEntered = new Promise((resolve) => { enteredSecondReadiness = resolve; }); let secondReadinessReleased = new Promise((resolve) => { releaseSecondReadiness = resolve; }); let holdInit = false; + let leaveWorkspacePartial = false; let initStarted!: () => void; let releaseInit!: () => void; let initHasStarted = new Promise((resolve) => { initStarted = resolve; }); @@ -77,7 +78,7 @@ try { initStarted(); await initReleased; } - initialized = true; + if (!leaveWorkspacePartial) initialized = true; return { ok: true, stdout: "initialized", stderr: "" }; }, invalidateQueueProjectReadinessCache: () => { invalidations += 1; }, @@ -103,6 +104,20 @@ try { assert.equal(changedSelectionResponse.status, 409); assert.deepEqual(initRoots, [], "a delayed A Generate never runs bd init in B or the unrelated cwd"); + // An init process can return successfully while leaving an unusable partial + // workspace. The route must return its failed readiness, not a false success. + selected = projectA; + initialized = false; + leaveWorkspacePartial = true; + holdSecondReadiness = false; + readinessCalls = 0; + const partialRepair = await POST(request({ action: "generate", projectId: projectA.id })); + assert.equal(partialRepair.status, 422, "Generate does not report success until the repaired workspace is ready"); + assert.equal((await partialRepair.json()).readiness.code, "needs-beads"); + leaveWorkspacePartial = false; + initRoots.length = 0; + invalidations = 0; + // Two same-project Generate calls share one serialized init; the second // returns the first caller's ready result rather than a misleading conflict. selected = projectA; diff --git a/src/app/api/queue/readiness/route.ts b/src/app/api/queue/readiness/route.ts index f79d0f0c1..084cfc818 100644 --- a/src/app/api/queue/readiness/route.ts +++ b/src/app/api/queue/readiness/route.ts @@ -98,7 +98,14 @@ export function createQueueReadinessPostHandler(dependencies: QueueReadinessRout return NextResponse.json({ ok: false, error: result.error, readiness: current }, { status: result.status }); } dependencies.invalidateQueueProjectReadinessCache(); - return NextResponse.json({ ok: true, readiness: await dependencies.queueProjectReadiness() }); + // `bd init` may create a partial .beads directory before exiting or + // leave one that still cannot answer `bd ready`. Never claim recovery + // succeeded until the same readiness contract proves it usable. + const repaired = await dependencies.queueProjectReadiness(); + if (!repaired.ok) { + return NextResponse.json({ ok: false, error: repaired.message, readiness: repaired }, { status: 422 }); + } + return NextResponse.json({ ok: true, readiness: repaired }); }); } From 11e89288a840c3eddb5a14ad7da4cf8551bcc069 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 24 Jul 2026 01:24:57 -0500 Subject: [PATCH 32/38] test(e2e): mock onboarding status in the mobile code-rail spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the dismissed short-circuit gone every boot fetches /api/onboarding/status for real; its cold compile + probes race the lazy code-rail chunk on pixel-5 and the sheet renders empty past the 5s expect (the exact E2E failure on this PR's last run). Mock the route like familiar-work-queue.spec.ts already does — specs are self-contained per the harness contract. Verified: code-rail-sheet green across pixel-5/iphone-13/desktop with --repeat-each=2 (was: deterministic pixel-5 fail at this tip). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/mobile/code-rail-sheet.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/mobile/code-rail-sheet.spec.ts b/tests/mobile/code-rail-sheet.spec.ts index fecefb2f3..7f2b36c8e 100644 --- a/tests/mobile/code-rail-sheet.spec.ts +++ b/tests/mobile/code-rail-sheet.spec.ts @@ -40,6 +40,14 @@ async function base(page: Page) { await page.route("**/api/familiars**", (route) => route.fulfill({ json: { ok: true, familiars: [{ id: "nova", display_name: "Nova", role: "Orchestrator", status: "active", icon: "ph:sparkle-fill" }] } }), ); + // Dismissed onboarding no longer short-circuits the startup status probe + // (a broken Queue project must resurface the wizard), so every boot hits + // this route. Left unmocked, the real handler's cold compile + probes race + // the lazy code-rail chunk on the slowest mobile project and the sheet + // renders empty past the expect window. Mock it like the queue specs do. + await page.route("**/api/onboarding/status**", (route) => + route.fulfill({ json: { ok: true, complete: true, steps: { project: { ok: true } }, tools: [] } }), + ); await page.route("**/api/sessions/list**", (route) => route.fulfill({ json: { ok: true, sessions: [REPO_SESSION] } }), ); From 93ddaf3b1077f0d6ee746cb139a7b4a29339c1b5 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:46:07 -0400 Subject: [PATCH 33/38] fix(queue): honor launch PATH for project tools --- src/app/api/beads/prs/route.ts | 4 +- src/app/api/beads/route.test.ts | 5 +- src/lib/coven-bin.test.ts | 31 ++++++++++-- src/lib/coven-bin.ts | 57 ++++++++++++++-------- src/lib/queue-project-readiness.test.ts | 7 +++ src/lib/queue-project-readiness.ts | 2 + src/lib/server/beads-cli.ts | 8 +-- src/lib/server/issue-worktree-provision.ts | 3 +- 8 files changed, 86 insertions(+), 31 deletions(-) diff --git a/src/app/api/beads/prs/route.ts b/src/app/api/beads/prs/route.ts index d72ecdc28..30feb9238 100644 --- a/src/app/api/beads/prs/route.ts +++ b/src/app/api/beads/prs/route.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { NextResponse } from "next/server.js"; -import { scrubSidecarInternalEnv } from "@/lib/coven-bin"; +import { caveToolSpawnEnv } from "@/lib/coven-bin"; import { rejectNonLocalRequest } from "@/lib/server/api-security"; import { resolveRepoRoot } from "@/lib/server/issue-worktree-provision"; import { @@ -36,7 +36,7 @@ const MERGED_PR_LIMIT = "30"; async function ghPrList(repoRoot: string, args: string[]): Promise { const { stdout } = await execFileAsync("gh", args, { cwd: repoRoot, // cwd's repo → gh targets the right OWNER/REPO without hardcoding - env: scrubSidecarInternalEnv({ ...process.env, GH_PROMPT_DISABLED: "1" }), + env: { ...caveToolSpawnEnv(), GH_PROMPT_DISABLED: "1" }, timeout: GH_TIMEOUT_MS, maxBuffer: MAX_GH_BUFFER, }); diff --git a/src/app/api/beads/route.test.ts b/src/app/api/beads/route.test.ts index b4a17c63a..101fdf85b 100644 --- a/src/app/api/beads/route.test.ts +++ b/src/app/api/beads/route.test.ts @@ -4,6 +4,7 @@ import { execFileSync } from "node:child_process"; import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { refreshCovenSpawnEnv } from "@/lib/coven-bin"; const temp = await mkdtemp(path.join(os.tmpdir(), "cave-beads-route-")); const projectA = path.join(temp, "project-a"); @@ -57,6 +58,7 @@ fi await Promise.all([writeFile(path.join(fakeBin, "bd"), fakeCommand), writeFile(path.join(fakeBin, "gh"), fakeCommand)]); await Promise.all([chmod(path.join(fakeBin, "bd"), 0o755), chmod(path.join(fakeBin, "gh"), 0o755)]); process.env.PATH = `${fakeBin}${path.delimiter}${previous.path ?? ""}`; + refreshCovenSpawnEnv(); process.env.CAVE_PROJECTS_PATH_OVERRIDE = projectsPath; process.env.CAVE_ROUTE_COMMAND_LOG = commandLog; delete process.env.COVEN_CAVE_AUTH_TOKEN; @@ -71,7 +73,7 @@ fi `http://127.0.0.1/api/beads?mode=show&id=cave-shared&projectRoot=${root}`, ]) { const response = await beads.GET(localRequest(url)); - assert.equal(response.status, 200); + assert.equal(response.status, 200, await response.clone().text()); assert.equal((await response.json()).projectRoot, projectA); } const prResponse = await prs.GET(localRequest(`http://127.0.0.1/api/beads/prs?projectRoot=${root}`)); @@ -111,6 +113,7 @@ fi process.chdir(previous.cwd); if (previous.path === undefined) delete process.env.PATH; else process.env.PATH = previous.path; + refreshCovenSpawnEnv(); if (previous.projects === undefined) delete process.env.CAVE_PROJECTS_PATH_OVERRIDE; else process.env.CAVE_PROJECTS_PATH_OVERRIDE = previous.projects; if (previous.commandLog === undefined) delete process.env.CAVE_ROUTE_COMMAND_LOG; diff --git a/src/lib/coven-bin.test.ts b/src/lib/coven-bin.test.ts index dfbdc48cc..a21872434 100644 --- a/src/lib/coven-bin.test.ts +++ b/src/lib/coven-bin.test.ts @@ -7,7 +7,7 @@ import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { covenAdapterDirsEnvValue, covenLaunchCommandForBinary, pickWindowsLauncher, runnableNodeToolchainDirs, scrubSidecarInternalEnv, windowsPathFromRegQuery } from "./coven-bin.ts"; +import { caveToolSpawnEnv, covenAdapterDirsEnvValue, covenLaunchCommandForBinary, pickWindowsLauncher, refreshCovenSpawnEnv, runnableNodeToolchainDirs, scrubSidecarInternalEnv, windowsPathFromRegQuery } from "./coven-bin.ts"; const source = await readFile(new URL("./coven-bin.ts", import.meta.url), "utf8"); @@ -17,6 +17,20 @@ assert.match( "Windows discovery includes the npm global shim directory under %APPDATA%\\npm", ); +{ + const previousPath = process.env.PATH; + process.env.PATH = "/queue-launch-path:/usr/bin"; + refreshCovenSpawnEnv(); + assert.equal( + caveToolSpawnEnv().PATH?.split(path.delimiter)[0], + "/queue-launch-path", + "Queue tools preserve the desktop launch PATH before Cave fallback directories", + ); + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + refreshCovenSpawnEnv(); +} + assert.match( source, /path\.join\([^\n]*HOME, "\.grok", "bin"\)/, @@ -89,8 +103,6 @@ assert.match( // gh/bd/npx/tailscale/vault children run user-visible (or arbitrary, // via npx postinstall) code and must not see sidecar secrets either. for (const rel of [ - "../app/api/beads/prs/route.ts", - "./server/beads-cli.ts", "../app/api/skills/directory/install/route.ts", "../app/api/skills/directory/use/route.ts", "./branch-pr-context.ts", @@ -110,6 +122,19 @@ for (const rel of [ ); } +// Queue subprocesses must use the same login-shell PATH as onboarding. A +// packaged Finder/Spotlight launch otherwise finds Git during setup but loses +// git/bd/gh when Queue performs its own repository work. +for (const rel of [ + "../app/api/beads/prs/route.ts", + "./server/beads-cli.ts", + "./server/issue-worktree-provision.ts", + "./queue-project-readiness.ts", +]) { + const spawnSite = await readFile(new URL(rel, import.meta.url), "utf8"); + assert.match(spawnSite, /caveToolSpawnEnv\(\)/, `${rel} uses Cave's augmented, scrubbed launch PATH`); +} + assert.match( source, /export function refreshCovenSpawnEnv\(\)[\s\S]*cachedPath = null[\s\S]*return covenSpawnEnv\(\)/, diff --git a/src/lib/coven-bin.ts b/src/lib/coven-bin.ts index 655e6bd39..5d7077dcd 100644 --- a/src/lib/coven-bin.ts +++ b/src/lib/coven-bin.ts @@ -30,6 +30,7 @@ import path from "node:path"; let cachedBin: string | null = null; let cachedPath: string | null = null; +let cachedToolPath: string | null = null; export type CovenLaunchCommand = { command: string; @@ -452,26 +453,23 @@ export function covenAdapterDirsEnvValue( * a Finder-spawned cave can still resolve nested tooling (codex, claude, * git, gh, …). */ -export function covenSpawnEnv(): NodeJS.ProcessEnv { - if (cachedPath === null) { - const fromSystem = - process.platform === "win32" ? windowsRegistryPath() : loginShellPath(); - const prependedDirs = candidateDirs(); - const parts = [ - ...prependedDirs, - ...(fromSystem ? fromSystem.split(path.delimiter) : []), - ...(process.env.PATH ? process.env.PATH.split(path.delimiter) : []), - ]; - const seen = new Set(); - const dedup: string[] = []; - for (const p of parts) { - if (!p || seen.has(p)) continue; - seen.add(p); - dedup.push(p); - } - cachedPath = dedup.join(path.delimiter); - } - const env: NodeJS.ProcessEnv = { ...process.env, PATH: cachedPath }; +function augmentedSpawnPath(preferLaunchPath: boolean): string { + const fromSystem = process.platform === "win32" ? windowsRegistryPath() : loginShellPath(); + const launchPath = process.env.PATH ? process.env.PATH.split(path.delimiter) : []; + const systemPath = fromSystem ? fromSystem.split(path.delimiter) : []; + const candidates = candidateDirs(); + // `coven` intentionally prefers its managed install locations over a stale + // shell binary. General Queue tools must do the opposite: a desktop launch + // should honor the user's PATH before falling back to Cave's helpful extras. + const parts = preferLaunchPath + ? [...launchPath, ...systemPath, ...candidates] + : [...candidates, ...systemPath, ...launchPath]; + const seen = new Set(); + return parts.filter((part) => !!part && !seen.has(part) && (seen.add(part), true)).join(path.delimiter); +} + +function spawnEnv(pathValue: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, PATH: pathValue }; env.COVEN_HARNESS_ADAPTER_DIRS = covenAdapterDirsEnvValue( process.env.COVEN_HARNESS_ADAPTER_DIRS, process.env.COVEN_HOME, @@ -489,8 +487,26 @@ export function covenSpawnEnv(): NodeJS.ProcessEnv { return scrubSidecarInternalEnv(env); } +export function covenSpawnEnv(): NodeJS.ProcessEnv { + cachedPath ??= augmentedSpawnPath(false); + return spawnEnv(cachedPath); +} + +/** + * Augmented, scrubbed environment for user-selected project tooling (Git, + * Beads, GitHub CLI). Preserve the actual launch/login PATH before Cave's + * fallback directories so Finder/Spotlight launches behave like the user's + * shell and a deliberately selected tool is never shadowed by Cave's own + * binary-discovery priority. + */ +export function caveToolSpawnEnv(): NodeJS.ProcessEnv { + cachedToolPath ??= augmentedSpawnPath(true); + return spawnEnv(cachedToolPath); +} + export function refreshCovenSpawnEnv(): NodeJS.ProcessEnv { cachedPath = null; + cachedToolPath = null; return covenSpawnEnv(); } @@ -502,5 +518,6 @@ export function refreshCovenSpawnEnv(): NodeJS.ProcessEnv { export function refreshCovenBin(): string { cachedBin = null; cachedPath = null; + cachedToolPath = null; return covenBin(); } diff --git a/src/lib/queue-project-readiness.test.ts b/src/lib/queue-project-readiness.test.ts index 8539a0fc3..0cb42ef63 100644 --- a/src/lib/queue-project-readiness.test.ts +++ b/src/lib/queue-project-readiness.test.ts @@ -189,12 +189,19 @@ try { assert.match(route, /withGenerationLock/, "Generate serializes initialization per repository"); assert.match(route, /current\.ok && identityMatches/, "a matching concurrent Generate succeeds idempotently"); + const readinessSource = await (await import("node:fs/promises")).readFile( + new URL("./queue-project-readiness.ts", import.meta.url), + "utf8", + ); + assert.match(readinessSource, /env: caveToolSpawnEnv\(\)/, "Queue readiness finds Git through Cave's launch PATH"); + const prBridgeRoute = await (await import("node:fs/promises")).readFile( new URL("../app/api/beads/prs/route.ts", import.meta.url), "utf8", ); assert.match(prBridgeRoute, /projectRoot is required/, "the PR bridge rejects anonymous Queue requests"); assert.doesNotMatch(prBridgeRoute, /projectRoot\) \|\| process\.cwd\(\)/, "the PR bridge cannot use the app cwd as a project fallback"); + assert.match(prBridgeRoute, /caveToolSpawnEnv\(\)/, "Queue PR reads use Cave's launch PATH for gh"); } finally { if (previousProjectsPath === undefined) delete process.env.CAVE_PROJECTS_PATH_OVERRIDE; else process.env.CAVE_PROJECTS_PATH_OVERRIDE = previousProjectsPath; diff --git a/src/lib/queue-project-readiness.ts b/src/lib/queue-project-readiness.ts index 9daa4a378..87e13a0eb 100644 --- a/src/lib/queue-project-readiness.ts +++ b/src/lib/queue-project-readiness.ts @@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto"; import { loadProjects, projectById } from "@/lib/cave-projects"; import type { CaveProject } from "@/lib/cave-projects-types"; import { caveHome } from "@/lib/coven-paths"; +import { caveToolSpawnEnv } from "@/lib/coven-bin"; import { isAllowedNewProjectRoot, validateCaveProjectRoot } from "@/lib/server/project-paths"; import { runBdCommand, type BdResult } from "@/lib/server/beads-cli"; @@ -129,6 +130,7 @@ async function gitTopLevel(root: string): Promise { try { const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd: /* turbopackIgnore: true */ root, + env: caveToolSpawnEnv(), timeout: GIT_TIMEOUT_MS, }); const top = stdout.trim(); diff --git a/src/lib/server/beads-cli.ts b/src/lib/server/beads-cli.ts index 6991cdff7..5c34f7547 100644 --- a/src/lib/server/beads-cli.ts +++ b/src/lib/server/beads-cli.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { scrubSidecarInternalEnv } from "@/lib/coven-bin"; +import { caveToolSpawnEnv } from "@/lib/coven-bin"; const execFileAsync = promisify(execFile); const BD_TIMEOUT_MS = 30_000; @@ -81,11 +81,11 @@ export async function runBdCommand( ): Promise { const platform = options?.platform ?? process.platform; const exec = options?.exec ?? (execFileAsync as unknown as Exec); - const env = scrubSidecarInternalEnv({ - ...process.env, + const env = { + ...caveToolSpawnEnv(), BEADS_DIR: beadsDir, BD_NON_INTERACTIVE: "1", - }); + }; try { const { stdout, stderr } = await exec("bd", args, { diff --git a/src/lib/server/issue-worktree-provision.ts b/src/lib/server/issue-worktree-provision.ts index ea4540f11..7760039e3 100644 --- a/src/lib/server/issue-worktree-provision.ts +++ b/src/lib/server/issue-worktree-provision.ts @@ -19,6 +19,7 @@ import { promisify } from "node:util"; import fs from "node:fs"; import path from "node:path"; import { resolveAllowedProjectPath } from "@/lib/server/project-paths"; +import { caveToolSpawnEnv } from "@/lib/coven-bin"; import { daemonSessionRoots, resolveWithinSessionRoots } from "@/lib/server/session-project-roots"; import { branchWorktreeDir, @@ -34,7 +35,7 @@ const FETCH_TIMEOUT_MS = 10_000; const MAX_GIT_BUFFER = 16 * 1024 * 1024; function git(cwd: string, args: string[], timeout: number = GIT_TIMEOUT_MS) { - return execFileAsync("git", args, { cwd, timeout, maxBuffer: MAX_GIT_BUFFER }); + return execFileAsync("git", args, { cwd, env: caveToolSpawnEnv(), timeout, maxBuffer: MAX_GIT_BUFFER }); } export type RepoRootResolution = From 01e51073a8fde9dd620e584fac0cee77e794ff7d Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:46:08 -0400 Subject: [PATCH 34/38] fix(onboarding): present Git before Queue selection --- src/app/api/onboarding/status/route.test.ts | 10 +++++++++ src/components/onboarding-overlay.tsx | 22 +++++++++--------- tests/onboarding-wizard.spec.ts | 25 +++++++++++++++++++++ 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/app/api/onboarding/status/route.test.ts b/src/app/api/onboarding/status/route.test.ts index 359c9caf9..37ac9ccff 100644 --- a/src/app/api/onboarding/status/route.test.ts +++ b/src/app/api/onboarding/status/route.test.ts @@ -153,6 +153,16 @@ const onboardingModel = readFileSync( ); assert.match(onboardingModel, /git\?: Step/, "onboarding model accepts the git step"); assert.match(overlay, /title: "Find Git"/, "overlay renders the required git checklist row"); +assert.match( + overlay, + /key: "git"[\s\S]*?title: "Find Git"[\s\S]*?key: "project"[\s\S]*?title: "Choose your Queue project"/, + "Git is presented before the Queue project it is required to validate", +); +assert.match( + overlay, + /Git is required before selecting a Queue project/, + "the Git pane explains the required Queue prerequisite rather than calling Git optional", +); const projectFiles = readFileSync( new URL("../../project/files/route.ts", import.meta.url), diff --git a/src/components/onboarding-overlay.tsx b/src/components/onboarding-overlay.tsx index 3cc1abe6a..d4c68dc24 100644 --- a/src/components/onboarding-overlay.tsx +++ b/src/components/onboarding-overlay.tsx @@ -26,6 +26,7 @@ import { type LatestCheckDisplay, } from "@/lib/opencoven-tools-status-display"; import { requestSummonFamiliar } from "@/lib/summon-events"; +import { publishQueueProjectSelection } from "@/lib/queue-project-selection"; import { classifySetupFailure, setupRetryLabel, @@ -919,13 +920,6 @@ export function OnboardingOverlay({ detail: s?.daemon.detail ?? s?.daemon.hint ?? "checking…", icon: "ph:plug", }, - { - key: "project", - title: "Choose your Queue project", - ok: !!s?.project?.ok, - detail: s?.project?.detail ?? s?.project?.hint ?? "checking…", - icon: "ph:folder-simple-dashed", - }, { key: "git", title: "Find Git", @@ -937,6 +931,13 @@ export function OnboardingOverlay({ "Git is required to select and use a Queue project.", icon: "ph:git-branch-bold", }, + { + key: "project", + title: "Choose your Queue project", + ok: !!s?.project?.ok, + detail: s?.project?.detail ?? s?.project?.hint ?? "checking…", + icon: "ph:folder-simple-dashed", + }, ]; }, [status]); @@ -1471,8 +1472,9 @@ export function OnboardingOverlay({ ) : step.key === "git" ? (

- Chat works without Git, but the changes panel, - project file tree, and checkpoints all use it. + Git is required before selecting a Queue project. + Chat can work without Git, but Queue setup, the + changes panel, project file tree, and checkpoints all use it.

{status?.steps.git?.hint ?? @@ -1600,7 +1602,7 @@ function QueueProjectSetup({ onSelected }: { onSelected: () => void }) { } if (generation !== requestGeneration.current) return; setReadiness(body.readiness); - window.dispatchEvent(new CustomEvent("cave:queue-project-selected", { detail: { project: body.readiness.project } })); + publishQueueProjectSelection(body.readiness.project); onSelected(); } catch (cause) { if (generation === requestGeneration.current) { diff --git a/tests/onboarding-wizard.spec.ts b/tests/onboarding-wizard.spec.ts index da89820b4..9218edc2d 100644 --- a/tests/onboarding-wizard.spec.ts +++ b/tests/onboarding-wizard.spec.ts @@ -45,6 +45,22 @@ const DAEMON_DOWN_VETERAN_STATUS = { tools: [], }; +const GIT_REQUIRED_STATUS = { + ok: true, + complete: false, + steps: { + covenCli: { ok: true, detail: "0.0.60" }, + covenHome: { ok: true, detail: "~/.coven" }, + adapters: { ok: true, detail: "Codex" }, + daemon: { ok: true, detail: "running" }, + git: { ok: false, detail: "Git is required to select and use a Queue project.", hint: "Install Git from https://git-scm.com, then re-check." }, + project: { ok: false, detail: "Git is required before selecting a Queue project." }, + familiars: { ok: false, optional: true, detail: "no familiars" }, + binding: { ok: false, optional: true, detail: "no binding configured" }, + }, + tools: [], +}; + // Every step healthy but the roster empty — the state the finish CTA's // "summon your familiar" promise is about. Server `complete` alone drives the // wizard's finish state: Coven Code is an optional runtime adapter, so it is @@ -135,6 +151,15 @@ test.describe("onboarding wizard", () => { await expect(current.first()).toContainText("Install the Coven CLI"); }); + test("puts Git remediation before an unavailable Queue project selector", async ({ page }) => { + await gotoApp(page, GIT_REQUIRED_STATUS); + await expect(wizard(page)).toBeVisible({ timeout: 30_000 }); + const current = wizard(page).locator('li[aria-current="step"]'); + await expect(current).toContainText("Find Git"); + await expect(current).toContainText("Git is required before selecting a Queue project."); + await expect(current).toContainText("Install Git from https://git-scm.com"); + }); + test("Escape closes for the session without permanently skipping", async ({ page }) => { await gotoApp(page, FRESH_STATUS); await expect(wizard(page)).toBeVisible({ timeout: 30_000 }); From df939a4b3c56fa9abcaf39019a8f27da4ad65563 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:46:09 -0400 Subject: [PATCH 35/38] fix(queue): synchronize project changes across windows --- src/components/familiar-work-queue-view.tsx | 95 +++++++++++------- src/lib/queue-project-selection.ts | 72 ++++++++++++++ tests/familiar-work-queue.spec.ts | 102 ++++++++++++++++++++ 3 files changed, 235 insertions(+), 34 deletions(-) create mode 100644 src/lib/queue-project-selection.ts diff --git a/src/components/familiar-work-queue-view.tsx b/src/components/familiar-work-queue-view.tsx index e07493ad5..fe374e8c6 100644 --- a/src/components/familiar-work-queue-view.tsx +++ b/src/components/familiar-work-queue-view.tsx @@ -12,6 +12,7 @@ import { useAnnouncer } from "@/components/ui/live-region"; import { usePausablePoll } from "@/lib/use-pausable-poll"; import { useMinuteTick } from "@/lib/use-minute-tick"; import { relativeTime } from "@/lib/relative-time"; +import { subscribeToQueueProjectSelection, type QueueProjectSelection } from "@/lib/queue-project-selection"; import type { ResolvedFamiliar } from "@/lib/familiar-resolve"; import { buildWorkQueue, @@ -211,11 +212,36 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa const [evidenceAdded, setEvidenceAdded] = useState>(() => new Set()); const loadSeq = useRef(0); const abortRef = useRef(null); + const queueSurfaceRef = useRef(null); + const restoreQueueFocusRef = useRef(false); + const announcedRef = useRef(false); // True once a load has landed WITH PR-bridge data. A later bridge failure is // then a refresh failure (keep the richer on-screen picture + inline retry // banner) rather than a degradation (which would silently drop PR lanes). const hadPrDataRef = useRef(false); const activeProjectRootRef = useRef(null); + + const resetForProject = useCallback((project: QueueProjectSelection | null) => { + const surface = queueSurfaceRef.current; + const focusedQueueControl = surface?.contains(document.activeElement) ?? false; + activeProjectRootRef.current = project?.root ?? null; + hadPrDataRef.current = false; + announcedRef.current = false; + setQueue(null); + setReadiness(null); + setReadinessFailure(null); + setError(null); + setBeadsDegraded(false); + setPrsDegraded(null); + setLastUpdated(null); + setBusyId(null); + setDetailId(null); + setEvidenceAdded(new Set()); + if (focusedQueueControl) { + restoreQueueFocusRef.current = true; + surface?.focus({ preventScroll: true }); + } + }, []); // Re-render ~once a minute so the header freshness and per-card ages stay // truthful between polls (the equality guard below keeps queue state stable, // so nothing else would tick them). @@ -257,14 +283,7 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa if (activeProjectRootRef.current !== nextRoot) { // A selected repository is an isolation boundary. Do not retain cards, // failure state, detail selection, or evidence from the prior project. - activeProjectRootRef.current = nextRoot; - hadPrDataRef.current = false; - setQueue(null); - setBeadsDegraded(false); - setPrsDegraded(null); - setLastUpdated(null); - setDetailId(null); - setEvidenceAdded(new Set()); + resetForProject(nextReadiness.project); } setReadiness(nextReadiness); if (!nextReadiness.ok || !nextReadiness.project) { @@ -296,7 +315,7 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa } finally { if (seq === loadSeq.current) setHasLoaded(true); } - }, []); + }, [resetForProject]); const generateQueue = useCallback(async () => { if (generating) return; @@ -307,15 +326,28 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "generate", projectId: readiness?.project?.id }), }); - const json = (await response.json().catch(() => null)) as { ok?: boolean; error?: string } | null; - if (!response.ok || !json?.ok) throw new Error(json?.error || "Couldn't generate the Queue workspace"); + const json = (await response.json().catch(() => null)) as { ok?: boolean; error?: string; readiness?: QueueReadiness } | null; + if (!response.ok || !json?.ok) { + // Another Cave window can change the persisted selection while this + // Generate request is in flight. The route returns that newer + // readiness on 409: adopt it immediately so a retry targets B, not + // the stale A button the user originally pressed. + if (response.status === 409 && json?.readiness) { + resetForProject(json.readiness.project); + setReadiness(json.readiness); + setError(json.error || json.readiness.message); + void load(true); + return; + } + throw new Error(json?.error || "Couldn't generate the Queue workspace"); + } await load(true); } catch (err) { setError(err instanceof Error ? err.message : "Couldn't generate the Queue workspace"); } finally { setGenerating(false); } - }, [generating, load, readiness?.project?.id]); + }, [generating, load, readiness?.project?.id, resetForProject]); useEffect(() => { void load(); @@ -323,31 +355,16 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa }, [load]); useEffect(() => { - const onProjectSelected = (event: Event) => { - const selected = event as CustomEvent<{ project?: { root?: string } | null }>; + return subscribeToQueueProjectSelection((project) => { // Selection is an isolation boundary even if its readiness request is // unavailable. Clear all prior-project controls synchronously so a // transient B failure cannot leave actionable cards from A on screen. - activeProjectRootRef.current = selected.detail?.project?.root ?? null; - hadPrDataRef.current = false; - setQueue(null); - setReadiness(null); - setReadinessFailure(null); - setError(null); - setBeadsDegraded(false); - setPrsDegraded(null); - setLastUpdated(null); - setBusyId(null); - setDetailId(null); - setEvidenceAdded(new Set()); + resetForProject(project); void load(true); - }; - window.addEventListener("cave:queue-project-selected", onProjectSelected); - return () => window.removeEventListener("cave:queue-project-selected", onProjectSelected); - }, [load]); + }); + }, [load, resetForProject]); // Announce the actionable count once the first load settles. - const announcedRef = useRef(false); useEffect(() => { if (!hasLoaded || announcedRef.current || !queue) return; announcedRef.current = true; @@ -358,6 +375,15 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa ); }, [hasLoaded, queue, announce]); + // The selected project can replace the focused row action with a loading + // state. Keep keyboard focus on the Queue surface, then restore it there + // after the new project settles instead of dropping users onto document body. + useEffect(() => { + if (!restoreQueueFocusRef.current || !hasLoaded || (!queue && !error)) return; + restoreQueueFocusRef.current = false; + queueSurfaceRef.current?.focus({ preventScroll: true }); + }, [hasLoaded, queue, error]); + usePausablePoll(() => void load(true), 30_000, { pauseWhileInputActive: true }); const familiarName = useCallback( @@ -560,11 +586,12 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa // gap rather than dereferencing the removed Queue or retaining its controls. if (!hasLoaded || (!queue && !error)) { return ( -

+
{embedded ? null :

Queue

}
+

Loading the selected Queue project…

@@ -583,7 +610,7 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa || readiness?.code === "project-storage-error"; const projectUnavailable = !readinessUnavailable && !sourcesUnavailable && !canGenerate && !selectionRemediable && readiness?.project !== null; return ( -
+
+
{/* Meta row (queue redesign) — the live summary with a truthful "updated Xm ago" readout, Refresh on the right. The surface title + Tasks/Queue tabs live in the hosting board header (embedded). */} diff --git a/src/lib/queue-project-selection.ts b/src/lib/queue-project-selection.ts new file mode 100644 index 000000000..db519c4cb --- /dev/null +++ b/src/lib/queue-project-selection.ts @@ -0,0 +1,72 @@ +/** + * Queue selection is persisted for the whole Cave home, so mounted Queue + * surfaces in every window must invalidate together. A local CustomEvent keeps + * same-window listeners simple; BroadcastChannel carries that intent to the + * other Cave windows sharing this origin. + */ +export const QUEUE_PROJECT_SELECTED_EVENT = "cave:queue-project-selected"; + +export type QueueProjectSelection = { + id: string; + name: string; + root: string; +}; + +type QueueProjectSelectionMessage = { + type: "queue-project-selected"; + project: QueueProjectSelection | null; +}; + +const CHANNEL_NAME = "cave:queue-project-selection"; +let channel: BroadcastChannel | null = null; + +function validProject(value: unknown): value is QueueProjectSelection { + if (!value || typeof value !== "object") return false; + const project = value as Partial; + return typeof project.id === "string" && typeof project.name === "string" && typeof project.root === "string"; +} + +function projectFromMessage(value: unknown): QueueProjectSelection | null | undefined { + if (!value || typeof value !== "object") return undefined; + const message = value as Partial; + if (message.type !== "queue-project-selected") return undefined; + return message.project === null || validProject(message.project) ? message.project : undefined; +} + +function selectionChannel(): BroadcastChannel | null { + if (typeof window === "undefined" || typeof BroadcastChannel === "undefined") return null; + channel ??= new BroadcastChannel(CHANNEL_NAME); + return channel; +} + +function dispatch(project: QueueProjectSelection | null): void { + window.dispatchEvent(new CustomEvent(QUEUE_PROJECT_SELECTED_EVENT, { detail: { project } })); +} + +/** Publish immediately in this window and asynchronously to every other Cave window. */ +export function publishQueueProjectSelection(project: QueueProjectSelection | null): void { + if (typeof window === "undefined") return; + dispatch(project); + selectionChannel()?.postMessage({ type: "queue-project-selected", project } satisfies QueueProjectSelectionMessage); +} + +/** Subscribe to both local selections and cross-window BroadcastChannel messages. */ +export function subscribeToQueueProjectSelection(listener: (project: QueueProjectSelection | null) => void): () => void { + if (typeof window === "undefined") return () => {}; + const onLocal = (event: Event) => { + const selected = event as CustomEvent<{ project?: unknown }>; + const project = selected.detail?.project; + if (project === null || validProject(project)) listener(project ?? null); + }; + window.addEventListener(QUEUE_PROJECT_SELECTED_EVENT, onLocal); + const activeChannel = selectionChannel(); + const onMessage = (event: MessageEvent) => { + const project = projectFromMessage(event.data); + if (project !== undefined) dispatch(project); + }; + activeChannel?.addEventListener("message", onMessage); + return () => { + window.removeEventListener(QUEUE_PROJECT_SELECTED_EVENT, onLocal); + activeChannel?.removeEventListener("message", onMessage); + }; +} diff --git a/tests/familiar-work-queue.spec.ts b/tests/familiar-work-queue.spec.ts index 47d3e144c..21c741d46 100644 --- a/tests/familiar-work-queue.spec.ts +++ b/tests/familiar-work-queue.spec.ts @@ -195,6 +195,108 @@ test.describe("familiar work queue (PR control tower)", () => { expect(aRootMutation).toBe(false); }); + test("announces a project-switch reload and keeps Queue focus stable", async ({ page }) => { + let selectedProject = QUEUE_PROJECT; + let releaseBReadiness!: () => void; + const bReadiness = new Promise((resolve) => { releaseBReadiness = resolve; }); + await page.route("**/api/queue/readiness", async (route) => { + if (selectedProject.id === QUEUE_PROJECT_B.id) await bReadiness; + return route.fulfill({ json: { ok: true, readiness: { ...QUEUE_READINESS, project: selectedProject } } }); + }); + await gotoWorkQueue(page); + + const fwq = page.locator(".fwq"); + const claim = fwq.getByRole("region", { name: "No open PR" }).getByRole("button", { name: "Claim", exact: true }); + await claim.focus(); + selectedProject = QUEUE_PROJECT_B; + await page.evaluate((project) => window.dispatchEvent(new CustomEvent("cave:queue-project-selected", { detail: { project } })), QUEUE_PROJECT_B); + + await expect(fwq.getByRole("status")).toHaveText("Loading the selected Queue project…"); + await expect.poll(() => page.evaluate(() => document.activeElement?.tagName)).not.toBe("BODY"); + releaseBReadiness(); + await expect(fwq.getByRole("region", { name: "No open PR" })).toBeVisible(); + await expect.poll(() => page.evaluate(() => document.activeElement?.className)).toContain("fwq"); + }); + + test("a Generate conflict adopts the other window's selected project before retrying", async ({ page }) => { + let selectedProject = QUEUE_PROJECT; + const generateBodies: Array<{ projectId?: string }> = []; + const needsBeads = (project: typeof QUEUE_PROJECT) => ({ + ok: false, + code: "needs-beads", + message: `Generate Queue for ${project.name}.`, + canGenerate: true, + project, + }); + await page.route("**/api/queue/readiness", async (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ json: { ok: true, readiness: needsBeads(selectedProject) } }); + } + const body = route.request().postDataJSON(); + if (body.action !== "generate") return route.fallback(); + generateBodies.push(body); + if (generateBodies.length === 1) { + selectedProject = QUEUE_PROJECT_B; + return route.fulfill({ + status: 409, + json: { ok: false, error: "Queue project changed in another Cave window.", readiness: needsBeads(QUEUE_PROJECT_B) }, + }); + } + return route.fulfill({ json: { ok: true, readiness: { ...needsBeads(QUEUE_PROJECT_B), ok: true, code: "ready", canGenerate: false } } }); + }); + await gotoWorkQueue(page); + + const fwq = page.locator(".fwq"); + await fwq.getByRole("button", { name: "Generate" }).click(); + await expect.poll(() => generateBodies).toHaveLength(1); + await expect(fwq).toContainText(QUEUE_PROJECT_B.name); + await fwq.getByRole("button", { name: "Generate" }).click(); + await expect.poll(() => generateBodies).toHaveLength(2); + expect(generateBodies.map((body) => body.projectId)).toEqual([QUEUE_PROJECT.id, QUEUE_PROJECT_B.id]); + }); + + test("a Queue project selection broadcasts to another mounted Cave window", async ({ browser }) => { + const context = await browser.newContext(); + const pageA = await context.newPage(); + const pageB = await context.newPage(); + let selectedProject = QUEUE_PROJECT; + let releaseBReadiness!: () => void; + const bReadiness = new Promise((resolve) => { releaseBReadiness = resolve; }); + try { + await gotoWorkQueue(pageA); + await gotoWorkQueue(pageB); + for (const page of [pageA, pageB]) { + await page.route("**/api/queue/readiness", async (route) => { + if (selectedProject.id === QUEUE_PROJECT_B.id) await bReadiness; + return route.fulfill({ json: { ok: true, readiness: { ...QUEUE_READINESS, project: selectedProject } } }); + }); + } + await pageB.route(/\/api\/beads\?/, (route) => { + if (new URL(route.request().url()).searchParams.get("projectRoot") !== QUEUE_PROJECT_B.root) return route.fallback(); + return route.fulfill({ + json: { + ok: true, + data: [{ id: "cave-project-b", title: "Only project B", priority: 1, status: "open", issue_type: "task", labels: [], updated_at: null, comment_count: 0 }], + }, + }); + }); + + const queueB = pageB.locator(".fwq"); + await expect(queueB.getByText("iOS profile avatar")).toBeVisible(); + selectedProject = QUEUE_PROJECT_B; + await pageA.evaluate((project) => { + new BroadcastChannel("cave:queue-project-selection").postMessage({ type: "queue-project-selected", project }); + }, QUEUE_PROJECT_B); + + await expect(queueB.getByRole("status")).toHaveText("Loading the selected Queue project…"); + await expect(queueB.getByText("iOS profile avatar")).toHaveCount(0); + releaseBReadiness(); + await expect(queueB.getByText("Only project B")).toBeVisible(); + } finally { + await context.close(); + } + }); + test("claiming for a familiar posts the selected assignee", async ({ page }) => { let claimBody: unknown = null; await page.route("**/api/beads", async (route) => { From f564ec0573a09534b49e526499f9bb1ca28b35e3 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Fri, 24 Jul 2026 03:03:10 -0400 Subject: [PATCH 36/38] test(queue): stabilize cross-window selection coverage --- tests/familiar-work-queue.spec.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/familiar-work-queue.spec.ts b/tests/familiar-work-queue.spec.ts index 21c741d46..5710aaf7e 100644 --- a/tests/familiar-work-queue.spec.ts +++ b/tests/familiar-work-queue.spec.ts @@ -263,14 +263,20 @@ test.describe("familiar work queue (PR control tower)", () => { let releaseBReadiness!: () => void; const bReadiness = new Promise((resolve) => { releaseBReadiness = resolve; }); try { - await gotoWorkQueue(pageA); - await gotoWorkQueue(pageB); for (const page of [pageA, pageB]) { await page.route("**/api/queue/readiness", async (route) => { if (selectedProject.id === QUEUE_PROJECT_B.id) await bReadiness; return route.fulfill({ json: { ok: true, readiness: { ...QUEUE_READINESS, project: selectedProject } } }); }); + await page.route("**/api/onboarding/status**", (route) => + route.fulfill({ json: { ok: true, complete: true, steps: { project: { ok: true } }, tools: [] } }), + ); } + // These pages belong to an explicitly created BrowserContext, so the + // page-fixture beforeEach routes do not apply. Install their readiness + // routes before navigation to keep the initial A load deterministic. + await gotoWorkQueue(pageA); + await gotoWorkQueue(pageB); await pageB.route(/\/api\/beads\?/, (route) => { if (new URL(route.request().url()).searchParams.get("projectRoot") !== QUEUE_PROJECT_B.root) return route.fallback(); return route.fulfill({ From 106a6c6caf56ce560098ce52715bde98d5ae1a42 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 24 Jul 2026 04:04:42 -0500 Subject: [PATCH 37/38] fix(queue): share the /api/beads workspace safety contract in readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queue readiness previously accepted a canonical-path mismatch for a contained .beads directory (&&), while resolveSafeBeadsWorkspace treats any mismatch as unsafe (||) — so readiness could report ready where /api/beads answers 422 unsafe. Delegate the existing-directory verdict to resolveSafeBeadsWorkspace so both surfaces share one contract, and cover the swapped in-repo .beads symlink in the readiness test. Also canonicalize mkdtemp roots in the readiness and workspace tests so they pass on macOS, where os.tmpdir() sits behind the /var symlink. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/lib/queue-project-readiness.test.ts | 21 +++++++++++++++++++-- src/lib/queue-project-readiness.ts | 16 ++++++++-------- src/lib/server/beads-workspace.test.ts | 6 ++++-- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/lib/queue-project-readiness.test.ts b/src/lib/queue-project-readiness.test.ts index 0cb42ef63..e48c76ab8 100644 --- a/src/lib/queue-project-readiness.test.ts +++ b/src/lib/queue-project-readiness.test.ts @@ -1,11 +1,13 @@ // @ts-nocheck import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -const tempDir = await mkdtemp(path.join(os.tmpdir(), "cave-queue-project-")); +// realpath: readiness canonicalizes the Git toplevel, so a symlinked tmpdir +// (macOS /var → /private/var) must not skew root-equality assertions. +const tempDir = await realpath(await mkdtemp(path.join(os.tmpdir(), "cave-queue-project-"))); const projectRoot = path.join(tempDir, "project"); const nonGitRoot = path.join(tempDir, "not-a-git-project"); const projectsPath = path.join(tempDir, "projects.json"); @@ -94,6 +96,21 @@ try { assert.equal(ready.code, "ready"); assert.equal(ready.ok, true); + // Readiness shares resolveSafeBeadsWorkspace with /api/beads: a swapped + // .beads symlink — even one contained inside the repository — must read as + // an error here exactly like the mutation adapter's 422, never as ready. + await rm(path.join(projectRoot, ".beads"), { recursive: true, force: true }); + await mkdir(path.join(projectRoot, "beads-elsewhere")); + await symlink(path.join(projectRoot, "beads-elsewhere"), path.join(projectRoot, ".beads"), "dir"); + const swapped = await queueProjectReadiness({ + beadsProbe: async () => ({ ok: true, stdout: "[]", stderr: "" }), + }); + assert.equal(swapped.code, "beads-error", "an in-repo .beads symlink is unsafe for readiness exactly as it is for /api/beads"); + assert.equal(swapped.canGenerate, false); + assert.match(swapped.message, /Repair it before loading Queue work/); + await rm(path.join(projectRoot, ".beads"), { recursive: true, force: true }); + await mkdir(path.join(projectRoot, ".beads")); + let probeCalls = 0; const cachedProbe = async () => { probeCalls += 1; diff --git a/src/lib/queue-project-readiness.ts b/src/lib/queue-project-readiness.ts index 87e13a0eb..fa7fe5831 100644 --- a/src/lib/queue-project-readiness.ts +++ b/src/lib/queue-project-readiness.ts @@ -10,6 +10,7 @@ import { caveHome } from "@/lib/coven-paths"; import { caveToolSpawnEnv } from "@/lib/coven-bin"; import { isAllowedNewProjectRoot, validateCaveProjectRoot } from "@/lib/server/project-paths"; import { runBdCommand, type BdResult } from "@/lib/server/beads-cli"; +import { resolveSafeBeadsWorkspace } from "@/lib/server/beads-workspace"; const execFileAsync = promisify(execFile); const GIT_TIMEOUT_MS = 10_000; @@ -169,14 +170,7 @@ function beadsUnavailable(result: BdResult): boolean { async function beadsWorkspaceStatus(repoRoot: string, probe: BeadsProbe): Promise { const beadsDir = path.join(/* turbopackIgnore: true */ repoRoot, ".beads"); try { - const entry = await lstat(/* turbopackIgnore: true */ beadsDir); - if (!entry.isDirectory() || entry.isSymbolicLink()) { - return { kind: "error", message: "The Queue Beads workspace is invalid. Repair it before loading Queue work." }; - } - const canonicalBeads = await realpath(/* turbopackIgnore: true */ beadsDir); - if (canonicalBeads !== beadsDir && !canonicalBeads.startsWith(repoRoot + path.sep)) { - return { kind: "error", message: "The Queue Beads workspace points outside the selected project. Repair it before loading Queue work." }; - } + await lstat(/* turbopackIgnore: true */ beadsDir); } catch (cause) { const error = cause as NodeJS.ErrnoException; if (error.code === "ENOENT") { @@ -193,6 +187,12 @@ async function beadsWorkspaceStatus(repoRoot: string, probe: BeadsProbe): Promis } return { kind: "error", message: "Cave could not inspect the Queue Beads workspace. Check project permissions and try again." }; } + // Readiness and the /api/beads mutation adapter must agree on what a safe + // workspace is: delegate to the shared resolver so "ready" here can never + // describe a workspace /api/beads would reject as unsafe (422). + if (!resolveSafeBeadsWorkspace(repoRoot).ok) { + return { kind: "error", message: "The Queue Beads workspace is invalid or points outside the selected project. Repair it before loading Queue work." }; + } // Directory presence alone is not a workspace: a failed bd init can leave an // empty .beads behind. A read-only probe keeps Generate available to repair it. const result = await probe(repoRoot, beadsDir, ["ready", "--json"]); diff --git a/src/lib/server/beads-workspace.test.ts b/src/lib/server/beads-workspace.test.ts index 98a73e3a8..f37c2fb9c 100644 --- a/src/lib/server/beads-workspace.test.ts +++ b/src/lib/server/beads-workspace.test.ts @@ -1,10 +1,12 @@ // @ts-nocheck import assert from "node:assert/strict"; -import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises"; +import { mkdtemp, mkdir, realpath, rm, symlink } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -const temp = await mkdtemp(path.join(os.tmpdir(), "cave-beads-workspace-")); +// realpath: macOS tmpdir lives behind a /var → /private/var symlink, and the +// resolver's contract compares canonical paths against the given repo root. +const temp = await realpath(await mkdtemp(path.join(os.tmpdir(), "cave-beads-workspace-"))); const projectA = path.join(temp, "project-a"); const projectB = path.join(temp, "project-b"); From 2016fdbeb2ed5e2561e5f7aad621e5ee5a24d542 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 24 Jul 2026 04:09:44 -0500 Subject: [PATCH 38/38] docs: credit Timothy Wayne Gregg for the Queue readiness contract His fork branch for #3743 became unreachable, so the work re-lands through this internal branch; record the attribution it is due. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTORS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 09d543e01..1fe77401e 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -6,6 +6,16 @@ improvements. ## Community Contributors +### Timothy Wayne Gregg ([@CompleteDotTech](https://github.com/CompleteDotTech)) + +Timothy contributed the Queue project readiness contract: a persisted +Queue-specific project selection validated before every Queue load, onboarding +and launch-time reselection for stale paths, and a Generate action that +initializes Beads only inside the selected Git repository. His original pull +request was re-landed through an internal branch after his fork branch became +unreachable, so this file records the credit his work is due. +(proposed in [#3743](https://github.com/OpenCoven/coven-cave/pull/3743)) + ### Val Alexander ([@BunsDev](https://github.com/BunsDev)) Val contributed the direct copilot flow workspace trust grant, including the