From c3a4f45bb3a1f9bf0f64f99f8f95bfc3e6cd2572 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Fri, 29 May 2026 09:20:09 +1000 Subject: [PATCH 1/3] test: isolate Playwright e2e harness --- e2e/setup/global-setup.ts | 14 +-- playwright.config.ts | 19 +++- src/db/pglite.ts | 234 ++++++++++++++++++++++++++++++++------ 3 files changed, 220 insertions(+), 47 deletions(-) diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 1c0019a7..2d6dc153 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -1,13 +1,12 @@ import { request } from "@playwright/test"; /** - * Playwright global setup: ensures a test user exists and the app is seeded. - * - * The dev server (PGlite) auto-seeds via `pnpm dev`, so we just need to - * create a test user account for authenticated tests. + * Playwright global setup: creates the first-user account. Individual tests + * opt into authentication with the login helpers so unauthenticated API + * coverage stays meaningful. */ async function globalSetup() { - const baseURL = process.env.BASE_URL ?? "http://localhost:3000"; + const baseURL = process.env.BASE_URL ?? "http://127.0.0.1:3100"; const api = await request.newContext({ baseURL }); // Wait for the app to be ready (healthcheck via auth status) @@ -26,7 +25,8 @@ async function globalSetup() { } if (!ready) throw new Error("App not ready after 20s"); - // Create a test user (first user gets super_admin role) + // Create a test user. The e2e web server starts with a freshly seeded DB, so + // this is the first user and receives the super_admin role. const signupRes = await api.post("/api/auth/signup", { data: { name: "E2E Test User", @@ -38,7 +38,7 @@ async function globalSetup() { // 200 = created, 409 = already exists — both fine if (!signupRes.ok() && signupRes.status() !== 409) { const body = await signupRes.text(); - console.warn(`[global-setup] Signup response ${signupRes.status()}: ${body}`); + throw new Error(`[global-setup] Signup response ${signupRes.status()}: ${body}`); } await api.dispose(); diff --git a/playwright.config.ts b/playwright.config.ts index 781b4d61..f370b897 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,9 @@ import { defineConfig } from "@playwright/test"; +const e2ePort = process.env.E2E_PORT ?? "3100"; +const e2eBaseURL = process.env.BASE_URL ?? `http://127.0.0.1:${e2ePort}`; +const e2eDataDir = process.env.CREWCMD_PGLITE_DATA_DIR ?? ".data/e2e-pglite"; + export default defineConfig({ testDir: "./e2e", fullyParallel: false, @@ -8,7 +12,7 @@ export default defineConfig({ workers: 1, reporter: process.env.CI ? "github" : "list", use: { - baseURL: "http://localhost:3000", + baseURL: e2eBaseURL, trace: "on-first-retry", }, projects: [ @@ -19,9 +23,16 @@ export default defineConfig({ ], globalSetup: "./e2e/setup/global-setup.ts", webServer: { - command: "pnpm dev", - url: "http://localhost:3000", - reuseExistingServer: !process.env.CI, + command: `rm -rf ${e2eDataDir} && (pnpm db:seed || test "$?" = "100") && pnpm dev`, + url: e2eBaseURL, + reuseExistingServer: false, timeout: 30_000, + env: { + ...process.env, + PORT: e2ePort, + BASE_URL: e2eBaseURL, + NEXT_PUBLIC_APP_URL: e2eBaseURL, + CREWCMD_PGLITE_DATA_DIR: e2eDataDir, + }, }, }); diff --git a/src/db/pglite.ts b/src/db/pglite.ts index 06920ac0..b22e3548 100644 --- a/src/db/pglite.ts +++ b/src/db/pglite.ts @@ -4,7 +4,10 @@ import { mkdirSync, readFileSync, readdirSync, existsSync } from "fs"; import path from "path"; import * as schema from "./schema"; -const dataDir = path.join(process.cwd(), ".data", "pglite"); +const configuredDataDir = process.env.CREWCMD_PGLITE_DATA_DIR; +const dataDir = configuredDataDir + ? path.resolve(process.cwd(), configuredDataDir) + : path.join(process.cwd(), ".data", "pglite"); const markerFile = path.join(dataDir, ".schema-applied"); // Ensure the data directory exists @@ -63,6 +66,161 @@ const queuedClient = new Proxy(client, { const pgliteDb = drizzle(queuedClient as PGlite, { schema }); +function splitSqlStatements(sql: string): string[] { + const statements: string[] = []; + let start = 0; + let dollarQuote: string | null = null; + let inSingleQuote = false; + let inDoubleQuote = false; + let inLineComment = false; + let inBlockComment = false; + + for (let i = 0; i < sql.length; i++) { + const char = sql[i]; + const next = sql[i + 1]; + + if (inLineComment) { + if (char === "\n") inLineComment = false; + continue; + } + + if (inBlockComment) { + if (char === "*" && next === "/") { + inBlockComment = false; + i++; + } + continue; + } + + if (dollarQuote) { + if (sql.startsWith(dollarQuote, i)) { + i += dollarQuote.length - 1; + dollarQuote = null; + } + continue; + } + + if (inSingleQuote) { + if (char === "'" && next === "'") { + i++; + } else if (char === "'") { + inSingleQuote = false; + } + continue; + } + + if (inDoubleQuote) { + if (char === '"') inDoubleQuote = false; + continue; + } + + if (char === "-" && next === "-") { + inLineComment = true; + i++; + continue; + } + + if (char === "/" && next === "*") { + inBlockComment = true; + i++; + continue; + } + + if (char === "'") { + inSingleQuote = true; + continue; + } + + if (char === '"') { + inDoubleQuote = true; + continue; + } + + if (char === "$") { + const match = sql.slice(i).match(/^\$[A-Za-z0-9_]*\$/); + if (match) { + dollarQuote = match[0]; + i += dollarQuote.length - 1; + continue; + } + } + + if (char === ";") { + const statement = sql.slice(start, i + 1).trim(); + if (statement) statements.push(statement); + start = i + 1; + } + } + + const tail = sql.slice(start).trim(); + if (tail) statements.push(tail); + + return statements; +} + +const incrementalAlters = [ + `ALTER TABLE users ADD COLUMN IF NOT EXISTS name text`, + `ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash text`, + `ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url text`, + `ALTER TABLE agents ADD COLUMN IF NOT EXISTS company_id uuid`, + `ALTER TABLE agents ADD COLUMN IF NOT EXISTS adapter_type text NOT NULL DEFAULT 'openclaw_gateway'`, + `ALTER TABLE agents ADD COLUMN IF NOT EXISTS adapter_config jsonb DEFAULT '{}'`, + `ALTER TABLE agents ADD COLUMN IF NOT EXISTS role text DEFAULT 'engineer'`, + `ALTER TABLE agents ADD COLUMN IF NOT EXISTS model text`, + `ALTER TABLE agents ADD COLUMN IF NOT EXISTS workspace_path text`, + `ALTER TABLE agents ADD COLUMN IF NOT EXISTS runtime_config JSONB DEFAULT '{}'`, + `ALTER TABLE agents ADD COLUMN IF NOT EXISTS canvas_position JSONB`, + `ALTER TABLE agents ADD COLUMN IF NOT EXISTS runtime_id UUID`, + `ALTER TABLE agents ADD COLUMN IF NOT EXISTS runtime_ref TEXT`, + `ALTER TABLE agents ADD COLUMN IF NOT EXISTS owner_type ownership_type NOT NULL DEFAULT 'user'`, + `ALTER TABLE agents ADD COLUMN IF NOT EXISTS owner_user_id UUID`, + `ALTER TABLE agents ADD COLUMN IF NOT EXISTS owner_company_id UUID`, + `ALTER TABLE agents ADD COLUMN IF NOT EXISTS visibility agent_visibility NOT NULL DEFAULT 'private'`, + `ALTER TABLE agents ADD COLUMN IF NOT EXISTS avatar_url text`, + `ALTER TABLE projects ADD COLUMN IF NOT EXISTS url text`, + `ALTER TABLE projects ADD COLUMN IF NOT EXISTS folder text`, + `ALTER TABLE projects ADD COLUMN IF NOT EXISTS workspace_id uuid`, + `ALTER TABLE projects ADD COLUMN IF NOT EXISTS company_id uuid`, + `ALTER TABLE projects ADD COLUMN IF NOT EXISTS goal_id uuid`, + `ALTER TABLE tasks ADD COLUMN IF NOT EXISTS workspace_id uuid`, + `ALTER TABLE tasks ADD COLUMN IF NOT EXISTS company_id uuid`, + `ALTER TABLE tasks ADD COLUMN IF NOT EXISTS goal_id uuid`, + `ALTER TABLE tasks ADD COLUMN IF NOT EXISTS images jsonb DEFAULT '[]'::jsonb NOT NULL`, + `ALTER TABLE activity_log ADD COLUMN IF NOT EXISTS workspace_id uuid`, + `ALTER TABLE activity_log ADD COLUMN IF NOT EXISTS company_id uuid`, + `ALTER TABLE docs ADD COLUMN IF NOT EXISTS doc_type doc_type DEFAULT 'general' NOT NULL`, + `ALTER TABLE docs ADD COLUMN IF NOT EXISTS visibility doc_visibility DEFAULT 'company' NOT NULL`, + `ALTER TABLE docs ADD COLUMN IF NOT EXISTS author_user_id uuid`, + `ALTER TABLE docs ADD COLUMN IF NOT EXISTS workspace_id uuid`, + `ALTER TABLE docs ADD COLUMN IF NOT EXISTS company_id uuid`, + `ALTER TABLE docs ADD COLUMN IF NOT EXISTS pinned boolean DEFAULT false NOT NULL`, + `ALTER TABLE chat_sessions ADD COLUMN IF NOT EXISTS gateway_session_key TEXT`, + `ALTER TABLE chat_sessions ADD COLUMN IF NOT EXISTS workspace_id uuid`, + `ALTER TABLE chat_sessions ADD COLUMN IF NOT EXISTS channel_id uuid`, + `ALTER TABLE company_runtimes ADD COLUMN IF NOT EXISTS owner_type ownership_type NOT NULL DEFAULT 'company'`, + `ALTER TABLE company_runtimes ADD COLUMN IF NOT EXISTS owner_user_id UUID`, + `ALTER TABLE company_runtimes ADD COLUMN IF NOT EXISTS owner_company_id UUID`, + `UPDATE company_runtimes SET owner_company_id = company_id WHERE owner_company_id IS NULL`, + `ALTER TABLE org_chart_nodes ADD COLUMN IF NOT EXISTS workspace_id uuid`, + `ALTER TABLE skills ADD COLUMN IF NOT EXISTS workspace_id uuid`, + `ALTER TABLE inbox_messages ADD COLUMN IF NOT EXISTS workspace_id uuid`, + `ALTER TABLE chat_threads ADD COLUMN IF NOT EXISTS workspace_id uuid`, + `ALTER TABLE chat_threads ADD COLUMN IF NOT EXISTS parent_session_id uuid`, + `ALTER TABLE chat_threads ADD COLUMN IF NOT EXISTS parent_thread_id uuid`, + `ALTER TABLE chat_threads ADD COLUMN IF NOT EXISTS parent_fingerprint text`, + `ALTER TABLE chat_threads ADD COLUMN IF NOT EXISTS channel_id uuid`, +]; + +async function runIncrementalAlters() { + for (const stmt of incrementalAlters) { + try { + await queuedClient.exec(stmt); + } catch { + // Safe to ignore: the target table/type may not exist yet on fresh setup. + } + } +} + /** * Apply full schema from schema.ts via raw SQL generated from all migration files. * Since migration 0000 is a no-op baseline, we use drizzle-kit push equivalent: @@ -70,33 +228,7 @@ const pgliteDb = drizzle(queuedClient as PGlite, { schema }); */ async function applySchema() { // Always run incremental migrations for new columns - const incrementalAlters = [ - `ALTER TABLE agents ADD COLUMN IF NOT EXISTS adapter_type text NOT NULL DEFAULT 'openclaw_gateway'`, - `ALTER TABLE agents ADD COLUMN IF NOT EXISTS adapter_config jsonb DEFAULT '{}'`, - `ALTER TABLE agents ADD COLUMN IF NOT EXISTS role text DEFAULT 'engineer'`, - `ALTER TABLE agents ADD COLUMN IF NOT EXISTS model text`, - `ALTER TABLE agents ADD COLUMN IF NOT EXISTS workspace_path text`, - `ALTER TABLE agents ADD COLUMN IF NOT EXISTS runtime_config JSONB DEFAULT '{}'`, - `ALTER TABLE agents ADD COLUMN IF NOT EXISTS canvas_position JSONB`, - `ALTER TABLE agents ADD COLUMN IF NOT EXISTS runtime_id UUID`, - `ALTER TABLE agents ADD COLUMN IF NOT EXISTS runtime_ref TEXT`, - `ALTER TABLE agents ADD COLUMN IF NOT EXISTS owner_type TEXT NOT NULL DEFAULT 'user'`, - `ALTER TABLE agents ADD COLUMN IF NOT EXISTS owner_user_id UUID`, - `ALTER TABLE chat_sessions ADD COLUMN IF NOT EXISTS gateway_session_key TEXT`, - `ALTER TABLE agents ADD COLUMN IF NOT EXISTS owner_company_id UUID`, - `ALTER TABLE agents ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'private'`, - `ALTER TABLE company_runtimes ADD COLUMN IF NOT EXISTS owner_type TEXT NOT NULL DEFAULT 'company'`, - `ALTER TABLE company_runtimes ADD COLUMN IF NOT EXISTS owner_user_id UUID`, - `ALTER TABLE company_runtimes ADD COLUMN IF NOT EXISTS owner_company_id UUID`, - `UPDATE company_runtimes SET owner_company_id = company_id WHERE owner_company_id IS NULL`, - ]; - for (const stmt of incrementalAlters) { - try { - await queuedClient.exec(stmt); - } catch { - // Safe to ignore — column may already exist - } - } + await runIncrementalAlters(); // System settings table (zero-config startup) try { @@ -434,35 +566,52 @@ async function applySchema() { console.log("[CrewCmd] PGlite: applying schema from scratch..."); - // Read all migration SQL files in order and extract CREATE statements + // Read all migration SQL files in order and extract schema statements. const migrationsDir = path.join(process.cwd(), "drizzle"); const sqlFiles = readdirSync(migrationsDir) .filter((f) => f.endsWith(".sql")) .sort(); - // Collect all CREATE TYPE and CREATE TABLE statements across all migrations + // Collect statements by phase. Some migrations are multi-statement files + // without Drizzle breakpoints, so split on SQL semicolons while respecting + // dollar-quoted DO blocks. + const typeStatements: string[] = []; const createStatements: string[] = []; const alterStatements: string[] = []; + const indexStatements: string[] = []; for (const file of sqlFiles) { const sql = readFileSync(path.join(migrationsDir, file), "utf-8"); - const statements = sql - .split("--> statement-breakpoint") - .map((s) => s.trim()) - .filter(Boolean); + const statements = splitSqlStatements(sql); for (const stmt of statements) { // Strip leading SQL comments to detect statement type const stripped = stmt.replace(/^--.*\n?/gm, "").trim(); - if (stripped.startsWith("CREATE")) { + if (stripped.startsWith("CREATE TYPE") || (stripped.startsWith("DO $$") && stripped.includes("CREATE TYPE"))) { + typeStatements.push(stmt); + } else if (stripped.startsWith("CREATE TABLE")) { createStatements.push(stmt); + } else if (stripped.startsWith("CREATE INDEX") || stripped.startsWith("CREATE UNIQUE INDEX")) { + indexStatements.push(stmt); } else if (stripped.startsWith("ALTER") || stripped.startsWith("DO $$")) { alterStatements.push(stmt); } } } - // Execute CREATEs first (types, then tables), then ALTERs + // Execute types first, then tables, then ALTERs, then indexes that may rely + // on columns introduced by ALTER statements. + for (const stmt of typeStatements) { + try { + await queuedClient.exec(stmt); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes("already exists")) { + console.warn("[CrewCmd] PGlite schema warning:", msg.slice(0, 120)); + } + } + } + for (const stmt of createStatements) { try { await queuedClient.exec(stmt); @@ -484,6 +633,19 @@ async function applySchema() { } } + await runIncrementalAlters(); + + for (const stmt of indexStatements) { + try { + await queuedClient.exec(stmt); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes("already exists")) { + console.warn("[CrewCmd] PGlite schema warning:", msg.slice(0, 120)); + } + } + } + // Mark schema as applied const { writeFileSync } = await import("fs"); writeFileSync(markerFile, new Date().toISOString()); From e12581850e13712b194eb4664a40bf2946660115 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Fri, 29 May 2026 09:20:19 +1000 Subject: [PATCH 2/3] fix: return stable API conflict responses --- src/app/api/agents/route.ts | 24 +++++++++++++++++++++--- src/app/api/tasks/route.ts | 2 +- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/app/api/agents/route.ts b/src/app/api/agents/route.ts index c9914a89..e8e4f31c 100644 --- a/src/app/api/agents/route.ts +++ b/src/app/api/agents/route.ts @@ -26,6 +26,24 @@ import { export const dynamic = "force-dynamic"; +function isUniqueViolation(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const cause = error instanceof Error ? error.cause : null; + const causeRecord = cause && typeof cause === "object" + ? cause as Record + : {}; + const causeMessage = cause instanceof Error ? cause.message : String(cause ?? ""); + + return ( + message.includes("unique") || + message.includes("duplicate") || + causeMessage.includes("unique") || + causeMessage.includes("duplicate") || + causeRecord.code === "23505" || + causeRecord.constraint === "agents_callsign_unique" + ); +} + export async function GET(request: NextRequest) { if (!db) { return NextResponse.json({ agents: [], source: "none" }); @@ -268,11 +286,11 @@ export async function POST(request: NextRequest) { return NextResponse.json(created, { status: 201 }); } catch (err) { - console.error("[api/agents] POST Error:", err); - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes("unique") || msg.includes("duplicate")) { + if (isUniqueViolation(err)) { return NextResponse.json({ error: "An agent with that callsign already exists" }, { status: 409 }); } + console.error("[api/agents] POST Error:", err); + const msg = err instanceof Error ? err.message : String(err); if (err instanceof Error && err.name === "PolicyViolation") { return NextResponse.json({ error: msg }, { status: 403 }); } diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 939343d9..1834aa97 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -135,7 +135,7 @@ export async function POST(request: NextRequest) { ) ); if (existing) { - return NextResponse.json({ existing }, { status: 409 }); + return NextResponse.json({ existing, existingTask: existing }, { status: 409 }); } } From 06df9f1f9123e758c3145d16ca5113fe51a15312 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Fri, 29 May 2026 09:20:31 +1000 Subject: [PATCH 3/3] fix: allow task assignment by callsign --- src/app/api/tasks/[id]/route.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index f4b8fa06..0ee77d3a 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { db } from "@/db"; import * as schema from "@/db/schema"; import { requireAuth } from "@/lib/require-auth"; @@ -31,6 +31,23 @@ function getOperatingRolePack(agent: typeof schema.agents.$inferSelect | undefin return typeof rolePack === "string" ? (rolePack as CrewCmdRolePack) : null; } +function isUuid(value: string) { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + +async function resolveAssignedAgent(agentRef: string) { + const whereClause = isUuid(agentRef) + ? eq(schema.agents.id, agentRef) + : sql`lower(${schema.agents.callsign}) = ${agentRef.toLowerCase()}`; + + const [agent] = await db! + .select() + .from(schema.agents) + .where(whereClause) + .limit(1); + return agent ?? null; +} + export async function GET( _request: NextRequest, { params }: RouteParams @@ -119,9 +136,9 @@ export async function PATCH( } const nextAssignedAgentId = body.assignedAgentId ?? oldTask.assignedAgentId ?? null; - const [assignedAgent] = nextAssignedAgentId - ? await db.select().from(schema.agents).where(eq(schema.agents.id, nextAssignedAgentId)).limit(1) - : [null]; + const assignedAgent = nextAssignedAgentId + ? await resolveAssignedAgent(nextAssignedAgentId) + : null; const rolePack = getOperatingRolePack(assignedAgent ?? undefined); const nextPrUrl = body.prUrl ?? oldTask.prUrl ?? null; @@ -131,7 +148,7 @@ export async function PATCH( (targetStatus === "review" || targetStatus === "done") && nextAssignedAgentId ) { - const validationResult = await verifyTaskCompletion(id, nextAssignedAgentId); + const validationResult = await verifyTaskCompletion(id, assignedAgent?.id ?? null); if (!validationResult.valid) { const rejection = evaluateSupervisorRejection(validationResult, targetStatus); if (rejection.rejected) {