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 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/scripts/run-tests.mjs b/scripts/run-tests.mjs index cdb5f298c..6be236d9d 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", @@ -936,6 +937,8 @@ 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", "src/app/api/chat/conversation/[id]/route.test.ts", @@ -1113,6 +1116,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", @@ -1269,6 +1274,11 @@ 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/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/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)] 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/app/api/beads/prs/route.ts b/src/app/api/beads/prs/route.ts index 0719fcab7..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"; -import { scrubSidecarInternalEnv } from "@/lib/coven-bin"; +import { NextResponse } from "next/server.js"; +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, }); @@ -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.test.ts b/src/app/api/beads/route.test.ts new file mode 100644 index 000000000..101fdf85b --- /dev/null +++ b/src/app/api/beads/route.test.ts @@ -0,0 +1,126 @@ +// @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"; +import { refreshCovenSpawnEnv } from "@/lib/coven-bin"; + +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 ?? ""}`; + refreshCovenSpawnEnv(); + 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, 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}`)); + 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; + 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; + 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 164a0e0a5..0f8cf3425 100644 --- a/src/app/api/beads/route.ts +++ b/src/app/api/beads/route.ts @@ -1,10 +1,10 @@ -import fs from "node:fs"; -import path from "node:path"; -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"; 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"; export const runtime = "nodejs"; @@ -39,17 +39,15 @@ 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 { - 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 }) { @@ -85,7 +83,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/onboarding/status/route.test.ts b/src/app/api/onboarding/status/route.test.ts index 9bbe02021..37ac9ccff 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"\)/, @@ -94,18 +100,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 +134,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 +152,17 @@ 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"); +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/app/api/onboarding/status/route.ts b/src/app/api/onboarding/status/route.ts index 6eccb37a8..4221cbfd1 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 { cachedQueueProjectReadiness } from "@/lib/queue-project-readiness"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -39,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()}`, }; } @@ -288,14 +288,33 @@ 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] = await Promise.all([ + const [openCovenTools, covenHome, git, daemon, familiarsRes, queueProject] = await Promise.all([ openCovenToolReadinessStatuses(), checkCovenHome(), checkGit(), checkDaemon(), checkFamiliars(), + checkQueueProject(), ]); const covenCli = checkCovenCli( openCovenTools.find((tool) => tool.id === "coven-cli"), @@ -308,9 +327,10 @@ export async function GET() { openclawAgentCount, ); - const steps = { + const steps: Record = { covenCli, covenHome, + project: queueProject, git, adapters: adapters.step, daemon, @@ -320,9 +340,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 INFRASTRUCTURE is ready - // (CLI, home, runtime, daemon); 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/app/api/queue/readiness/route.test.ts b/src/app/api/queue/readiness/route.test.ts new file mode 100644 index 000000000..ef7f91bdb --- /dev/null +++ b/src/app/api/queue/readiness/route.test.ts @@ -0,0 +1,148 @@ +// @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 leaveWorkspacePartial = 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; + } + if (!leaveWorkspacePartial) 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"); + + // 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; + 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 new file mode 100644 index 000000000..084cfc818 --- /dev/null +++ b/src/app/api/queue/readiness/route.ts @@ -0,0 +1,125 @@ +import path from "node:path"; + +import { NextResponse } from "next/server.js"; + +import { runBdCommand } from "@/lib/server/beads-cli"; +import { readJsonBody, rejectNonLocalRequest } from "@/lib/server/api-security"; +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"; +export const runtime = "nodejs"; + +type Body = { action?: unknown; projectId?: unknown }; +const MAX_PROJECT_ID_LENGTH = 200; + +async function withGenerationLock(locks: Map>, root: string, action: () => Promise): Promise { + let release!: () => void; + const next = new Promise((resolve) => { release = resolve; }); + const previous = locks.get(root) ?? Promise.resolve(); + locks.set(root, next); + await previous; + try { + return await action(); + } finally { + release(); + 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() }); +} + +/** 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 (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 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 }); + } + 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 }); + } + 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(); + // `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 }); + }); + } + + 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); +} diff --git a/src/components/asana-queue-strip.tsx b/src/components/asana-queue-strip.tsx index a8b12f38d..4b1cca49f 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); @@ -49,6 +51,12 @@ export function AsanaQueueStrip({ onOpenUrl, onFiledBead, familiarId }: Props) { 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(); @@ -111,7 +119,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 +131,7 @@ export function AsanaQueueStrip({ onOpenUrl, onFiledBead, familiarId }: Props) { setBusyGid(null); } }, - [announce, onFiledBead], + [announce, onFiledBead, projectRoot], ); if (patInvalid) { 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 }); }; 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 9b5ac8731..fde253085 100644 --- a/src/components/familiar-work-queue-view.test.ts +++ b/src/components/familiar-work-queue-view.test.ts @@ -91,7 +91,7 @@ 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, /\/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"); @@ -142,7 +142,27 @@ 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, /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, /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"); +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 @@ -158,6 +178,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* { +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 +179,9 @@ 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 [readinessFailure, setReadinessFailure] = useState(null); + const [generating, setGenerating] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); const [error, setError] = useState(null); const [beadsDegraded, setBeadsDegraded] = useState(false); @@ -191,10 +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). @@ -221,8 +268,30 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; + let readinessResolved = false; 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; + 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. + resetForProject(nextReadiness.project); + } + 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 @@ -242,18 +311,60 @@ 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); } - }, []); + }, [resetForProject]); + + 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", projectId: readiness?.project?.id }), + }); + 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, resetForProject]); useEffect(() => { void load(); return () => abortRef.current?.abort(); }, [load]); + useEffect(() => { + 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. + resetForProject(project); + void load(true); + }); + }, [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; @@ -264,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( @@ -278,10 +398,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", @@ -291,7 +412,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"); @@ -299,7 +419,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 @@ -309,19 +429,23 @@ 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"); + // 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}.`); - invalidateSurfaceResources("tasks:queue"); await load(true); return true; } catch (err) { @@ -331,7 +455,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 @@ -340,18 +464,18 @@ 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"); 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"); @@ -359,7 +483,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 @@ -369,6 +493,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", @@ -379,13 +505,13 @@ 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(); 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) { @@ -396,7 +522,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+ @@ -455,13 +581,17 @@ 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 ( -
+
{embedded ? null :

Queue

}
+

Loading the selected Queue project…

@@ -469,17 +599,38 @@ 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; + 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 ( -
+
void load(true)}> - Retry - +
+ {readinessUnavailable || sourcesUnavailable || projectUnavailable ? null : canGenerate ? ( + + ) : ( + + )} + +
} />
@@ -490,7 +641,7 @@ export function FamiliarWorkQueueView({ familiars = [], onOpenUrl, embedded = fa const q = queue!; 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). */} @@ -652,7 +803,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 ? ( @@ -767,6 +923,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); 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-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..d4c68dc24 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"; @@ -23,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, @@ -918,16 +922,22 @@ 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", }, + { + 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]); @@ -1457,11 +1467,14 @@ export function OnboardingOverlay({ ) : null}
+ ) : step.key === "project" ? ( + void refresh()} /> ) : 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 ?? @@ -1525,6 +1538,112 @@ 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 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 { + ok?: boolean; + readiness?: QueueReadinessView; + error?: string; + }; + if (!response.ok || !body.ok || !body.readiness) { + throw new Error(body.error ?? "Couldn’t check the Queue project."); + } + if (generation === requestGeneration.current) { + setReadiness(body.readiness); + setError(null); + } + } catch (cause) { + if (generation === requestGeneration.current) { + setError(cause instanceof Error ? cause.message : "Couldn’t check the Queue project."); + } + } + }, []); + + useEffect(() => { + void refreshReadiness(); + }, [refreshReadiness]); + + const selectProject = async (projectId: string) => { + const generation = ++requestGeneration.current; + 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."); + } + if (generation !== requestGeneration.current) return; + setReadiness(body.readiness); + publishQueueProjectSelection(body.readiness.project); + onSelected(); + } catch (cause) { + if (generation === requestGeneration.current) { + setError(cause instanceof Error ? cause.message : "Couldn’t save the Queue project."); + } + } finally { + if (generation === requestGeneration.current) 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/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/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/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; } diff --git a/src/lib/queue-project-readiness.test.ts b/src/lib/queue-project-readiness.test.ts new file mode 100644 index 000000000..e48c76ab8 --- /dev/null +++ b/src/lib/queue-project-readiness.test.ts @@ -0,0 +1,230 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +// 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"); +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); + await mkdir(nonGitRoot); + 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 { + 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"); + + 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: "" }), + }); + 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"); + + await mkdir(path.join(projectRoot, ".beads")); + 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/); + + 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({ + beadsProbe: async () => ({ ok: true, stdout: "[]", stderr: "" }), + }); + 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; + 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"); + + 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( + 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({ + 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", + }], + }), + ); + 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/); + + 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", + ); + 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 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; + 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"); diff --git a/src/lib/queue-project-readiness.ts b/src/lib/queue-project-readiness.ts new file mode 100644 index 000000000..fa7fe5831 --- /dev/null +++ b/src/lib/queue-project-readiness.ts @@ -0,0 +1,377 @@ +import { execFile } from "node:child_process"; +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 { 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; + +type QueueProjectFile = { + version: 1; + projectId: string; +}; + +export type QueueProjectReadinessCode = + | "ready" + | "needs-beads" + | "no-project" + | "project-missing" + | "project-not-allowed" + | "not-git-repository" + | "git-unavailable" + | "git-error" + | "project-not-git-root" + | "project-storage-error" + | "beads-unavailable" + | "beads-error"; + +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; +}; + +type BeadsProbe = (repoRoot: string, beadsDir: string, args: string[]) => Promise; +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; 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.") { + 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 + // sidecar archive. + 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; + 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."); + } +} + +export async function selectQueueProject(projectId: string): Promise { + const project = projectById(projectId, await loadProjects()); + if (!project) return null; + const file = queueProjectFilePath(); + await mkdir(/* turbopackIgnore: true */ path.dirname(file), { recursive: true }); + await writeSelectedProjectId(file, project.id); + invalidateQueueProjectReadinessCache(); + return project; +} + +async function isDirectory(value: string): Promise { + try { + return (await stat(/* turbopackIgnore: true */ value)).isDirectory(); + } catch { + return false; + } +} + +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, + env: caveToolSpawnEnv(), + timeout: GIT_TIMEOUT_MS, + }); + const top = stdout.trim(); + 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 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", + message: "Git could not validate the Queue project. Check Git permissions and repository trust, then try again.", + }; + } +} + +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 { + await lstat(/* turbopackIgnore: true */ beadsDir); + } catch (cause) { + const error = cause as NodeJS.ErrnoException; + 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." }; + } + // 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"]); + if (result.ok) { + readyProbeCache.set(repoRoot, { expiresAt: Date.now() + READY_PROBE_TTL_MS, result }); + return { kind: "ready" }; + } + if (beadsUnavailable(result)) { + return { kind: "unavailable", message: "Beads is required to use the Queue project. Install or repair the bd CLI, 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. */ +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; +} + +/** Clear the short onboarding cache whenever selection or generation changes it. */ +export function invalidateQueueProjectReadinessCache(): void { + onboardingReadinessGeneration += 1; + cachedOnboardingReadiness = 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 generation = onboardingReadinessGeneration; + const inFlight = onboardingReadinessInFlight; + if (inFlight && inFlight.generation === generation && inFlight.probe === options.beadsProbe) return inFlight.pending; + const pending = queueProjectReadiness(options).then((readiness) => { + if (generation === onboardingReadinessGeneration) { + cachedOnboardingReadiness = { expiresAt: Date.now() + ONBOARDING_READINESS_TTL_MS, readiness, probe: options.beadsProbe }; + } + return readiness; + }).finally(() => { + if (onboardingReadinessInFlight?.pending === pending && onboardingReadinessInFlight.generation === generation) { + onboardingReadinessInFlight = null; + } + }); + onboardingReadinessInFlight = { probe: options.beadsProbe, generation, pending }; + return pending; +} + +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(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, + 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 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: "project-not-git-root", + message: `Choose the Git repository root for Queue work, not a subdirectory: ${gitRoot.root}.`, + project: selected, + canGenerate: false, + }; + } + + const beads = await beadsWorkspaceStatus(gitRoot.root, options.beadsProbe ?? runBdCommand); + if (beads.kind === "missing" || beads.kind === "repairable") { + return { + ok: false, + code: "needs-beads", + 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, + }; + } + 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", + message: "Queue project is ready.", + project: { ...selected, root: gitRoot.root }, + canGenerate: false, + }; +} 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/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", 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/beads-workspace.test.ts b/src/lib/server/beads-workspace.test.ts new file mode 100644 index 000000000..f37c2fb9c --- /dev/null +++ b/src/lib/server/beads-workspace.test.ts @@ -0,0 +1,28 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, realpath, rm, symlink } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +// 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"); + +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" }; + } +} 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 = 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 }); 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 68349cdb6..5710aaf7e 100644 --- a/tests/familiar-work-queue.spec.ts +++ b/tests/familiar-work-queue.spec.ts @@ -29,8 +29,20 @@ 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_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 }; -async function gotoWorkQueue(page: Page) { +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, onQueueRequest?: (request: import("@playwright/test").Request) => void) { await page.addInitScript(() => { window.localStorage.setItem("cave:onboarding:dismissed", "1"); window.localStorage.setItem("cave:active-familiar", "kitty"); @@ -50,10 +62,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 @@ -130,7 +148,159 @@ 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("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); + 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 }) => { + 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("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 { + 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({ + 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 }) => { @@ -148,7 +318,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,11 +358,93 @@ 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(); }); + 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: "Save 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" }); 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] } }), ); diff --git a/tests/onboarding-wizard.spec.ts b/tests/onboarding-wizard.spec.ts index d4e524abb..9218edc2d 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,12 +38,29 @@ 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" }, }, 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 @@ -56,6 +74,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" }, }, @@ -132,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 }); @@ -143,7 +171,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);