Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2e24586
add queue project readiness API
CompleteDotTech Jul 24, 2026
1779c88
scope queue requests to selected project
CompleteDotTech Jul 24, 2026
b443dc6
require a queue project during onboarding
CompleteDotTech Jul 24, 2026
1151f70
cover queue project readiness
CompleteDotTech Jul 24, 2026
40b99f6
type onboarding queue status steps
CompleteDotTech Jul 24, 2026
8cdee85
load queue readiness test aliases
CompleteDotTech Jul 24, 2026
98b8264
exclude queue paths from standalone tracing
CompleteDotTech Jul 24, 2026
91410b8
ignore queue readiness runtime file access
CompleteDotTech Jul 24, 2026
96405a5
raise sidecar closure budget for queue readiness
CompleteDotTech Jul 24, 2026
11596ed
fix(queue): scope operations to selected project
CompleteDotTech Jul 24, 2026
c8fa34d
fix(board): stop invalidating removed queue cache
CompleteDotTech Jul 24, 2026
e93d41c
fix(queue): bind and validate project readiness
CompleteDotTech Jul 24, 2026
cb90979
test(onboarding): model Queue project readiness
CompleteDotTech Jul 24, 2026
7b3d7e6
test(onboarding): preserve required project gate
CompleteDotTech Jul 24, 2026
4026406
fix(queue): harden project readiness
CompleteDotTech Jul 24, 2026
7ad56d1
fix(queue): clarify source recovery
CompleteDotTech Jul 24, 2026
d449c28
fix(queue): distinguish Beads recovery states
CompleteDotTech Jul 24, 2026
c97ee0b
fix(onboarding): ignore stale project selections
CompleteDotTech Jul 24, 2026
116c2b7
test(queue): cover selected root reads
CompleteDotTech Jul 24, 2026
bacc880
fix(queue): harden Beads recovery
CompleteDotTech Jul 24, 2026
d8139e3
fix(backup): preserve Queue project selection
CompleteDotTech Jul 24, 2026
45f2f58
fix(queue): isolate failed project switches
CompleteDotTech Jul 24, 2026
6933209
test(queue): execute Generate safeguards
CompleteDotTech Jul 24, 2026
c383239
test(queue): assert detail root scope
CompleteDotTech Jul 24, 2026
2d263f2
fix(queue): preserve project isolation in E2E
CompleteDotTech Jul 24, 2026
532ba78
test(queue): execute scoped Beads routes
CompleteDotTech Jul 24, 2026
482c80c
fix(e2e): satisfy the Queue project step in the daemon-less harness
BunsDev Jul 24, 2026
bdf5c5a
test(queue): align contracts with redesigned controls
CompleteDotTech Jul 24, 2026
c8f42b1
fix(queue): verify Beads recovery readiness
CompleteDotTech Jul 24, 2026
11e8928
test(e2e): mock onboarding status in the mobile code-rail spec
BunsDev Jul 24, 2026
32ee3f9
fix(queue): reset Asana filing per project
CompleteDotTech Jul 24, 2026
71c943a
fix(onboarding): require Git for Queue projects
CompleteDotTech Jul 24, 2026
93ddaf3
fix(queue): honor launch PATH for project tools
CompleteDotTech Jul 24, 2026
01e5107
fix(onboarding): present Git before Queue selection
CompleteDotTech Jul 24, 2026
df939a4
fix(queue): synchronize project changes across windows
CompleteDotTech Jul 24, 2026
f564ec0
test(queue): stabilize cross-window selection coverage
CompleteDotTech Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/;

Expand All @@ -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",
Expand Down Expand Up @@ -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`),
},
Expand Down
10 changes: 10 additions & 0 deletions scripts/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions scripts/sidecar-bundle-deps.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down
5 changes: 4 additions & 1 deletion scripts/sidecar-runtime-closure.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
4 changes: 2 additions & 2 deletions scripts/sidecar-runtime-closure.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
2 changes: 1 addition & 1 deletion scripts/sidecar-runtime-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/sidecar_archive_manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
1 change: 1 addition & 0 deletions src/app/api/api-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
10 changes: 6 additions & 4 deletions src/app/api/beads/prs/route.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -36,7 +36,7 @@ const MERGED_PR_LIMIT = "30";
async function ghPrList(repoRoot: string, args: string[]): Promise<unknown> {
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,
});
Expand All @@ -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 });
Expand Down
126 changes: 126 additions & 0 deletions src/app/api/beads/route.test.ts
Original file line number Diff line number Diff line change
@@ -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");
26 changes: 12 additions & 14 deletions src/app/api/beads/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 }) {
Expand Down Expand Up @@ -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 },
Expand Down
Loading
Loading