diff --git a/.gitignore b/.gitignore index 766f911..186a48f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,30 @@ -node_modules/ -dist/ -coverage/ -.turbo/ .DS_Store .env .env.local .env.*.local *.log -*.tsbuildinfo data/ !.gitkeep + +# Python +__pycache__/ +*.py[cod] +*$py.class +.venv/ +.python-version +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +htmlcov/ + +# Build / IDE +dist/ +build/ +.idea/ +.vscode/ + +# SQLite local +*.sqlite3 +*.sqlite3-journal diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 2bd5a0a..0000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -22 diff --git a/apps/.gitkeep b/apps/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/apps/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/api/package.json b/apps/api/package.json deleted file mode 100644 index 2c90752..0000000 --- a/apps/api/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@devflow/api", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "tsup src/index.ts --format esm --clean", - "typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit", - "test": "cd ../.. && vitest run --project apps/api" - }, - "dependencies": { - "@devflow/core": "workspace:*", - "@devflow/db": "workspace:*", - "@devflow/run-engine": "workspace:*", - "@devflow/session": "workspace:*", - "@devflow/workflows": "workspace:*", - "@fastify/sensible": "6", - "@temporalio/client": "^1.17.1", - "fastify": "5" - } -} diff --git a/apps/api/src/http.test.ts b/apps/api/src/http.test.ts deleted file mode 100644 index 262bb60..0000000 --- a/apps/api/src/http.test.ts +++ /dev/null @@ -1,952 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { mkdtempSync, realpathSync, rmSync } from "node:fs"; -import { get } from "node:http"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import type { ApprovalDecisionAction } from "@devflow/core"; -import { - type DbClient, - RunEventRepository, - agentPersonas, - approvalDecisions, - approvalRequests, - createDbClient, - runInputs, - runs, - tuiSessions, - workflowTemplates, -} from "@devflow/db"; -import type { RunEngine, RunStartInput, RunStatus } from "@devflow/run-engine"; -import { and, eq, inArray } from "drizzle-orm"; -import { afterEach, describe, expect, it } from "vitest"; - -import { createHttpApi } from "./http.js"; -import { formatSseMessage, runEventMessages } from "./sse.js"; - -const databaseUrl = - process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow"; - -class RecordingEngine implements RunEngine { - readonly approvalSignals: Array<{ - action: ApprovalDecisionAction; - approvalRequestId: string; - clientToken: string; - comment?: string; - runId: string; - }> = []; - readonly startedRuns: RunStartInput[] = []; - - constructor( - protected readonly db: DbClient["db"], - private readonly runId = randomUUID(), - ) {} - - async startRun(input: RunStartInput): Promise<{ runId: string }> { - this.startedRuns.push(input); - return { runId: this.runId }; - } - - async signalApproval( - runId: string, - approvalRequestId: string, - action: ApprovalDecisionAction, - clientToken: string, - comment?: string, - ): Promise { - this.approvalSignals.push({ - action, - approvalRequestId, - clientToken, - ...(comment === undefined ? {} : { comment }), - runId, - }); - } - - async pauseRun(_runId: string): Promise { - return; - } - - async resumeRun(_runId: string): Promise { - return; - } - - async abortRun(_runId: string, _reason: string): Promise { - return; - } - - async getStatus(runId: string): Promise { - const { readRunStatus } = await import("@devflow/run-engine"); - return readRunStatus(this.db, runId); - } -} - -class DecisionRecordingEngine extends RecordingEngine { - override async signalApproval( - runId: string, - approvalRequestId: string, - action: ApprovalDecisionAction, - clientToken: string, - comment?: string, - ): Promise { - await new Promise((resolve) => setTimeout(resolve, 25)); - await super.signalApproval(runId, approvalRequestId, action, clientToken, comment); - const idempotencyKey = `${approvalRequestId}:${action}:${clientToken}`; - const [existing] = await this.db - .select({ id: approvalDecisions.id }) - .from(approvalDecisions) - .where(eq(approvalDecisions.idempotencyKey, idempotencyKey)) - .limit(1); - if (existing !== undefined) { - return; - } - await this.db.insert(approvalDecisions).values({ - approvalRequestId, - action, - comment, - idempotencyKey, - }); - } -} - -describe("HTTP API", () => { - let client: DbClient | undefined; - const runIds: string[] = []; - const templateIds: string[] = []; - const personaIds: string[] = []; - const tempRoots: string[] = []; - - afterEach(async () => { - if (client !== undefined) { - if (runIds.length > 0) { - const requests = await client.db - .select({ id: approvalRequests.id }) - .from(approvalRequests) - .where(inArray(approvalRequests.runId, [...runIds])); - if (requests.length > 0) { - await client.db.delete(approvalDecisions).where( - inArray( - approvalDecisions.approvalRequestId, - requests.map((request) => request.id), - ), - ); - } - await client.db - .delete(approvalRequests) - .where(inArray(approvalRequests.runId, [...runIds])); - await client.db.delete(runs).where(inArray(runs.id, [...runIds])); - } - if (personaIds.length > 0) { - await client.db.delete(agentPersonas).where(inArray(agentPersonas.id, [...personaIds])); - } - if (templateIds.length > 0) { - await client.db - .delete(workflowTemplates) - .where(inArray(workflowTemplates.id, [...templateIds])); - } - await client.close(); - client = undefined; - } - for (const root of tempRoots.splice(0)) { - rmSync(root, { recursive: true, force: true }); - } - runIds.length = 0; - templateIds.length = 0; - personaIds.length = 0; - }); - - it("exposes templates, personas, run creation, and approval signaling routes", async () => { - client = createDbClient(databaseUrl); - const templateId = await insertTemplate(client, templateIds); - const personaId = await insertPersona(client, personaIds); - const engine = new RecordingEngine(client.db, "00000000-0000-4000-8000-000000000701"); - const approvalRunId = "00000000-0000-4000-8000-000000000703"; - const approvalRequestId = "00000000-0000-4000-8000-000000000704"; - const app = await createHttpApi({ db: client.db, engine }); - try { - const templates = await app.inject({ method: "GET", url: "/api/templates" }); - expect(templates.statusCode).toBe(200); - expect((templates.json() as { templates: unknown[] }).templates).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: templateId, name: "http-template", version: 1 }), - ]), - ); - - const personas = await app.inject({ method: "GET", url: "/api/personas" }); - expect(personas.statusCode).toBe(200); - expect((personas.json() as { personas: unknown[] }).personas).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: personaId, name: "http-persona", version: 1 }), - ]), - ); - - const created = await app.inject({ - method: "POST", - url: "/api/runs", - payload: { - baseBranch: "main", - repoPath: "/tmp/repo", - requirementsMd: "Build the thing.", - templateName: "development", - templateVersion: 1, - }, - }); - expect(created.statusCode).toBe(201); - expect(created.json()).toEqual({ runId: "00000000-0000-4000-8000-000000000701" }); - expect(engine.startedRuns).toMatchObject([ - { - baseBranch: "main", - repoPath: "/tmp/repo", - requirementsMd: "Build the thing.", - templateName: "development", - templateVersion: 1, - }, - ]); - - const approval = await app.inject({ - method: "POST", - url: `/api/runs/${approvalRunId}/approvals/${approvalRequestId}`, - payload: { - action: "approve", - clientToken: "00000000-0000-4000-8000-000000000702", - comment: "ship", - }, - }); - expect(approval.statusCode).toBe(201); - expect(engine.approvalSignals).toEqual([ - { - action: "approve", - approvalRequestId, - clientToken: "00000000-0000-4000-8000-000000000702", - comment: "ship", - runId: approvalRunId, - }, - ]); - - const missingToken = await app.inject({ - method: "POST", - url: `/api/runs/${approvalRunId}/approvals/${approvalRequestId}`, - payload: { action: "approve" }, - }); - expect(missingToken.statusCode).toBe(400); - } finally { - await app.close(); - } - }); - - it("streams run events over native SSE with run-event replay", async () => { - client = createDbClient(databaseUrl); - const templateId = await insertTemplate(client, templateIds); - const runId = randomUUID(); - runIds.push(runId); - const root = realpathSync(mkdtempSync(join(tmpdir(), "devflow-http-sse-"))); - tempRoots.push(root); - await client.db.insert(runs).values({ - id: runId, - templateId, - templateHash: "a".repeat(64), - state: "created", - repoPath: root, - baseBranch: "main", - worktreeRoot: root, - }); - await client.db.insert(runInputs).values({ - runId, - requirementsMd: "SSE replay", - inputHash: "b".repeat(64), - }); - await new RunEventRepository(client.db).append({ - runId, - type: "run.created", - payload: { runId }, - idempotencyKey: `run.created:${runId}`, - }); - - const app = await createHttpApi({ - db: client.db, - engine: new RecordingEngine(client.db), - heartbeatMs: 1000, - pollMs: 10, - }); - await app.listen({ host: "127.0.0.1", port: 0 }); - const address = app.server.address(); - if (address === null || typeof address === "string") { - throw new Error("HTTP server did not expose a TCP address"); - } - - try { - const body = await readSseUntil( - `http://127.0.0.1:${address.port}/sse/runs/${runId}`, - "run.event_appended", - ); - expect(body).toContain("event: run.event_appended"); - expect(body).toContain('"type":"run.created"'); - } finally { - await app.close(); - } - }); - - it("streams only global-scope derived events on fresh global SSE connect", async () => { - client = createDbClient(databaseUrl); - const templateId = await insertTemplate(client, templateIds); - const runId = randomUUID(); - runIds.push(runId); - const root = realpathSync(mkdtempSync(join(tmpdir(), "devflow-http-global-sse-"))); - tempRoots.push(root); - await client.db.insert(runs).values({ - id: runId, - templateId, - templateHash: "1".repeat(64), - state: "created", - repoPath: root, - baseBranch: "main", - worktreeRoot: root, - }); - await client.db.insert(runInputs).values({ - runId, - requirementsMd: "Global SSE", - inputHash: "2".repeat(64), - }); - const events = new RunEventRepository(client.db); - await events.append({ - runId, - type: "run.created", - payload: { runId }, - idempotencyKey: `run.created:${runId}`, - }); - - const app = await createHttpApi({ - db: client.db, - engine: new RecordingEngine(client.db), - heartbeatMs: 1000, - pollMs: 10, - }); - await app.listen({ host: "127.0.0.1", port: 0 }); - const address = app.server.address(); - if (address === null || typeof address === "string") { - throw new Error("HTTP server did not expose a TCP address"); - } - - try { - const body = await readSseUntil( - `http://127.0.0.1:${address.port}/sse/global`, - '"next":"bound"', - async () => { - await events.append({ - runId, - type: "session.ready", - payload: { - sessionId: "00000000-0000-4000-8000-000000000705", - roleId: "builder", - recoveryAttempts: 0, - }, - idempotencyKey: "session.ready:00000000-0000-4000-8000-000000000705:0", - }); - await events.append({ - runId, - type: "run.started", - payload: { templateHash: "1".repeat(64) }, - idempotencyKey: `run.started:${runId}`, - }); - }, - ); - expect(body).toContain("id:"); - expect(body).toContain('"next":"bound"'); - expect(body).not.toContain('"next":"created"'); - expect(body).not.toContain("event: session.state_changed"); - } finally { - await app.close(); - } - }); - - it("replays missed global SSE events from Last-Event-ID", async () => { - client = createDbClient(databaseUrl); - const templateId = await insertTemplate(client, templateIds); - const runId = randomUUID(); - runIds.push(runId); - const root = realpathSync(mkdtempSync(join(tmpdir(), "devflow-http-global-replay-"))); - tempRoots.push(root); - await client.db.insert(runs).values({ - id: runId, - templateId, - templateHash: "3".repeat(64), - state: "created", - repoPath: root, - baseBranch: "main", - worktreeRoot: root, - }); - await client.db.insert(runInputs).values({ - runId, - requirementsMd: "Global SSE replay", - inputHash: "4".repeat(64), - }); - const events = new RunEventRepository(client.db); - const created = await events.append({ - runId, - type: "run.created", - payload: { runId }, - idempotencyKey: `run.created:${runId}`, - }); - await events.append({ - runId, - type: "run.started", - payload: { templateHash: "3".repeat(64) }, - idempotencyKey: `run.started:${runId}`, - }); - - const app = await createHttpApi({ - db: client.db, - engine: new RecordingEngine(client.db), - heartbeatMs: 1000, - pollMs: 10, - }); - await app.listen({ host: "127.0.0.1", port: 0 }); - const address = app.server.address(); - if (address === null || typeof address === "string") { - throw new Error("HTTP server did not expose a TCP address"); - } - - try { - const body = await readSseUntil( - `http://127.0.0.1:${address.port}/sse/global`, - '"next":"bound"', - undefined, - { "Last-Event-ID": created.id.toString() }, - ); - expect(body).toContain('"next":"bound"'); - expect(body).not.toContain('"next":"created"'); - expect(body).not.toContain("event: run.event_appended"); - } finally { - await app.close(); - } - }); - - it("returns 200 for approval decision replay", async () => { - client = createDbClient(databaseUrl); - const templateId = await insertTemplate(client, templateIds); - const runId = randomUUID(); - const approvalRequestId = randomUUID(); - const clientToken = randomUUID(); - runIds.push(runId); - const root = realpathSync(mkdtempSync(join(tmpdir(), "devflow-http-approval-"))); - tempRoots.push(root); - await client.db.insert(runs).values({ - id: runId, - templateId, - templateHash: "e".repeat(64), - state: "awaiting_approval", - repoPath: root, - baseBranch: "main", - worktreeRoot: root, - }); - await client.db.insert(runInputs).values({ - runId, - requirementsMd: "Approval replay", - inputHash: "f".repeat(64), - }); - await client.db.insert(approvalRequests).values({ - id: approvalRequestId, - runId, - gateKey: "spec_approved", - state: "approved", - idempotencyKey: `${runId}:spec_approved::1`, - payload: { replay: true }, - }); - await client.db.insert(approvalDecisions).values({ - approvalRequestId, - action: "approve", - idempotencyKey: `${approvalRequestId}:approve:${clientToken}`, - }); - - const engine = new RecordingEngine(client.db); - const app = await createHttpApi({ db: client.db, engine }); - try { - const response = await app.inject({ - method: "POST", - url: `/api/runs/${runId}/approvals/${approvalRequestId}`, - payload: { action: "approve", clientToken }, - }); - expect(response.statusCode).toBe(200); - expect(engine.approvalSignals).toMatchObject([{ action: "approve", approvalRequestId }]); - } finally { - await app.close(); - } - }); - - it("serializes same-token approval responses so exactly one request reports created", async () => { - client = createDbClient(databaseUrl); - const templateId = await insertTemplate(client, templateIds); - const runId = randomUUID(); - const approvalRequestId = randomUUID(); - const clientToken = randomUUID(); - runIds.push(runId); - const root = realpathSync(mkdtempSync(join(tmpdir(), "devflow-http-approval-race-"))); - tempRoots.push(root); - await client.db.insert(runs).values({ - id: runId, - templateId, - templateHash: "7".repeat(64), - state: "awaiting_approval", - repoPath: root, - baseBranch: "main", - worktreeRoot: root, - }); - await client.db.insert(runInputs).values({ - runId, - requirementsMd: "Approval race", - inputHash: "8".repeat(64), - }); - await client.db.insert(approvalRequests).values({ - id: approvalRequestId, - runId, - gateKey: "spec_approved", - state: "pending", - idempotencyKey: `${runId}:spec_approved::1`, - payload: { replay: false }, - }); - - const engine = new DecisionRecordingEngine(client.db); - const app = await createHttpApi({ db: client.db, engine }); - const secondApp = await createHttpApi({ db: client.db, engine }); - try { - const responses = await Promise.all([ - app.inject({ - method: "POST", - url: `/api/runs/${runId}/approvals/${approvalRequestId}`, - payload: { action: "approve", clientToken }, - }), - secondApp.inject({ - method: "POST", - url: `/api/runs/${runId}/approvals/${approvalRequestId}`, - payload: { action: "approve", clientToken }, - }), - ]); - expect(responses.map((response) => response.statusCode).sort()).toEqual([200, 201]); - expect( - responses.map((response) => (response.json() as { decision: { id: string } }).decision.id), - ).toEqual([expect.any(String), expect.any(String)]); - const decisions = await client.db - .select({ id: approvalDecisions.id }) - .from(approvalDecisions) - .where( - and( - eq(approvalDecisions.approvalRequestId, approvalRequestId), - eq(approvalDecisions.action, "approve"), - ), - ); - expect(decisions).toHaveLength(1); - } finally { - await app.close(); - await secondApp.close(); - } - }); - - it("drains long run SSE replay gaps without derived historical events", async () => { - client = createDbClient(databaseUrl); - const templateId = await insertTemplate(client, templateIds); - const runId = randomUUID(); - runIds.push(runId); - const root = realpathSync(mkdtempSync(join(tmpdir(), "devflow-http-long-replay-"))); - tempRoots.push(root); - await client.db.insert(runs).values({ - id: runId, - templateId, - templateHash: "9".repeat(64), - state: "executing", - repoPath: root, - baseBranch: "main", - worktreeRoot: root, - }); - await client.db.insert(runInputs).values({ - runId, - requirementsMd: "Long SSE replay", - inputHash: "0".repeat(64), - }); - const events = new RunEventRepository(client.db); - for (let index = 0; index < 100; index += 1) { - const commandId = randomUUID(); - await events.append({ - runId, - type: "command.started", - payload: { commandId }, - idempotencyKey: `command.started:${commandId}`, - }); - } - await events.append({ - runId, - type: "run.started", - payload: { templateHash: "9".repeat(64) }, - idempotencyKey: `run.started:${runId}`, - }); - - const app = await createHttpApi({ - db: client.db, - engine: new RecordingEngine(client.db), - heartbeatMs: 1000, - pollMs: 10, - }); - await app.listen({ host: "127.0.0.1", port: 0 }); - const address = app.server.address(); - if (address === null || typeof address === "string") { - throw new Error("HTTP server did not expose a TCP address"); - } - - try { - const body = await readSseUntil( - `http://127.0.0.1:${address.port}/sse/runs/${runId}`, - '"type":"run.started"', - ); - expect(body).toContain("event: run.event_appended"); - expect(body).not.toContain("event: run.state_changed"); - } finally { - await app.close(); - } - }); - - it("assigns the global SSE cursor only after all messages derived from one row", async () => { - client = createDbClient(databaseUrl); - const templateId = await insertTemplate(client, templateIds); - const runId = randomUUID(); - const approvalRequestId = randomUUID(); - runIds.push(runId); - const root = realpathSync(mkdtempSync(join(tmpdir(), "devflow-http-global-cursor-"))); - tempRoots.push(root); - await client.db.insert(runs).values({ - id: runId, - templateId, - templateHash: "5".repeat(64), - state: "awaiting_approval", - repoPath: root, - baseBranch: "main", - worktreeRoot: root, - }); - await client.db.insert(runInputs).values({ - runId, - requirementsMd: "Global SSE cursor", - inputHash: "6".repeat(64), - }); - const events = new RunEventRepository(client.db); - - const app = await createHttpApi({ - db: client.db, - engine: new RecordingEngine(client.db), - heartbeatMs: 1000, - pollMs: 10, - }); - await app.listen({ host: "127.0.0.1", port: 0 }); - const address = app.server.address(); - if (address === null || typeof address === "string") { - throw new Error("HTTP server did not expose a TCP address"); - } - - try { - const body = await readSseUntil( - `http://127.0.0.1:${address.port}/sse/global`, - '"next":"awaiting_approval"', - async () => { - await events.append({ - runId, - type: "approval.requested", - payload: { - approvalRequestId, - approvalIdempotencyKey: `${runId}:spec_approved::1`, - gateKey: "spec_approved", - runState: "awaiting_approval", - }, - idempotencyKey: `approval.requested:${runId}:spec_approved::1`, - }); - }, - ); - expect(body).toContain("event: approval.created"); - expect(body).toContain("event: run.state_changed"); - expect(body).toMatch(/event: approval\.created\ndata:/); - expect(body).toMatch(/id: \d+\nevent: run\.state_changed/); - } finally { - await app.close(); - } - }); - - it("exposes TUI sessions for the run detail view", async () => { - client = createDbClient(databaseUrl); - const templateId = await insertTemplate(client, templateIds); - const runId = randomUUID(); - runIds.push(runId); - const root = realpathSync(mkdtempSync(join(tmpdir(), "devflow-http-sessions-"))); - tempRoots.push(root); - await client.db.insert(runs).values({ - id: runId, - templateId, - templateHash: "c".repeat(64), - state: "executing", - repoPath: root, - baseBranch: "main", - worktreeRoot: root, - }); - await client.db.insert(runInputs).values({ - runId, - requirementsMd: "Session list", - inputHash: "d".repeat(64), - }); - await client.db.insert(tuiSessions).values({ - runId, - roleId: "builder", - backend: "fake", - cwd: root, - expectedArtifactPath: join(root, "artifact.json"), - expectedSchema: "dev/spec@1", - state: "READY", - }); - const app = await createHttpApi({ db: client.db, engine: new RecordingEngine(client.db) }); - try { - const response = await app.inject({ method: "GET", url: `/api/runs/${runId}/sessions` }); - expect(response.statusCode).toBe(200); - expect(response.json()).toMatchObject({ - sessions: [ - { - backend: "fake", - cwd: root, - expectedArtifactPath: join(root, "artifact.json"), - expectedSchema: "dev/spec@1", - recoveryAttempts: 0, - roleId: "builder", - state: "READY", - }, - ], - }); - } finally { - await app.close(); - } - }); -}); - -describe("SSE formatting", () => { - it("formats run event messages and derives contract events", () => { - const messages = runEventMessages( - { - id: 1n, - payload: { - approvalRequestId: "00000000-0000-4000-8000-000000000703", - gateKey: "spec_approved", - }, - phaseId: null, - runId: "00000000-0000-4000-8000-000000000704", - seq: 7n, - ts: new Date("2026-05-13T00:00:00.000Z"), - type: "approval.requested", - }, - { deriveStateEvents: true }, - ); - const appended = messages[0]; - const derived = messages[1]; - if (appended === undefined || derived === undefined) { - throw new Error("Expected appended and derived SSE messages"); - } - - expect(formatSseMessage(appended)).toContain("id: 7\nevent: run.event_appended"); - expect(derived).toEqual({ - data: { - approvalId: "00000000-0000-4000-8000-000000000703", - gateKey: "spec_approved", - runId: "00000000-0000-4000-8000-000000000704", - }, - event: "approval.created", - id: "7", - }); - }); - - it("derives state change events with locked state values", () => { - expect( - runEventMessages( - { - id: 2n, - payload: { templateHash: "a".repeat(64) }, - phaseId: null, - runId: "00000000-0000-4000-8000-000000000704", - seq: 8n, - ts: new Date("2026-05-13T00:00:00.000Z"), - type: "run.started", - }, - { deriveStateEvents: true }, - )[1], - ).toMatchObject({ data: { next: "bound" }, event: "run.state_changed" }); - expect( - runEventMessages( - { - id: 3n, - payload: { attempt: 1, phaseKey: "implement" }, - phaseId: "00000000-0000-4000-8000-000000000705", - runId: "00000000-0000-4000-8000-000000000704", - seq: 9n, - ts: new Date("2026-05-13T00:00:00.000Z"), - type: "phase.started", - }, - { deriveStateEvents: true }, - )[1], - ).toMatchObject({ data: { next: "running" }, event: "phase.state_changed" }); - expect( - runEventMessages( - { - id: 4n, - payload: { - recoveryAttempts: 0, - roleId: "builder", - sessionId: "00000000-0000-4000-8000-000000000706", - }, - phaseId: null, - runId: "00000000-0000-4000-8000-000000000704", - seq: 10n, - ts: new Date("2026-05-13T00:00:00.000Z"), - type: "session.ready", - }, - { deriveStateEvents: true }, - )[1], - ).toMatchObject({ data: { next: "READY" }, event: "session.state_changed" }); - expect( - runEventMessages( - { - id: 7n, - payload: { attempt: 1, phaseKey: "implement", runState: "executing" }, - phaseId: "00000000-0000-4000-8000-000000000705", - runId: "00000000-0000-4000-8000-000000000704", - seq: 13n, - ts: new Date("2026-05-13T00:00:00.000Z"), - type: "phase.started", - }, - { deriveStateEvents: true }, - ).map((message) => message.event), - ).toEqual(["run.event_appended", "run.state_changed", "phase.state_changed"]); - expect( - runEventMessages( - { - id: 5n, - payload: { - attempt: 1, - path: "/tmp/spec.json", - phaseKey: "spec", - schemaId: "dev/spec@1", - }, - phaseId: "00000000-0000-4000-8000-000000000705", - runId: "00000000-0000-4000-8000-000000000704", - seq: 11n, - ts: new Date("2026-05-13T00:00:00.000Z"), - type: "artifact.expected", - }, - { deriveStateEvents: true }, - )[1], - ).toMatchObject({ data: { next: "awaiting_artifact" }, event: "phase.state_changed" }); - expect( - runEventMessages( - { - id: 6n, - payload: { - approvalRequestId: "00000000-0000-4000-8000-000000000707", - approvalIdempotencyKey: "approval-key", - gateKey: "spec_approved", - phaseKey: "spec", - phaseState: "awaiting_approval", - roleId: "builder", - runState: "awaiting_approval", - sessionId: "00000000-0000-4000-8000-000000000706", - sessionState: "WAITING_FOR_APPROVAL", - }, - phaseId: "00000000-0000-4000-8000-000000000705", - runId: "00000000-0000-4000-8000-000000000704", - seq: 12n, - ts: new Date("2026-05-13T00:00:00.000Z"), - type: "approval.requested", - }, - { deriveStateEvents: true }, - ).map((message) => message.event), - ).toEqual([ - "run.event_appended", - "approval.created", - "run.state_changed", - "phase.state_changed", - "session.state_changed", - ]); - }); -}); - -async function insertTemplate(client: DbClient, ids: string[]): Promise { - const id = randomUUID(); - ids.push(id); - await client.db.insert(workflowTemplates).values({ - id, - name: "http-template", - version: 1, - hash: randomHash(), - definition: { name: "http-template", version: 1, roles: [], phases: [] }, - }); - return id; -} - -async function insertPersona(client: DbClient, ids: string[]): Promise { - const id = randomUUID(); - ids.push(id); - await client.db.insert(agentPersonas).values({ - id, - name: "http-persona", - version: 1, - hash: randomHash(), - definition: { - backend: "fake", - capabilities: [], - maxRiskLevel: "low", - modelConfig: {}, - name: "http-persona", - promptConfig: {}, - version: 1, - }, - }); - return id; -} - -function randomHash(): string { - return randomUUID().replaceAll("-", "").padEnd(64, "0").slice(0, 64); -} - -async function readSseUntil( - url: string, - marker: string, - onConnected?: () => Promise, - headers?: Record, -): Promise { - return new Promise((resolve, reject) => { - let settled = false; - let connected = false; - let body = ""; - const request = get(url, { headers }, (response) => { - response.setEncoding("utf8"); - response.on("data", (chunk: string) => { - body += chunk; - if (body.includes(": connected") && !connected) { - connected = true; - onConnected?.().catch((error: unknown) => { - if (!settled) { - settled = true; - request.destroy(); - reject(error); - } - }); - } - if (body.includes(marker) && !settled) { - settled = true; - request.destroy(); - resolve(body); - } - }); - }); - request.on("error", (error) => { - if (!settled) { - reject(error); - } - }); - setTimeout(() => { - if (!settled) { - settled = true; - request.destroy(); - reject(new Error(`Timed out waiting for ${marker}`)); - } - }, 2000); - }); -} diff --git a/apps/api/src/http.ts b/apps/api/src/http.ts deleted file mode 100644 index e710b8b..0000000 --- a/apps/api/src/http.ts +++ /dev/null @@ -1,615 +0,0 @@ -import { ApprovalDecisionAction, DevflowError, getConfig } from "@devflow/core"; -import type { DbClient } from "@devflow/db"; -import { - agentPersonas, - approvalDecisions, - runs, - tuiSessions, - tuiTranscriptChunks, - workflowTemplates, -} from "@devflow/db"; -import { type RunEngine, type RunStartInput, readRunStatus } from "@devflow/run-engine"; -import sensible from "@fastify/sensible"; -import { and, desc, eq, sql } from "drizzle-orm"; -import Fastify, { type FastifyInstance, type FastifyReply } from "fastify"; - -import { - formatSseComment, - formatSseMessage, - latestGlobalRunEventId, - latestRunEventSeq, - latestTranscriptChunkId, - openSseResponse, - readGlobalRunEventRows, - readRunEventRows, - readTranscriptChunkRows, - runEventMessages, - transcriptChunkMessage, -} from "./sse.js"; - -type Database = DbClient["db"]; - -export interface HttpApiOptions { - db: Database; - engine: RunEngine; - heartbeatMs?: number; - pollMs?: number; -} - -export async function createHttpApi(options: HttpApiOptions): Promise { - const app = Fastify({ logger: false }); - await app.register(sensible); - app.setErrorHandler((error, _request, reply) => { - const normalizedError = error instanceof Error ? error : new Error(String(error)); - const statusCode = httpStatusForError(normalizedError); - reply.code(statusCode).send({ - error: { - class: normalizedError instanceof DevflowError ? normalizedError.class : "fatal", - code: normalizedError instanceof DevflowError ? normalizedError.code : "internal_error", - message: normalizedError.message, - recoveryHint: - normalizedError instanceof DevflowError ? normalizedError.recoveryHint : undefined, - }, - }); - }); - - app.addHook("onRequest", async (_request, reply) => { - reply.header("access-control-allow-origin", "*"); - reply.header("access-control-allow-methods", "GET,POST,OPTIONS"); - reply.header("access-control-allow-headers", "content-type,last-event-id"); - }); - - app.options("/*", async (_request, reply) => reply.code(204).send()); - - app.get("/health", async () => ({ ok: true })); - app.get("/api/health", async () => ({ ok: true })); - - app.get("/api/runs", async () => ({ - runs: await listRuns(options.db), - })); - - app.post("/api/runs", async (request, reply) => { - const input = parseRunStartBody(request.body); - const result = await options.engine.startRun(input); - reply.code(201).send(result); - }); - - app.get<{ Params: { runId: string } }>("/api/runs/:runId", async (request) => - options.engine.getStatus(request.params.runId), - ); - - app.get<{ Params: { runId: string } }>("/api/runs/:runId/transcript", async (request) => ({ - chunks: await listTranscriptChunks(options.db, request.params.runId), - })); - - app.get<{ Params: { runId: string } }>("/api/runs/:runId/sessions", async (request) => ({ - sessions: await listSessions(options.db, request.params.runId), - })); - - app.post<{ - Body: unknown; - Params: { approvalRequestId: string; runId: string }; - }>("/api/runs/:runId/approvals/:approvalRequestId", async (request, reply) => { - const body = asRecord(request.body); - const parsedAction = ApprovalDecisionAction.safeParse(body.action); - if (!parsedAction.success) { - throw badRequest("Invalid approval action", "invalid_approval_action"); - } - if (typeof body.clientToken !== "string" || body.clientToken.length === 0) { - throw badRequest("clientToken is required", "invalid_client_token"); - } - const clientToken = body.clientToken; - const comment = typeof body.comment === "string" ? body.comment : undefined; - const result = await withApprovalRouteLock( - options.db, - `${request.params.approvalRequestId}:${clientToken}`, - async () => { - const replay = await approvalDecisionExists( - options.db, - request.params.approvalRequestId, - parsedAction.data, - clientToken, - ); - await options.engine.signalApproval( - request.params.runId, - request.params.approvalRequestId, - parsedAction.data, - clientToken, - comment, - ); - const decision = await readApprovalDecision( - options.db, - request.params.approvalRequestId, - parsedAction.data, - clientToken, - ); - return { decision, replay }; - }, - ); - reply.code(result.replay ? 200 : 201).send({ - ok: true, - clientToken, - decision: result.decision ?? null, - }); - }); - - app.post<{ Params: { runId: string } }>("/api/runs/:runId/pause", async (request) => { - await options.engine.pauseRun(request.params.runId); - return { ok: true }; - }); - - app.post<{ Params: { runId: string } }>("/api/runs/:runId/resume", async (request) => { - await options.engine.resumeRun(request.params.runId); - return { ok: true }; - }); - - app.post<{ Body: unknown; Params: { runId: string } }>( - "/api/runs/:runId/abort", - async (request) => { - const body = asRecord(request.body); - await options.engine.abortRun( - request.params.runId, - typeof body.reason === "string" ? body.reason : "api_abort", - ); - return { ok: true }; - }, - ); - - app.get("/api/templates", async () => ({ - templates: await listTemplates(options.db), - })); - - app.get("/api/personas", async () => ({ - personas: await listPersonas(options.db), - })); - - app.get("/api/doctor", async () => ({ - checks: doctorChecks(), - })); - - app.get("/api/backends", async () => ({ - backends: getConfig().backends, - })); - - app.get<{ Params: { runId: string } }>("/sse/runs/:runId", async (request, reply) => { - await readRunStatus(options.db, request.params.runId); - reply.hijack(); - openSseResponse(reply.raw); - const heartbeatMs = options.heartbeatMs ?? 15_000; - const pollMs = options.pollMs ?? 500; - let cursor = parseLastEventId(request.headers["last-event-id"]); - let transcriptCursor = await latestTranscriptChunkId(options.db, request.params.runId); - let closed = false; - let polling = false; - const drainRows = async (deriveStateEvents: boolean, throughSeq?: bigint) => { - while (!closed) { - const rows = await readRunEventRows(options.db, request.params.runId, cursor, throughSeq); - if (rows.length === 0) { - return; - } - for (const row of rows) { - for (const message of runEventMessages(row, { deriveStateEvents })) { - reply.raw.write(formatSseMessage(message)); - } - cursor = row.seq; - } - if (throughSeq !== undefined && cursor >= throughSeq) { - return; - } - } - }; - const replayThroughSeq = await latestRunEventSeq(options.db, request.params.runId); - await drainRows(false, replayThroughSeq); - const pollOnce = async () => { - await drainRows(true); - const chunks = await readTranscriptChunkRows( - options.db, - request.params.runId, - transcriptCursor, - ); - for (const chunk of chunks) { - reply.raw.write(formatSseMessage(transcriptChunkMessage(chunk))); - transcriptCursor = chunk.id; - } - }; - const poll = setInterval(() => { - if (polling) { - return; - } - polling = true; - void pollOnce() - .catch(() => closeSse(reply)) - .finally(() => { - polling = false; - }); - }, pollMs); - const heartbeat = setInterval(() => { - reply.raw.write(formatSseComment("heartbeat")); - }, heartbeatMs); - request.raw.on("close", () => { - closed = true; - clearInterval(poll); - clearInterval(heartbeat); - }); - }); - - app.get("/sse/global", async (request, reply) => { - reply.hijack(); - openSseResponse(reply.raw); - const heartbeatMs = options.heartbeatMs ?? 15_000; - const pollMs = options.pollMs ?? 500; - let cursor = - parseOptionalLastEventId(request.headers["last-event-id"]) ?? - (await latestGlobalRunEventId(options.db)); - let polling = false; - const writeRows = async () => { - const rows = await readGlobalRunEventRows(options.db, cursor); - for (const row of rows) { - const messages = runEventMessages(row, { deriveStateEvents: true }).filter((message) => - isGlobalSseEvent(message.event), - ); - messages.forEach((message, index) => { - if (index === messages.length - 1) { - reply.raw.write( - formatSseMessage({ - event: message.event, - data: message.data, - id: row.id.toString(), - }), - ); - } else { - reply.raw.write(formatSseMessage({ event: message.event, data: message.data })); - } - }); - cursor = row.id; - } - }; - const poll = setInterval(() => { - if (polling) { - return; - } - polling = true; - void writeRows() - .catch(() => closeSse(reply)) - .finally(() => { - polling = false; - }); - }, pollMs); - const heartbeat = setInterval(() => { - reply.raw.write(formatSseComment("heartbeat")); - }, heartbeatMs); - request.raw.on("close", () => { - clearInterval(poll); - clearInterval(heartbeat); - }); - }); - - return app; -} - -async function listRuns(db: Database) { - const rows = await db - .select({ - id: runs.id, - state: runs.state, - repoPath: runs.repoPath, - baseBranch: runs.baseBranch, - worktreeRoot: runs.worktreeRoot, - currentPhaseId: runs.currentPhaseId, - finalReportPath: runs.finalReportPath, - startedAt: runs.startedAt, - endedAt: runs.endedAt, - createdAt: runs.createdAt, - updatedAt: runs.updatedAt, - }) - .from(runs) - .orderBy(desc(runs.createdAt)) - .limit(100); - return rows.map((row) => ({ - ...row, - createdAt: row.createdAt.toISOString(), - endedAt: row.endedAt?.toISOString() ?? null, - startedAt: row.startedAt?.toISOString() ?? null, - updatedAt: row.updatedAt?.toISOString() ?? null, - })); -} - -async function listTemplates(db: Database) { - return db - .select({ - id: workflowTemplates.id, - name: workflowTemplates.name, - version: workflowTemplates.version, - hash: workflowTemplates.hash, - definition: workflowTemplates.definition, - createdAt: workflowTemplates.createdAt, - }) - .from(workflowTemplates) - .orderBy(desc(workflowTemplates.createdAt)); -} - -async function listPersonas(db: Database) { - return db - .select({ - id: agentPersonas.id, - name: agentPersonas.name, - version: agentPersonas.version, - hash: agentPersonas.hash, - definition: agentPersonas.definition, - createdAt: agentPersonas.createdAt, - }) - .from(agentPersonas) - .orderBy(desc(agentPersonas.createdAt)); -} - -async function listTranscriptChunks(db: Database, runId: string) { - const rows = await db - .select({ - sessionId: tuiTranscriptChunks.sessionId, - roleId: tuiSessions.roleId, - seq: tuiTranscriptChunks.seq, - content: tuiTranscriptChunks.content, - capturedAt: tuiTranscriptChunks.capturedAt, - }) - .from(tuiTranscriptChunks) - .innerJoin(tuiSessions, eq(tuiTranscriptChunks.sessionId, tuiSessions.id)) - .where(eq(tuiSessions.runId, runId)) - .orderBy(desc(tuiTranscriptChunks.id)) - .limit(200); - return rows.reverse().map((row) => ({ - ...row, - capturedAt: row.capturedAt.toISOString(), - seq: row.seq.toString(), - })); -} - -async function listSessions(db: Database, runId: string) { - const rows = await db - .select({ - id: tuiSessions.id, - roleId: tuiSessions.roleId, - backend: tuiSessions.backend, - cwd: tuiSessions.cwd, - expectedArtifactPath: tuiSessions.expectedArtifactPath, - expectedSchema: tuiSessions.expectedSchema, - lastPromptAt: tuiSessions.lastPromptAt, - recoveryAttempts: tuiSessions.recoveryAttempts, - state: tuiSessions.state, - tmuxSession: tuiSessions.tmuxSession, - tmuxWindow: tuiSessions.tmuxWindow, - }) - .from(tuiSessions) - .where(eq(tuiSessions.runId, runId)) - .orderBy(desc(tuiSessions.createdAt)); - return rows.map((row) => ({ - ...row, - lastPromptAt: row.lastPromptAt?.toISOString() ?? null, - })); -} - -async function approvalDecisionExists( - db: Database, - approvalRequestId: string, - action: string, - clientToken: string, -): Promise { - const [row] = await db - .select({ id: approvalDecisions.id }) - .from(approvalDecisions) - .where( - and( - eq(approvalDecisions.approvalRequestId, approvalRequestId), - eq(approvalDecisions.action, action), - eq(approvalDecisions.idempotencyKey, `${approvalRequestId}:${action}:${clientToken}`), - ), - ) - .limit(1); - return row !== undefined; -} - -async function readApprovalDecision( - db: Database, - approvalRequestId: string, - action: string, - clientToken: string, -) { - const [row] = await db - .select({ - id: approvalDecisions.id, - approvalRequestId: approvalDecisions.approvalRequestId, - action: approvalDecisions.action, - comment: approvalDecisions.comment, - decidedAt: approvalDecisions.decidedAt, - idempotencyKey: approvalDecisions.idempotencyKey, - }) - .from(approvalDecisions) - .where( - and( - eq(approvalDecisions.approvalRequestId, approvalRequestId), - eq(approvalDecisions.action, action), - eq(approvalDecisions.idempotencyKey, `${approvalRequestId}:${action}:${clientToken}`), - ), - ) - .limit(1); - if (row === undefined) { - return undefined; - } - return { - ...row, - decidedAt: row.decidedAt.toISOString(), - }; -} - -async function withApprovalRouteLock( - db: Database, - key: string, - operation: () => Promise, -): Promise { - return db.transaction(async (tx) => { - await tx.execute( - sql`SELECT pg_advisory_xact_lock(hashtextextended(${`devflow:approval-route:${key}`}, 0))`, - ); - return operation(); - }); -} - -function doctorChecks() { - const config = getConfig(); - return [ - { - name: "config", - status: "pass", - detail: "Config loaded and validated", - remediation: "", - }, - ...config.backends.map((backend) => { - if (backend.id === "fake") { - return { - name: "backend.fake", - status: "pass", - detail: "Fake backend is always available", - remediation: "", - }; - } - const resolved = backend.binaryPath !== undefined; - return { - name: `backend.${backend.id}`, - status: resolved ? "pass" : "warn", - detail: resolved ? backend.binaryPath : `${backend.id} binary did not resolve at startup`, - remediation: `Install ${backend.id} or disable it in DEVFLOW_BACKENDS_JSON.`, - }; - }), - ]; -} - -function parseRunStartBody(body: unknown): RunStartInput { - const record = asRecord(body); - if (typeof record.requirementsMd !== "string" || record.requirementsMd.length === 0) { - throw badRequest("requirementsMd is required", "invalid_run_start_input"); - } - if (typeof record.repoPath !== "string" || record.repoPath.length === 0) { - throw badRequest("repoPath is required", "invalid_run_start_input"); - } - if (typeof record.baseBranch !== "string" || record.baseBranch.length === 0) { - throw badRequest("baseBranch is required", "invalid_run_start_input"); - } - const input: RunStartInput = { - baseBranch: record.baseBranch, - repoPath: record.repoPath, - requirementsMd: record.requirementsMd, - }; - if (typeof record.runId === "string" && record.runId.length > 0) { - input.runId = record.runId; - } - if (typeof record.templateName === "string" && record.templateName.length > 0) { - input.templateName = record.templateName; - } - const templateVersion = optionalNumber(record.templateVersion); - if (templateVersion !== undefined) { - input.templateVersion = templateVersion; - } - if (typeof record.worktreeRoot === "string" && record.worktreeRoot.length > 0) { - input.worktreeRoot = record.worktreeRoot; - } - if (record.objective !== undefined) { - input.objective = record.objective; - } - if (isRecord(record.extra)) { - input.extra = record.extra; - } - if (isRecord(record.overrides)) { - input.overrides = record.overrides as NonNullable; - } - if (isRecord(record.scenarios)) { - input.scenarios = record.scenarios as NonNullable; - } - return input; -} - -function optionalNumber(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string" && value.length > 0) { - const parsed = Number(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return undefined; -} - -function asRecord(value: unknown): Record { - if (!isRecord(value)) { - throw badRequest("Request body must be a JSON object", "invalid_request_body"); - } - return value; -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function badRequest(message: string, code: string) { - return new DevflowError(message, { - class: "human_required", - code, - recoveryHint: message, - }); -} - -function httpStatusForError(error: Error): number { - if (!(error instanceof DevflowError)) { - return 500; - } - if (error.code.endsWith("_not_found")) { - return 404; - } - if ( - error.code === "active_run_exists" || - error.code === "approval_conflict" || - error.code === "invalid_client_token" || - error.code === "invalid_request_body" || - error.code === "invalid_run_start_input" || - error.code === "invalid_approval_action" - ) { - return error.code === "approval_conflict" || error.code === "active_run_exists" ? 409 : 400; - } - return error.class === "fatal" ? 500 : 409; -} - -function parseLastEventId(value: string | string[] | undefined): bigint { - const raw = Array.isArray(value) ? value.at(-1) : value; - if (raw === undefined || raw.length === 0) { - return 0n; - } - try { - return BigInt(raw); - } catch { - return 0n; - } -} - -function parseOptionalLastEventId(value: string | string[] | undefined): bigint | undefined { - const raw = Array.isArray(value) ? value.at(-1) : value; - if (raw === undefined || raw.length === 0) { - return undefined; - } - try { - return BigInt(raw); - } catch { - return undefined; - } -} - -function isGlobalSseEvent(event: string): boolean { - return ( - event === "run.state_changed" || event === "approval.created" || event === "approval.resolved" - ); -} - -function closeSse(reply: FastifyReply): void { - if (!reply.raw.destroyed) { - reply.raw.end(); - } -} diff --git a/apps/api/src/index.test.ts b/apps/api/src/index.test.ts deleted file mode 100644 index 2b96498..0000000 --- a/apps/api/src/index.test.ts +++ /dev/null @@ -1,1087 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -import { - DevflowError, - type PromptEnvelope, - loadPersonaFiles, - loadTemplateFiles, -} from "@devflow/core"; -import { - type DbClient, - agentPersonas, - approvalDecisions, - approvalRequests, - createDbClient, - runEvents, - runs, - tuiSessions, - workflowTemplates, -} from "@devflow/db"; -import { DbRunEngine } from "@devflow/run-engine"; -import { FakeSessionAdapter, type SessionHandle, SessionManager } from "@devflow/session"; -import type { WorkflowClient, WorkflowHandle } from "@temporalio/client"; -import { and, eq, inArray } from "drizzle-orm"; -import { afterEach, describe, expect, it } from "vitest"; - -import { startApi, startM4Api } from "./index.js"; - -const databaseUrl = - process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow"; - -class ResumeFailsFakeSessionAdapter extends FakeSessionAdapter { - resumeAttempts = 0; - - override async resume(_handle: SessionHandle): Promise { - this.resumeAttempts += 1; - throw new DevflowError("resume failed", { - class: "recoverable", - code: "pane_briefly_unresponsive", - recoveryHint: "resume failed", - }); - } -} - -class ResumeSucceedsAfterTwoFailuresFakeSessionAdapter extends FakeSessionAdapter { - resumeAttempts = 0; - - override async resume(handle: SessionHandle): Promise { - this.resumeAttempts += 1; - if (this.resumeAttempts <= 2) { - throw new DevflowError("resume failed transiently", { - class: "recoverable", - code: "pane_briefly_unresponsive", - recoveryHint: "resume failed transiently", - }); - } - return super.resume(handle); - } -} - -class DelayedSendPromptFakeSessionAdapter extends FakeSessionAdapter { - readonly promptStarted = deferred(); - readonly releasePrompt = deferred(); - - override async sendPrompt( - handle: SessionHandle, - envelope: PromptEnvelope, - ): Promise<{ promptId: string }> { - this.promptStarted.resolve(); - await this.releasePrompt.promise; - return super.sendPrompt(handle, envelope); - } -} - -class FakeWorkflowClient { - started: { workflowId: string; taskQueue: string; args: unknown[] } | undefined; - - async start( - _workflow: unknown, - options: { workflowId: string; taskQueue: string; args: unknown[] }, - ) { - this.started = { - workflowId: options.workflowId, - taskQueue: options.taskQueue, - args: options.args, - }; - } - - getHandle(_workflowId: string): Pick { - return { - signal: async () => undefined, - }; - } -} - -function deferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve; - reject = promiseReject; - }); - return { promise, reject, resolve }; -} - -function createGitRepo(): string { - const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-"))); - execFileSync("git", ["init", "-b", "main"], { cwd: repoPath, stdio: "ignore" }); - writeFileSync(join(repoPath, "README.md"), "# API fixture\n"); - execFileSync("git", ["add", "README.md"], { cwd: repoPath, stdio: "ignore" }); - execFileSync( - "git", - [ - "-c", - "user.name=Devflow Test", - "-c", - "user.email=devflow@example.test", - "commit", - "-m", - "initial", - ], - { cwd: repoPath, stdio: "ignore" }, - ); - return repoPath; -} - -async function seedDevelopmentRegistry(db: DbClient["db"]) { - const [templateEntry] = loadTemplateFiles(resolve("docs/schemas/templates")).filter( - (entry) => entry.name === "development" && entry.version === 1, - ); - if (templateEntry === undefined) { - throw new Error("development@1 template fixture is missing"); - } - await db - .insert(workflowTemplates) - .values({ - name: templateEntry.name, - version: templateEntry.version, - hash: templateEntry.hash, - definition: templateEntry.definition, - }) - .onConflictDoUpdate({ - target: [workflowTemplates.name, workflowTemplates.version], - set: { hash: templateEntry.hash, definition: templateEntry.definition }, - }); - - for (const personaEntry of loadPersonaFiles(resolve("docs/schemas/personas"))) { - await db - .insert(agentPersonas) - .values({ - name: personaEntry.name, - version: personaEntry.version, - hash: personaEntry.hash, - definition: personaEntry.definition, - }) - .onConflictDoNothing({ target: [agentPersonas.name, agentPersonas.version] }); - } -} - -async function waitForRunEventType(db: DbClient["db"], runId: string, type: string) { - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { - const [event] = await db - .select({ id: runEvents.id }) - .from(runEvents) - .where(and(eq(runEvents.runId, runId), eq(runEvents.type, type))) - .limit(1); - if (event !== undefined) { - return; - } - await new Promise((resolveWait) => setTimeout(resolveWait, 10)); - } - throw new Error(`timed out waiting for ${type}`); -} - -describe("startApi", () => { - let client: DbClient | undefined; - const runIds: string[] = []; - const templateIds: string[] = []; - const tempRoots: string[] = []; - - function createApiWorkspaceRoot(): string { - const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-workspace-"))); - tempRoots.push(workspaceRoot); - return workspaceRoot; - } - - function startTestM4Api(options: Parameters[0] = {}) { - return startM4ApiWhenLockFree({ - workspaceRoot: createApiWorkspaceRoot(), - maxConcurrentRuns: 100, - ...options, - }); - } - - afterEach(async () => { - if (client !== undefined) { - if (runIds.length > 0) { - const requests = await client.db - .select({ id: approvalRequests.id }) - .from(approvalRequests) - .where(inArray(approvalRequests.runId, [...runIds])); - if (requests.length > 0) { - await client.db.delete(approvalDecisions).where( - inArray( - approvalDecisions.approvalRequestId, - requests.map((request) => request.id), - ), - ); - } - await client.db - .delete(approvalRequests) - .where(inArray(approvalRequests.runId, [...runIds])); - await client.db.delete(runs).where(inArray(runs.id, [...runIds])); - } - if (templateIds.length > 0) { - await client.db - .delete(workflowTemplates) - .where(inArray(workflowTemplates.id, [...templateIds])); - } - await client.close(); - client = undefined; - } - - for (const root of tempRoots.splice(0)) { - rmSync(root, { recursive: true, force: true }); - } - runIds.length = 0; - templateIds.length = 0; - }); - - it("runs M4 restart recovery before startup completes", async () => { - client = createDbClient(databaseUrl); - const templateId = randomUUID(); - const runId = randomUUID(); - const sessionId = randomUUID(); - const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-"))); - const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-"))); - tempRoots.push(repoPath, worktreeRoot); - templateIds.push(templateId); - runIds.push(runId); - - await client.db.insert(workflowTemplates).values({ - id: templateId, - name: `api-startup-${templateId}`, - version: 1, - hash: "a".repeat(64), - definition: { name: "api-startup", version: 1, roles: [], phases: [], defaultGates: [] }, - }); - await client.db.insert(runs).values({ - id: runId, - templateId, - templateHash: "a".repeat(64), - state: "executing", - repoPath, - baseBranch: "main", - worktreeRoot, - }); - await client.db.insert(tuiSessions).values({ - id: sessionId, - runId, - roleId: "spec_writer", - backend: "fake", - cwd: worktreeRoot, - state: "READY", - }); - - const result = await startTestM4Api({ dbClient: client, recoveryRunIds: [runId] }); - try { - expect(result.recovery).toEqual({ - failedSessionIds: [sessionId], - sweptRunIds: [runId], - }); - expect(result.sessionRecovery).toEqual({ - failedSessionIds: [], - recoveredSessionIds: [], - }); - } finally { - await result.stop(); - } - const [run] = await client.db - .select({ state: runs.state }) - .from(runs) - .where(eq(runs.id, runId)); - expect(run).toEqual({ state: "failed" }); - const [session] = await client.db - .select({ state: tuiSessions.state }) - .from(tuiSessions) - .where(eq(tuiSessions.id, sessionId)); - expect(session).toEqual({ state: "FAILED_NEEDS_HUMAN" }); - const events = await client.db - .select({ type: runEvents.type }) - .from(runEvents) - .where(eq(runEvents.runId, runId)) - .orderBy(runEvents.seq); - expect(events.map((event) => event.type)).toEqual(["run.failed", "session.failed"]); - }); - - it("holds the SessionManager singleton lock until stopped", async () => { - client = createDbClient(databaseUrl); - const recoveryRunIds = [randomUUID()]; - const first = await startTestM4Api({ dbClient: client, recoveryRunIds }); - try { - await expect( - startM4Api({ - dbClient: client, - workspaceRoot: createApiWorkspaceRoot(), - recoveryRunIds, - }), - ).rejects.toMatchObject({ - code: "session_manager_already_running", - }); - } finally { - await first.stop(); - } - }); - - it("hosts the M4 run engine behind the API startup boundary", async () => { - client = createDbClient(databaseUrl); - await seedDevelopmentRegistry(client.db); - const workspaceRoot = createApiWorkspaceRoot(); - const repoPath = createGitRepo(); - tempRoots.push(repoPath); - - const api = await startM4ApiWhenLockFree({ - dbClient: client, - workspaceRoot, - recoveryRunIds: [], - maxConcurrentRuns: 100, - }); - try { - expect(api.engine).toBeInstanceOf(DbRunEngine); - const { runId } = await api.engine.startRun({ - requirementsMd: "Start a fake development run through the API-owned engine.", - repoPath, - baseBranch: "main", - scenarios: { spec: "ok" }, - }); - runIds.push(runId); - - const status = await api.engine.getStatus(runId); - expect(status.run.state).toBe("awaiting_approval"); - expect(status.run.worktreeRoot).toBe(resolve(workspaceRoot, runId, "main")); - expect(status.approvals).toMatchObject([{ gateKey: "spec_approved", state: "pending" }]); - } finally { - await api.stop(); - } - }); - - it("uses the Temporal RunEngine by default without acquiring the SessionManager lock", async () => { - client = createDbClient(databaseUrl); - const first = await startM4ApiWhenLockFree({ - dbClient: client, - workspaceRoot: createApiWorkspaceRoot(), - recoveryRunIds: [], - maxConcurrentRuns: 100, - }); - const temporalClient = new FakeWorkflowClient(); - try { - const temporalApi = await startApi({ - dbClient: client, - temporalClient: temporalClient as unknown as WorkflowClient, - taskQueue: "devflow-runs-test", - workspaceRoot: createApiWorkspaceRoot(), - awaitRunStart: false, - }); - const runId = randomUUID(); - await temporalApi.engine.startRun({ - runId, - requirementsMd: "Temporal API should only dispatch workflow commands.", - repoPath: "/repo", - baseBranch: "main", - }); - expect(temporalClient.started).toMatchObject({ - taskQueue: "devflow-runs-test", - workflowId: `devflow-run:${runId}`, - }); - await temporalApi.stop(); - } finally { - await first.stop(); - } - }); - - it("wires Temporal approval replay side effects through the API boundary", async () => { - client = createDbClient(databaseUrl); - await seedDevelopmentRegistry(client.db); - const workspaceRoot = createApiWorkspaceRoot(); - const template = ( - await client.db - .select({ hash: workflowTemplates.hash, id: workflowTemplates.id }) - .from(workflowTemplates) - .where(eq(workflowTemplates.name, "development")) - .limit(1) - )[0]; - if (template === undefined) { - throw new Error("development template missing"); - } - const runId = randomUUID(); - const approvalRequestId = randomUUID(); - const clientToken = randomUUID(); - const repoPath = createGitRepo(); - const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-"))); - tempRoots.push(repoPath, worktreeRoot); - runIds.push(runId); - await client.db.insert(runs).values({ - id: runId, - templateId: template.id, - templateHash: template.hash, - state: "completed", - repoPath, - baseBranch: "main", - worktreeRoot, - endedAt: new Date(), - finalReportPath: null, - }); - await client.db.insert(approvalRequests).values({ - id: approvalRequestId, - runId, - gateKey: "spec_approved", - state: "approved", - idempotencyKey: `${runId}:spec_approved::1`, - payload: { replay: true }, - }); - await client.db.insert(approvalDecisions).values({ - approvalRequestId, - action: "approve", - idempotencyKey: `${approvalRequestId}:approve:${clientToken}`, - }); - - const temporalApi = await startApi({ - dbClient: client, - temporalClient: new FakeWorkflowClient() as unknown as WorkflowClient, - taskQueue: "devflow-runs-test", - workspaceRoot, - awaitRunStart: false, - }); - try { - await temporalApi.engine.signalApproval(runId, approvalRequestId, "approve", clientToken); - const [run] = await client.db - .select({ finalReportPath: runs.finalReportPath }) - .from(runs) - .where(eq(runs.id, runId)); - expect(run?.finalReportPath).toMatch(/\.report\.md$/); - } finally { - await temporalApi.stop(); - } - }); - - it.each([ - { action: "reject" as const, approvalState: "rejected", runState: "failed" }, - { action: "abort" as const, approvalState: "aborted", runState: "aborted" }, - ])( - "repairs $runState approval replay reports without mutating sessions through the API", - async ({ action, approvalState, runState }) => { - client = createDbClient(databaseUrl); - await seedDevelopmentRegistry(client.db); - const workspaceRoot = createApiWorkspaceRoot(); - const template = ( - await client.db - .select({ hash: workflowTemplates.hash, id: workflowTemplates.id }) - .from(workflowTemplates) - .where(eq(workflowTemplates.name, "development")) - .limit(1) - )[0]; - if (template === undefined) { - throw new Error("development template missing"); - } - const runId = randomUUID(); - const approvalRequestId = randomUUID(); - const clientToken = randomUUID(); - const sessionId = randomUUID(); - const repoPath = createGitRepo(); - const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-"))); - tempRoots.push(repoPath, worktreeRoot); - runIds.push(runId); - await client.db.insert(runs).values({ - id: runId, - templateId: template.id, - templateHash: template.hash, - state: runState, - repoPath, - baseBranch: "main", - worktreeRoot, - endedAt: new Date(), - finalReportPath: null, - }); - await client.db.insert(tuiSessions).values({ - id: sessionId, - runId, - roleId: "implementer", - backend: "fake", - cwd: worktreeRoot, - state: "READY", - }); - await client.db.insert(approvalRequests).values({ - id: approvalRequestId, - runId, - gateKey: "spec_approved", - state: approvalState, - idempotencyKey: `${runId}:spec_approved::1`, - payload: { replay: true }, - }); - await client.db.insert(approvalDecisions).values({ - approvalRequestId, - action, - idempotencyKey: `${approvalRequestId}:${action}:${clientToken}`, - }); - - const temporalApi = await startApi({ - dbClient: client, - temporalClient: new FakeWorkflowClient() as unknown as WorkflowClient, - taskQueue: "devflow-runs-test", - workspaceRoot, - awaitRunStart: false, - }); - try { - await temporalApi.engine.signalApproval(runId, approvalRequestId, action, clientToken); - const [run] = await client.db - .select({ finalReportPath: runs.finalReportPath }) - .from(runs) - .where(eq(runs.id, runId)); - expect(run?.finalReportPath).toMatch(/\.report\.md$/); - const [session] = await client.db - .select({ state: tuiSessions.state }) - .from(tuiSessions) - .where(eq(tuiSessions.id, sessionId)); - expect(session).toEqual({ state: "READY" }); - } finally { - await temporalApi.stop(); - } - }, - ); - - it("repairs missing terminal final reports during API startup", async () => { - client = createDbClient(databaseUrl); - await seedDevelopmentRegistry(client.db); - const workspaceRoot = createApiWorkspaceRoot(); - const template = ( - await client.db - .select({ hash: workflowTemplates.hash, id: workflowTemplates.id }) - .from(workflowTemplates) - .where(eq(workflowTemplates.name, "development")) - .limit(1) - )[0]; - if (template === undefined) { - throw new Error("development template missing"); - } - const runId = randomUUID(); - const repoPath = createGitRepo(); - const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-"))); - tempRoots.push(repoPath, worktreeRoot); - runIds.push(runId); - await client.db.insert(runs).values({ - id: runId, - templateId: template.id, - templateHash: template.hash, - state: "completed", - repoPath, - baseBranch: "main", - worktreeRoot, - endedAt: new Date(), - finalReportPath: null, - }); - - const api = await startM4ApiWhenLockFree({ - dbClient: client, - workspaceRoot, - recoveryRunIds: [runId], - maxConcurrentRuns: 100, - }); - try { - expect(api.finalReportRecovery).toEqual([runId]); - const [run] = await client.db - .select({ finalReportPath: runs.finalReportPath }) - .from(runs) - .where(eq(runs.id, runId)); - expect(run?.finalReportPath).toMatch(/\.report\.md$/); - } finally { - await api.stop(); - } - }); - - it("does not sweep active runs when a second API instance fails the singleton lock", async () => { - client = createDbClient(databaseUrl); - const first = await startTestM4Api({ dbClient: client, recoveryRunIds: [] }); - const templateId = randomUUID(); - const runId = randomUUID(); - const sessionId = randomUUID(); - const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-"))); - const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-"))); - tempRoots.push(repoPath, worktreeRoot); - templateIds.push(templateId); - runIds.push(runId); - try { - await client.db.insert(workflowTemplates).values({ - id: templateId, - name: `api-lock-order-${templateId}`, - version: 1, - hash: "c".repeat(64), - definition: { name: "api-lock-order", version: 1, roles: [], phases: [] }, - }); - await client.db.insert(runs).values({ - id: runId, - templateId, - templateHash: "c".repeat(64), - state: "executing", - repoPath, - baseBranch: "main", - worktreeRoot, - }); - await client.db.insert(tuiSessions).values({ - id: sessionId, - runId, - roleId: "spec_writer", - backend: "fake", - cwd: worktreeRoot, - state: "READY", - }); - - await expect( - startM4Api({ - dbClient: client, - workspaceRoot: createApiWorkspaceRoot(), - recoveryRunIds: [runId], - }), - ).rejects.toMatchObject({ - code: "session_manager_already_running", - }); - - const [run] = await client.db - .select({ state: runs.state }) - .from(runs) - .where(eq(runs.id, runId)); - expect(run).toEqual({ state: "executing" }); - const [session] = await client.db - .select({ state: tuiSessions.state }) - .from(tuiSessions) - .where(eq(tuiSessions.id, sessionId)); - expect(session).toEqual({ state: "READY" }); - const events = await client.db.select().from(runEvents).where(eq(runEvents.runId, runId)); - expect(events).toEqual([]); - } finally { - await first.stop(); - } - }); - - it("ignores terminal-run sessions during SessionManager startup recovery", async () => { - client = createDbClient(databaseUrl); - const templateId = randomUUID(); - const runId = randomUUID(); - const sessionId = randomUUID(); - const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-"))); - const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-"))); - tempRoots.push(repoPath, worktreeRoot); - templateIds.push(templateId); - runIds.push(runId); - const adapter = new FakeSessionAdapter({ - sessionIdFactory: () => sessionId, - writeDelayMs: 0, - }); - await adapter.start({ - runId, - roleId: "spec_writer", - backend: "fake", - cwd: worktreeRoot, - }); - - await client.db.insert(workflowTemplates).values({ - id: templateId, - name: `api-session-recovery-${templateId}`, - version: 1, - hash: "b".repeat(64), - definition: { name: "api-session-recovery", version: 1, roles: [], phases: [] }, - }); - await client.db.insert(runs).values({ - id: runId, - templateId, - templateHash: "b".repeat(64), - state: "completed", - repoPath, - baseBranch: "main", - worktreeRoot, - }); - await client.db.insert(tuiSessions).values({ - id: sessionId, - runId, - roleId: "spec_writer", - backend: "fake", - cwd: worktreeRoot, - state: "READY", - }); - - const result = await startTestM4Api({ - dbClient: client, - recoveryRunIds: [runId], - sessionAdapter: adapter, - }); - try { - expect(result.recovery).toEqual({ failedSessionIds: [], sweptRunIds: [] }); - expect(result.sessionRecovery).toEqual({ - failedSessionIds: [], - recoveredSessionIds: [], - }); - } finally { - await result.stop(); - } - const [session] = await client.db - .select({ state: tuiSessions.state }) - .from(tuiSessions) - .where(eq(tuiSessions.id, sessionId)); - expect(session).toEqual({ state: "READY" }); - const approvals = await client.db - .select() - .from(approvalRequests) - .where(eq(approvalRequests.runId, runId)); - expect(approvals).toEqual([]); - const events = await client.db.select().from(runEvents).where(eq(runEvents.runId, runId)); - expect(events).toEqual([]); - }); - - it("fails CREATED session reservations during SessionManager startup recovery", async () => { - client = createDbClient(databaseUrl); - const templateId = randomUUID(); - const runId = randomUUID(); - const sessionId = randomUUID(); - const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-"))); - const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-"))); - tempRoots.push(repoPath, worktreeRoot); - templateIds.push(templateId); - runIds.push(runId); - - await client.db.insert(workflowTemplates).values({ - id: templateId, - name: `api-session-created-${templateId}`, - version: 1, - hash: "f".repeat(64), - definition: { name: "api-session-created", version: 1, roles: [], phases: [] }, - }); - await client.db.insert(runs).values({ - id: runId, - templateId, - templateHash: "f".repeat(64), - state: "executing", - repoPath, - baseBranch: "main", - worktreeRoot, - }); - await client.db.insert(tuiSessions).values({ - id: sessionId, - runId, - roleId: "spec_writer", - backend: "fake", - cwd: worktreeRoot, - state: "CREATED", - }); - - const adapter = new ResumeFailsFakeSessionAdapter(); - const manager = new SessionManager({ - dbClient: client, - adapter, - recoveryRunIds: [runId], - }); - const recovery = await initializeManagerWhenLockFree(manager); - try { - expect(adapter.resumeAttempts).toBe(3); - expect(recovery).toEqual({ failedSessionIds: [sessionId], recoveredSessionIds: [] }); - } finally { - await manager.shutdown(); - } - const [run] = await client.db - .select({ pausedFromState: runs.pausedFromState, state: runs.state }) - .from(runs) - .where(eq(runs.id, runId)); - expect(run).toEqual({ pausedFromState: "executing", state: "paused" }); - const [session] = await client.db - .select({ recoveryAttempts: tuiSessions.recoveryAttempts, state: tuiSessions.state }) - .from(tuiSessions) - .where(eq(tuiSessions.id, sessionId)); - expect(session).toEqual({ recoveryAttempts: 1, state: "FAILED_NEEDS_HUMAN" }); - const approvals = await client.db - .select({ gateKey: approvalRequests.gateKey, state: approvalRequests.state }) - .from(approvalRequests) - .where(eq(approvalRequests.runId, runId)); - expect(approvals).toEqual([{ gateKey: "session_recovery_required", state: "pending" }]); - const events = await client.db - .select({ type: runEvents.type }) - .from(runEvents) - .where(eq(runEvents.runId, runId)) - .orderBy(runEvents.seq); - expect(events.map((event) => event.type)).toEqual([ - "session.failed", - "run.paused", - "approval.requested", - ]); - }); - - it("retries transient session resume failures during startup recovery", async () => { - client = createDbClient(databaseUrl); - const templateId = randomUUID(); - const runId = randomUUID(); - const sessionId = randomUUID(); - const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-"))); - const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-"))); - tempRoots.push(repoPath, worktreeRoot); - templateIds.push(templateId); - runIds.push(runId); - const adapter = new ResumeSucceedsAfterTwoFailuresFakeSessionAdapter({ - sessionIdFactory: () => sessionId, - writeDelayMs: 0, - }); - await adapter.start({ - runId, - roleId: "spec_writer", - backend: "fake", - cwd: worktreeRoot, - }); - - await client.db.insert(workflowTemplates).values({ - id: templateId, - name: `api-session-retry-${templateId}`, - version: 1, - hash: "e".repeat(64), - definition: { name: "api-session-retry", version: 1, roles: [], phases: [] }, - }); - await client.db.insert(runs).values({ - id: runId, - templateId, - templateHash: "e".repeat(64), - state: "executing", - repoPath, - baseBranch: "main", - worktreeRoot, - }); - await client.db.insert(tuiSessions).values({ - id: sessionId, - runId, - roleId: "spec_writer", - backend: "fake", - cwd: worktreeRoot, - state: "READY", - }); - - const manager = new SessionManager({ - dbClient: client, - adapter, - recoveryRunIds: [runId], - }); - const recovery = await initializeManagerWhenLockFree(manager); - try { - expect(adapter.resumeAttempts).toBe(3); - expect(recovery).toEqual({ failedSessionIds: [], recoveredSessionIds: [sessionId] }); - const approvals = await client.db - .select() - .from(approvalRequests) - .where(eq(approvalRequests.runId, runId)); - expect(approvals).toEqual([]); - } finally { - await manager.shutdown(); - } - }); - - it("pauses a non-terminal run when SessionManager startup recovery cannot resume a session", async () => { - client = createDbClient(databaseUrl); - const templateId = randomUUID(); - const runId = randomUUID(); - const sessionId = randomUUID(); - const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-"))); - const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-"))); - tempRoots.push(repoPath, worktreeRoot); - templateIds.push(templateId); - runIds.push(runId); - - await client.db.insert(workflowTemplates).values({ - id: templateId, - name: `api-session-recovery-failure-${templateId}`, - version: 1, - hash: "d".repeat(64), - definition: { name: "api-session-recovery-failure", version: 1, roles: [], phases: [] }, - }); - await client.db.insert(runs).values({ - id: runId, - templateId, - templateHash: "d".repeat(64), - state: "executing", - repoPath, - baseBranch: "main", - worktreeRoot, - }); - await client.db.insert(tuiSessions).values({ - id: sessionId, - runId, - roleId: "spec_writer", - backend: "fake", - cwd: worktreeRoot, - state: "READY", - }); - - const adapter = new ResumeFailsFakeSessionAdapter(); - const manager = new SessionManager({ - dbClient: client, - adapter, - recoveryRunIds: [runId], - }); - const recovery = await initializeManagerWhenLockFree(manager); - try { - expect(adapter.resumeAttempts).toBe(3); - expect(recovery).toEqual({ failedSessionIds: [sessionId], recoveredSessionIds: [] }); - const [run] = await client.db - .select({ pausedFromState: runs.pausedFromState, state: runs.state }) - .from(runs) - .where(eq(runs.id, runId)); - expect(run).toEqual({ pausedFromState: "executing", state: "paused" }); - const [session] = await client.db - .select({ recoveryAttempts: tuiSessions.recoveryAttempts, state: tuiSessions.state }) - .from(tuiSessions) - .where(eq(tuiSessions.id, sessionId)); - expect(session).toEqual({ recoveryAttempts: 1, state: "FAILED_NEEDS_HUMAN" }); - const [approval] = await client.db - .select({ - gateKey: approvalRequests.gateKey, - phaseId: approvalRequests.phaseId, - state: approvalRequests.state, - }) - .from(approvalRequests) - .where(eq(approvalRequests.runId, runId)); - expect(approval).toEqual({ - gateKey: "session_recovery_required", - phaseId: null, - state: "pending", - }); - const events = await client.db - .select({ type: runEvents.type }) - .from(runEvents) - .where(eq(runEvents.runId, runId)) - .orderBy(runEvents.seq); - expect(events.map((event) => event.type)).toEqual([ - "session.failed", - "run.paused", - "approval.requested", - ]); - } finally { - await manager.shutdown(); - } - }); - - it("keeps the singleton lock while shutdown drains in-flight session operations", async () => { - client = createDbClient(databaseUrl); - const adapter = new DelayedSendPromptFakeSessionAdapter({ writeDelayMs: 0 }); - const manager = new SessionManager({ - dbClient: client, - adapter, - recoveryRunIds: [], - shutdownDrainMs: 5_000, - }); - await initializeManagerWhenLockFree(manager); - const runId = randomUUID(); - const cwd = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-session-"))); - tempRoots.push(cwd); - const handle = await manager.start({ - runId, - roleId: "spec_writer", - backend: "fake", - cwd, - }); - const envelope: PromptEnvelope = { - uuid: randomUUID(), - runId, - roleId: "spec_writer", - phaseKey: "spec", - attempt: 0, - expectedArtifact: join(tmpdir(), `${randomUUID()}.json`), - expectedSchema: "dev/spec@1", - dedupKey: `dedup-${randomUUID()}`, - instructions: "Scenario: timeout", - }; - const promptPromise = manager.sendPrompt(handle, envelope); - await adapter.promptStarted.promise; - - const shutdownPromise = manager.shutdown(); - await expect( - new SessionManager({ - dbClient: client, - adapter: new FakeSessionAdapter(), - recoveryRunIds: [], - }).initialize(), - ).rejects.toMatchObject({ code: "session_manager_already_running" }); - - adapter.releasePrompt.resolve(undefined); - await expect(promptPromise).resolves.toEqual({ promptId: envelope.dedupKey }); - await shutdownPromise; - - const nextManager = new SessionManager({ - dbClient: client, - adapter: new FakeSessionAdapter(), - recoveryRunIds: [], - }); - await expect(initializeManagerWhenLockFree(nextManager)).resolves.toEqual({ - failedSessionIds: [], - recoveredSessionIds: [], - }); - await nextManager.shutdown(); - }); - - it("keeps the singleton lock while shutdown drains in-flight artifact polling", async () => { - client = createDbClient(databaseUrl); - await seedDevelopmentRegistry(client.db); - const workspaceRoot = createApiWorkspaceRoot(); - const repoPath = createGitRepo(); - tempRoots.push(repoPath); - const runId = randomUUID(); - runIds.push(runId); - const api = await startM4ApiWhenLockFree({ - dbClient: client, - workspaceRoot, - recoveryRunIds: [], - maxConcurrentRuns: 100, - sessionAdapter: new FakeSessionAdapter({ writeDelayMs: 1_000 }), - }); - const startPromise = api.engine.startRun({ - runId, - requirementsMd: "Keep artifact polling in flight during shutdown.", - repoPath, - baseBranch: "main", - scenarios: { spec: "ok" }, - }); - await waitForRunEventType(client.db, runId, "artifact.expected"); - - const stopPromise = api.stop(); - await expect( - new SessionManager({ - dbClient: client, - adapter: new FakeSessionAdapter(), - recoveryRunIds: [], - }).initialize(), - ).rejects.toMatchObject({ code: "session_manager_already_running" }); - - await expect(startPromise).resolves.toEqual({ runId }); - await stopPromise; - const nextManager = new SessionManager({ - dbClient: client, - adapter: new FakeSessionAdapter(), - recoveryRunIds: [], - }); - await expect(initializeManagerWhenLockFree(nextManager)).resolves.toEqual({ - failedSessionIds: [], - recoveredSessionIds: [], - }); - await nextManager.shutdown(); - }); -}); - -async function startM4ApiWhenLockFree(options: Parameters[0]) { - const deadline = Date.now() + 6_000; - let lastError: unknown; - while (Date.now() < deadline) { - try { - return await startM4Api(options); - } catch (error) { - lastError = error; - if (!(error instanceof DevflowError) || error.code !== "session_manager_already_running") { - throw error; - } - await new Promise((resolveWait) => setTimeout(resolveWait, 50)); - } - } - throw lastError; -} - -async function initializeManagerWhenLockFree(manager: SessionManager) { - const deadline = Date.now() + 6_000; - let lastError: unknown; - while (Date.now() < deadline) { - try { - return await manager.initialize(); - } catch (error) { - lastError = error; - if (!(error instanceof DevflowError) || error.code !== "session_manager_already_running") { - throw error; - } - await new Promise((resolveWait) => setTimeout(resolveWait, 50)); - } - } - throw lastError; -} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts deleted file mode 100644 index 01bec7e..0000000 --- a/apps/api/src/index.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { type BackendConfig, getConfig } from "@devflow/core"; -import { DevflowError } from "@devflow/core"; -import { type DbClient, createDbClient } from "@devflow/db"; -import { DbRunEngine, type RunEngine, readRunStatus } from "@devflow/run-engine"; -import { - FakeSessionAdapter, - type SessionAdapter, - SessionManager, - type SessionManagerRecoveryResult, - type SessionRuntime, -} from "@devflow/session"; -import { TemporalRunEngine, temporalNamespace } from "@devflow/workflows"; -import { Connection, WorkflowClient } from "@temporalio/client"; - -import { createHttpApi } from "./http.js"; -import { recoverM4ApiStartup, startM4SessionManager } from "./startup.js"; - -export * from "./http.js"; -export * from "./sse.js"; -export * from "./startup.js"; - -export interface StartM4ApiOptions { - dbClient?: DbClient; - workspaceRoot?: string; - availableBackends?: readonly BackendConfig[]; - recoveryRunIds?: readonly string[]; - sessionAdapter?: SessionAdapter; - sessionManager?: SessionManager; - runEngine?: RunEngine; - maxConcurrentRuns?: number; - sessionMaxHungMs?: number; -} - -export interface StartM4ApiResult { - recovery: Awaited>; - sessionRecovery: SessionManagerRecoveryResult; - sessionManager: SessionManager; - engine: RunEngine; - finalReportRecovery: string[]; - stop(): Promise; -} - -export interface StartTemporalApiOptions { - dbClient?: DbClient; - temporalClient?: WorkflowClient; - temporalAddress?: string; - taskQueue?: string; - workflowIdPrefix?: string; - awaitRunStart?: boolean; - awaitSignals?: boolean; - availableBackends?: readonly BackendConfig[]; - maxConcurrentRuns?: number; - workspaceRoot?: string; - sessionMaxHungMs?: number; -} - -export interface StartTemporalApiResult { - engine: RunEngine; - stop(): Promise; -} - -export type StartApiOptions = StartTemporalApiOptions; -export type StartApiResult = StartTemporalApiResult; - -export interface StartHttpApiOptions extends StartTemporalApiOptions { - host?: string; - port?: number; -} - -export interface StartHttpApiResult extends StartTemporalApiResult { - url: string; -} - -export async function startApi(options: StartApiOptions = {}): Promise { - return startTemporalApi(options); -} - -export async function startHttpApi(options: StartHttpApiOptions = {}): Promise { - const config = options.dbClient === undefined ? getConfig() : undefined; - const ownedClient = options.dbClient === undefined; - const dbClient = - options.dbClient ?? createDbClient(config?.DATABASE_URL ?? getConfig().DATABASE_URL); - const api = await startTemporalApi({ ...options, dbClient }); - const server = await createHttpApi({ db: dbClient.db, engine: api.engine }); - const host = options.host ?? "127.0.0.1"; - const port = options.port ?? 3000; - try { - const url = await server.listen({ host, port }); - return { - ...api, - url, - async stop() { - try { - await server.close(); - } finally { - try { - await api.stop(); - } finally { - if (ownedClient) { - await dbClient.close(); - } - } - } - }, - }; - } catch (error) { - await server.close().catch(() => undefined); - await api.stop().catch(() => undefined); - if (ownedClient) { - await dbClient.close(); - } - throw error; - } -} - -export async function startM4Api(options: StartM4ApiOptions = {}): Promise { - const ownedClient = options.dbClient === undefined; - const config = ownedClient || options.workspaceRoot === undefined ? getConfig() : undefined; - const dbClient = - options.dbClient ?? createDbClient(config?.DATABASE_URL ?? getConfig().DATABASE_URL); - const sessionMaxHungMs = options.sessionMaxHungMs ?? config?.SESSION_MAX_HUNG_MS; - const sessionManager = - options.sessionManager ?? - new SessionManager({ - dbClient, - adapter: options.sessionAdapter ?? new FakeSessionAdapter(), - ...(options.recoveryRunIds === undefined ? {} : { recoveryRunIds: options.recoveryRunIds }), - }); - const engine = - options.runEngine ?? - new DbRunEngine({ - db: dbClient.db, - sessions: sessionManager, - workspaceRoot: options.workspaceRoot ?? config?.WORKSPACE_ROOT ?? getConfig().WORKSPACE_ROOT, - ...(options.availableBackends === undefined - ? config?.backends === undefined - ? {} - : { availableBackends: config.backends } - : { availableBackends: options.availableBackends }), - ...(options.maxConcurrentRuns === undefined - ? {} - : { maxConcurrentRuns: options.maxConcurrentRuns }), - ...(sessionMaxHungMs === undefined ? {} : { recovery: { maxHungMs: sessionMaxHungMs } }), - }); - - try { - await sessionManager.acquireLock(); - const recovery = await recoverM4ApiStartup( - dbClient.db, - options.recoveryRunIds === undefined ? {} : { runIds: options.recoveryRunIds }, - ); - const sessionRecovery = await startM4SessionManager(sessionManager); - const finalReportRecovery = - engine instanceof DbRunEngine - ? await engine.recoverMissingFinalReports( - options.recoveryRunIds === undefined ? {} : { runIds: options.recoveryRunIds }, - ) - : []; - return { - engine, - finalReportRecovery, - recovery, - sessionRecovery, - sessionManager, - async stop() { - try { - await sessionManager.shutdown(); - } finally { - if (ownedClient) { - await dbClient.close(); - } - } - }, - }; - } catch (error) { - if (options.sessionManager === undefined) { - await sessionManager.shutdown().catch(() => undefined); - } - if (ownedClient) { - await dbClient.close(); - } - throw error; - } -} - -export async function startTemporalApi( - options: StartTemporalApiOptions = {}, -): Promise { - const ownedClient = options.dbClient === undefined; - const config = - options.dbClient === undefined || options.temporalClient === undefined - ? getConfig() - : undefined; - const dbClient = - options.dbClient ?? createDbClient(config?.DATABASE_URL ?? getConfig().DATABASE_URL); - const ownedTemporalClient = options.temporalClient === undefined; - let connection: Connection | undefined; - let temporalClient: WorkflowClient; - if (options.temporalClient === undefined) { - connection = await Connection.connect({ - address: options.temporalAddress ?? config?.TEMPORAL_ADDRESS ?? getConfig().TEMPORAL_ADDRESS, - }); - temporalClient = new WorkflowClient({ connection, namespace: temporalNamespace }); - } else { - temporalClient = options.temporalClient; - } - const replayValidationWorkspaceRoot = - options.workspaceRoot ?? config?.WORKSPACE_ROOT ?? getConfig().WORKSPACE_ROOT; - const replayValidationBackends = options.availableBackends ?? config?.backends; - const replayValidationMaxConcurrentRuns = - options.maxConcurrentRuns ?? config?.MAX_CONCURRENT_RUNS; - const replayValidationSessionMaxHungMs = options.sessionMaxHungMs ?? config?.SESSION_MAX_HUNG_MS; - const replayValidationEngine = new DbRunEngine({ - db: dbClient.db, - sessions: dbOnlySessionRuntime(), - workspaceRoot: replayValidationWorkspaceRoot, - ...(replayValidationBackends === undefined - ? {} - : { availableBackends: replayValidationBackends }), - ...(replayValidationMaxConcurrentRuns === undefined - ? {} - : { maxConcurrentRuns: replayValidationMaxConcurrentRuns }), - ...(replayValidationSessionMaxHungMs === undefined - ? {} - : { recovery: { maxHungMs: replayValidationSessionMaxHungMs } }), - }); - const engine = new TemporalRunEngine({ - client: temporalClient, - startReplayValidator: { - validateStartReplay: (input) => replayValidationEngine.validatePreparedRunInput(input), - }, - approvalSignalReader: { - readApprovalSignalResult: (runId, approvalRequestId, action, clientToken) => - replayValidationEngine.readApprovalSignalResult( - runId, - approvalRequestId, - action, - clientToken, - ), - validateApprovalSignalInput: (runId, approvalRequestId, action, clientToken) => - replayValidationEngine.validateApprovalSignalInput( - runId, - approvalRequestId, - action, - clientToken, - ), - replayAppliedApprovalSideEffects: (runId, action) => - replayValidationEngine.replayAppliedApprovalSideEffects(runId, action, { - disposeSessions: false, - }), - }, - controlValidator: { - validateResumeSignalInput: (runId) => replayValidationEngine.validateResumeSignalInput(runId), - }, - statusReader: { - getStatus: (runId) => readRunStatus(dbClient.db, runId), - }, - ...(options.taskQueue === undefined ? {} : { taskQueue: options.taskQueue }), - ...(options.workflowIdPrefix === undefined - ? {} - : { workflowIdPrefix: options.workflowIdPrefix }), - ...(options.awaitRunStart === undefined ? {} : { awaitRunStart: options.awaitRunStart }), - ...(options.awaitSignals === undefined ? {} : { awaitSignals: options.awaitSignals }), - }); - - return { - engine, - async stop() { - if (ownedTemporalClient) { - await connection?.close(); - } - if (ownedClient) { - await dbClient.close(); - } - }, - }; -} - -function dbOnlySessionRuntime(): SessionRuntime { - const rejectMutation = (operation: string) => - Promise.reject( - new DevflowError("API replay validation cannot mutate TUI sessions", { - class: "fatal", - code: "internal_state_corruption", - recoveryHint: operation, - }), - ); - - return { - trackOperation: (operation) => operation, - start: () => rejectMutation("start"), - sendPrompt: () => rejectMutation("sendPrompt"), - probe: () => rejectMutation("probe"), - resume: () => rejectMutation("resume"), - rebootstrap: () => rejectMutation("rebootstrap"), - async *capture() { - yield await rejectMutation("capture"); - }, - dispose: () => rejectMutation("dispose"), - }; -} - -if (isDirectEntry(import.meta.url, process.argv)) { - startHttpApi() - .then(async (api) => { - await waitForShutdownSignal(); - await api.stop(); - }) - .catch((error: unknown) => { - console.error(error); - process.exitCode = - error instanceof DevflowError && error.code === "session_manager_already_running" ? 3 : 2; - }); -} - -function isDirectEntry(importMetaUrl: string, argv: readonly string[]): boolean { - const entry = argv[1]; - return entry !== undefined && resolve(entry) === fileURLToPath(importMetaUrl); -} - -function waitForShutdownSignal(): Promise { - return new Promise((resolveSignal) => { - const resolveOnce = () => { - process.off("SIGINT", resolveOnce); - process.off("SIGTERM", resolveOnce); - resolveSignal(); - }; - process.once("SIGINT", resolveOnce); - process.once("SIGTERM", resolveOnce); - }); -} diff --git a/apps/api/src/sse.ts b/apps/api/src/sse.ts deleted file mode 100644 index 2566f3f..0000000 --- a/apps/api/src/sse.ts +++ /dev/null @@ -1,443 +0,0 @@ -import type { ServerResponse } from "node:http"; - -import { and, asc, desc, eq, gt, lte } from "drizzle-orm"; - -import type { DbClient } from "@devflow/db"; -import { runEvents, tuiSessions, tuiTranscriptChunks } from "@devflow/db"; - -type Database = DbClient["db"]; - -export interface SseMessage { - event: string; - data: unknown; - id?: string; -} - -export interface RunEventRow { - id: bigint; - runId: string; - phaseId: string | null; - seq: bigint; - type: string; - payload: unknown; - ts: Date; -} - -export interface TranscriptChunkRow { - id: bigint; - runId: string; - sessionId: string; - roleId: string; - seq: bigint; - content: string; - capturedAt: Date; -} - -export function formatSseMessage(message: SseMessage): string { - const lines: string[] = []; - if (message.id !== undefined) { - lines.push(`id: ${message.id}`); - } - lines.push(`event: ${message.event}`); - lines.push(`data: ${JSON.stringify(message.data)}`); - return `${lines.join("\n")}\n\n`; -} - -export function formatSseComment(comment: string): string { - return `: ${comment}\n\n`; -} - -export function openSseResponse(response: ServerResponse): void { - response.writeHead(200, { - "cache-control": "no-cache", - connection: "keep-alive", - "content-type": "text/event-stream", - "x-accel-buffering": "no", - }); - response.write(formatSseComment("connected")); -} - -export function runEventMessages(row: RunEventRow, options: { deriveStateEvents: boolean }) { - const base = runEventAppendedMessage(row); - if (!options.deriveStateEvents) { - return [base]; - } - return [base, ...derivedRunEventMessages(row)]; -} - -export function runEventAppendedMessage(row: RunEventRow): SseMessage { - return { - id: row.seq.toString(), - event: "run.event_appended", - data: { - eventId: row.seq.toString(), - id: row.id.toString(), - payload: row.payload, - phaseId: row.phaseId, - runId: row.runId, - ts: row.ts.toISOString(), - type: row.type, - }, - }; -} - -export function transcriptChunkMessage(row: TranscriptChunkRow): SseMessage { - return { - event: "transcript.chunk_appended", - data: { - content: row.content.slice(0, 4096), - runId: row.runId, - roleId: row.roleId, - seq: row.seq.toString(), - sessionId: row.sessionId, - ts: row.capturedAt.toISOString(), - }, - }; -} - -export async function readRunEventRows( - db: Database, - runId: string, - afterSeq: bigint, - throughSeq?: bigint, -): Promise { - const conditions = [eq(runEvents.runId, runId), gt(runEvents.seq, afterSeq)]; - if (throughSeq !== undefined) { - conditions.push(lte(runEvents.seq, throughSeq)); - } - return db - .select({ - id: runEvents.id, - runId: runEvents.runId, - phaseId: runEvents.phaseId, - seq: runEvents.seq, - type: runEvents.type, - payload: runEvents.payload, - ts: runEvents.ts, - }) - .from(runEvents) - .where(and(...conditions)) - .orderBy(asc(runEvents.seq)) - .limit(100); -} - -export async function latestRunEventSeq(db: Database, runId: string): Promise { - const [row] = await db - .select({ seq: runEvents.seq }) - .from(runEvents) - .where(eq(runEvents.runId, runId)) - .orderBy(desc(runEvents.seq)) - .limit(1); - return row?.seq ?? 0n; -} - -export async function readGlobalRunEventRows( - db: Database, - afterId: bigint, -): Promise { - return db - .select({ - id: runEvents.id, - runId: runEvents.runId, - phaseId: runEvents.phaseId, - seq: runEvents.seq, - type: runEvents.type, - payload: runEvents.payload, - ts: runEvents.ts, - }) - .from(runEvents) - .where(gt(runEvents.id, afterId)) - .orderBy(asc(runEvents.id)) - .limit(100); -} - -export async function latestGlobalRunEventId(db: Database): Promise { - const [row] = await db - .select({ id: runEvents.id }) - .from(runEvents) - .orderBy(desc(runEvents.id)) - .limit(1); - return row?.id ?? 0n; -} - -export async function readTranscriptChunkRows( - db: Database, - runId: string, - afterId: bigint, -): Promise { - return db - .select({ - id: tuiTranscriptChunks.id, - runId: tuiSessions.runId, - sessionId: tuiTranscriptChunks.sessionId, - roleId: tuiSessions.roleId, - seq: tuiTranscriptChunks.seq, - content: tuiTranscriptChunks.content, - capturedAt: tuiTranscriptChunks.capturedAt, - }) - .from(tuiTranscriptChunks) - .innerJoin(tuiSessions, eq(tuiTranscriptChunks.sessionId, tuiSessions.id)) - .where(and(eq(tuiSessions.runId, runId), gt(tuiTranscriptChunks.id, afterId))) - .orderBy(asc(tuiTranscriptChunks.id)) - .limit(100); -} - -export async function latestTranscriptChunkId(db: Database, runId: string): Promise { - const [row] = await db - .select({ id: tuiTranscriptChunks.id }) - .from(tuiTranscriptChunks) - .innerJoin(tuiSessions, eq(tuiTranscriptChunks.sessionId, tuiSessions.id)) - .where(eq(tuiSessions.runId, runId)) - .orderBy(desc(tuiTranscriptChunks.id)) - .limit(1); - return row?.id ?? 0n; -} - -function derivedRunEventMessages(row: RunEventRow): SseMessage[] { - if (row.type.startsWith("run.")) { - const next = runStateForEvent(row.type, row.payload); - if (next === null) { - return []; - } - return [ - { - id: row.seq.toString(), - event: "run.state_changed", - data: { runId: row.runId, prev: stringPayload(row.payload, "pausedFromState"), next }, - }, - ]; - } - if (row.type.startsWith("phase.")) { - const phaseKey = stringPayload(row.payload, "phaseKey"); - const next = phaseStateForEvent(row.type); - if (next === null) { - return []; - } - const messages: SseMessage[] = []; - const runState = stringPayload(row.payload, "runState"); - if (row.type === "phase.started" && runState !== null) { - messages.push({ - id: row.seq.toString(), - event: "run.state_changed", - data: { runId: row.runId, prev: null, next: runState }, - }); - } - messages.push({ - id: row.seq.toString(), - event: "phase.state_changed", - data: { - runId: row.runId, - phaseId: row.phaseId, - phaseKey, - prev: null, - next, - }, - }); - return messages; - } - if (row.type === "approval.requested") { - const messages: SseMessage[] = [ - { - id: row.seq.toString(), - event: "approval.created", - data: { - approvalId: stringPayload(row.payload, "approvalRequestId"), - gateKey: stringPayload(row.payload, "gateKey"), - runId: row.runId, - }, - }, - ]; - const runState = stringPayload(row.payload, "runState"); - if (runState !== null) { - messages.push({ - id: row.seq.toString(), - event: "run.state_changed", - data: { runId: row.runId, prev: null, next: runState }, - }); - } - const phaseState = stringPayload(row.payload, "phaseState"); - if (phaseState !== null) { - messages.push({ - id: row.seq.toString(), - event: "phase.state_changed", - data: { - runId: row.runId, - phaseId: row.phaseId, - phaseKey: stringPayload(row.payload, "phaseKey"), - prev: null, - next: phaseState, - }, - }); - } - const sessionState = stringPayload(row.payload, "sessionState"); - const sessionId = stringPayload(row.payload, "sessionId"); - const roleId = stringPayload(row.payload, "roleId"); - if (sessionState !== null && sessionId !== null) { - messages.push({ - id: row.seq.toString(), - event: "session.state_changed", - data: { - next: sessionState, - prev: null, - roleId, - runId: row.runId, - sessionId, - }, - }); - } - return messages; - } - if (row.type === "approval.resolved") { - return [ - { - id: row.seq.toString(), - event: "approval.resolved", - data: { - action: stringPayload(row.payload, "action"), - approvalId: stringPayload(row.payload, "approvalRequestId"), - runId: row.runId, - }, - }, - ]; - } - if (row.type.startsWith("session.")) { - const next = sessionStateForEvent(row.type); - if (next === null) { - return []; - } - return [ - { - id: row.seq.toString(), - event: "session.state_changed", - data: { - next, - prev: null, - roleId: stringPayload(row.payload, "roleId"), - runId: row.runId, - sessionId: stringPayload(row.payload, "sessionId"), - }, - }, - ]; - } - if (row.type === "artifact.expected") { - return [ - { - id: row.seq.toString(), - event: "phase.state_changed", - data: { - runId: row.runId, - phaseId: row.phaseId, - phaseKey: stringPayload(row.payload, "phaseKey"), - prev: null, - next: "awaiting_artifact", - }, - }, - ]; - } - if (row.type === "artifact.validated" || row.type === "artifact.invalid") { - const messages: SseMessage[] = [ - { - id: row.seq.toString(), - event: "phase.state_changed", - data: { - runId: row.runId, - phaseId: row.phaseId, - phaseKey: stringPayload(row.payload, "phaseKey"), - prev: null, - next: "validating", - }, - }, - ]; - if (row.type === "artifact.validated") { - messages.push({ - id: row.seq.toString(), - event: "artifact.validated", - data: { - artifactId: stringPayload(row.payload, "artifactId"), - path: stringPayload(row.payload, "path"), - runId: row.runId, - schemaId: stringPayload(row.payload, "schemaId"), - valid: true, - }, - }); - } - return messages; - } - return []; -} - -function runStateForEvent(type: string, payload: unknown): string | null { - const explicit = stringPayload(payload, "state") ?? stringPayload(payload, "resumedTo"); - if (explicit !== null) { - return explicit; - } - if (type === "run.created") { - return "created"; - } - if (type === "run.started") { - return "bound"; - } - if (type === "run.paused") { - return "paused"; - } - if (type === "run.resumed") { - const cause = stringPayload(payload, "cause"); - if (cause?.endsWith(":request_changes")) { - return "planning"; - } - return "executing"; - } - if (type === "run.completed") { - return "completed"; - } - if (type === "run.failed") { - return "failed"; - } - if (type === "run.aborted") { - return "aborted"; - } - return null; -} - -function phaseStateForEvent(type: string): string | null { - if (type === "phase.started") { - return "running"; - } - if (type === "phase.completed") { - return "completed"; - } - if (type === "phase.failed") { - return "failed"; - } - if (type === "phase.skipped") { - return "skipped"; - } - return null; -} - -function sessionStateForEvent(type: string): string | null { - if (type === "session.created") { - return "CREATED"; - } - if (type === "session.ready" || type === "session.idle" || type === "session.recovered") { - return "READY"; - } - if (type === "session.busy") { - return "BUSY"; - } - if (type === "session.crashed") { - return "CRASHED"; - } - if (type === "session.failed") { - return "FAILED_NEEDS_HUMAN"; - } - return null; -} - -function stringPayload(payload: unknown, key: string): string | null { - if (payload === null || typeof payload !== "object") { - return null; - } - const value = (payload as Record)[key]; - return typeof value === "string" ? value : null; -} diff --git a/apps/api/src/startup.ts b/apps/api/src/startup.ts deleted file mode 100644 index db159b3..0000000 --- a/apps/api/src/startup.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { DbClient } from "@devflow/db"; -import { type M4ProcessRestartSweepOptions, sweepM4ProcessRestart } from "@devflow/run-engine"; -import type { SessionManager } from "@devflow/session"; - -export async function recoverM4ApiStartup( - db: DbClient["db"], - options: M4ProcessRestartSweepOptions = {}, -) { - return sweepM4ProcessRestart(db, options); -} - -export async function startM4SessionManager(sessionManager: SessionManager) { - return sessionManager.recoverSessions(); -} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json deleted file mode 100644 index 3507707..0000000 --- a/apps/api/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "types": ["node", "vitest"] - }, - "include": ["src/**/*.ts"], - "references": [ - { "path": "../../packages/core" }, - { "path": "../../packages/db" }, - { "path": "../../packages/run-engine" }, - { "path": "../../packages/session" }, - { "path": "../../packages/workflows" } - ] -} diff --git a/apps/cli/package.json b/apps/cli/package.json deleted file mode 100644 index ac9684a..0000000 --- a/apps/cli/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@devflow/cli", - "version": "0.0.0", - "private": true, - "type": "module", - "bin": { - "devflow": "./dist/index.js" - }, - "scripts": { - "build": "tsup src/index.ts --format esm --clean", - "typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit", - "test": "cd ../.. && vitest run --project apps/cli" - }, - "dependencies": { - "commander": "12.1.0", - "dotenv": "17.4.2", - "pg": "8.20.0", - "zod": "3.24.1" - } -} diff --git a/apps/cli/src/doctor.test.ts b/apps/cli/src/doctor.test.ts deleted file mode 100644 index 9fa9345..0000000 --- a/apps/cli/src/doctor.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - type DoctorCommandRunner, - doctorExitCode, - formatDoctorJson, - formatDoctorTable, - runDoctor, -} from "./doctor.js"; - -const passingRunner: DoctorCommandRunner = async (command, args = []) => { - if (command === "docker" && args.join(" ") === "compose ps postgres") { - return { - exitCode: 0, - stdout: "postgres running\n", - stderr: "", - }; - } - - if (command === "docker" && args.includes("pg_isready")) { - return { - exitCode: 0, - stdout: "/var/run/postgresql:5432 - accepting connections\n", - stderr: "", - }; - } - - if (command === "df") { - return { - exitCode: 0, - stdout: - "Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/disk 20000000 1 19999999 1% /\n", - stderr: "", - }; - } - - const stdoutByCommand: Record = { - pnpm: "9.15.9\n", - tmux: "tmux 3.4\n", - git: "git version 2.45.0\n", - docker: "29.3.0\n", - }; - - return { - exitCode: 0, - stdout: stdoutByCommand[command] ?? "", - stderr: "", - }; -}; - -describe("doctor", () => { - it("emits the closed M1 check set in order", async () => { - const results = await runDoctor({ - commandRunner: passingRunner, - connectDatabase: async () => undefined, - countAppliedMigrations: async () => 1, - countExpectedMigrations: () => 1, - dockerComposePath: "docker", - env: { - DATABASE_URL: "postgres://devflow:devflow@127.0.0.1:55432/devflow", - WORKSPACE_ROOT: process.cwd(), - LOG_LEVEL: "info", - TEMPORAL_ADDRESS: "localhost:7233", - }, - nodeVersion: "22.11.0", - }); - - expect(results.map((result) => result.name)).toEqual([ - "node", - "pnpm", - "tmux", - "git", - "docker", - "postgres", - "drizzle_migrations", - "workspace_root", - "config", - "codex", - "claude", - "workspace_disk", - ]); - expect(doctorExitCode(results)).toBe(0); - }); - - it("returns exit code 1 when any hard check fails", () => { - expect( - doctorExitCode([ - { - name: "node", - status: "fail", - detail: "Node 21 is unsupported", - remediation: "Install Node 22", - }, - ]), - ).toBe(1); - }); - - it("surfaces command failures before parsing version output", async () => { - const results = await runDoctor({ - commandRunner: async (command, args) => { - if (command === "tmux") { - return { - exitCode: 127, - stdout: "", - stderr: "command not found: tmux", - }; - } - - return passingRunner(command, args); - }, - connectDatabase: async () => undefined, - countAppliedMigrations: async () => 1, - countExpectedMigrations: () => 1, - env: { - DATABASE_URL: "postgres://devflow:devflow@127.0.0.1:55432/devflow", - WORKSPACE_ROOT: process.cwd(), - LOG_LEVEL: "info", - TEMPORAL_ADDRESS: "localhost:7233", - }, - nodeVersion: "22.11.0", - }); - - expect(results.find((result) => result.name === "tmux")).toMatchObject({ - status: "fail", - detail: "command not found: tmux", - }); - }); - - it("formats human and JSON output", () => { - const result = { - name: "node", - status: "pass" as const, - detail: "22.11.0", - remediation: "", - }; - - expect(formatDoctorTable([result])).toContain("node"); - expect(JSON.parse(formatDoctorJson([result]))).toEqual([result]); - }); -}); diff --git a/apps/cli/src/doctor.ts b/apps/cli/src/doctor.ts deleted file mode 100644 index 967443d..0000000 --- a/apps/cli/src/doctor.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { execFile } from "node:child_process"; -import { constants } from "node:fs"; -import { access, readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { promisify } from "node:util"; -import { Pool } from "pg"; - -import { type Config, loadConfigFromSources } from "../../../packages/core/src/config.js"; - -const execFileAsync = promisify(execFile); - -export type DoctorStatus = "pass" | "fail" | "warn"; - -export interface DoctorResult { - name: string; - status: DoctorStatus; - detail: string; - remediation: string; -} - -export interface CommandResult { - exitCode: number; - stdout: string; - stderr: string; -} - -export type DoctorCommandRunner = ( - command: string, - args?: string[], - options?: { cwd?: string; env?: NodeJS.ProcessEnv }, -) => Promise; - -export interface DoctorOptions { - cwd?: string; - env?: Record; - nodeVersion?: string; - commandRunner?: DoctorCommandRunner; - connectDatabase?: (databaseUrl: string) => Promise; - countAppliedMigrations?: (databaseUrl: string) => Promise; - countExpectedMigrations?: (cwd: string) => Promise | number; - dockerComposePath?: string; -} - -const MIN_DISK_KB = 5 * 1024 * 1024; -const WARN_DISK_KB = 10 * 1024 * 1024; -const FAIL_DISK_KB = 2 * 1024 * 1024; - -export async function runDoctor(options: DoctorOptions = {}): Promise { - const cwd = options.cwd ?? process.cwd(); - const env = options.env ?? process.env; - const commandRunner = options.commandRunner ?? defaultCommandRunner; - const dockerCommand = options.dockerComposePath ?? "docker"; - const results: DoctorResult[] = []; - const configResult = loadDoctorConfig(cwd, env); - - results.push(checkNodeVersion(options.nodeVersion ?? process.versions.node)); - results.push(await checkCommandVersion("pnpm", ["--version"], "9.0.0", commandRunner)); - results.push(await checkCommandVersion("tmux", ["-V"], "3.3.0", commandRunner)); - results.push(await checkCommandVersion("git", ["--version"], "2.40.0", commandRunner)); - results.push(await checkDocker(commandRunner)); - results.push( - await checkPostgres({ - commandRunner, - config: configResult.config, - configError: configResult.error, - connectDatabase: options.connectDatabase ?? defaultConnectDatabase, - dockerCommand, - cwd, - }), - ); - results.push( - await checkMigrations({ - config: configResult.config, - configError: configResult.error, - countAppliedMigrations: options.countAppliedMigrations ?? defaultCountAppliedMigrations, - countExpectedMigrations: options.countExpectedMigrations ?? defaultCountExpectedMigrations, - cwd, - }), - ); - results.push(await checkWorkspaceRoot(configResult.config, configResult.error)); - results.push(checkConfig(configResult.config, configResult.error)); - results.push(await checkOptionalBinary("codex", commandRunner)); - results.push(await checkOptionalBinary("claude", commandRunner)); - results.push(await checkWorkspaceDisk(configResult.config, commandRunner)); - - return results; -} - -function loadDoctorConfig( - cwd: string, - env: Record, -): { config?: Config; error?: unknown } { - try { - return { config: loadConfigFromSources({ cwd, env }) }; - } catch (error) { - return { error }; - } -} - -function checkNodeVersion(version: string): DoctorResult { - if (satisfiesMin(version, "22.0.0") && compareVersions(version, "23.0.0") < 0) { - return pass("node", version, "Node is within >=22.0.0 <23"); - } - - return fail("node", version, "Install Node 22 LTS and rerun doctor"); -} - -async function checkCommandVersion( - name: string, - args: string[], - minimum: string, - commandRunner: DoctorCommandRunner, -): Promise { - const result = await safeRun(commandRunner, name, args); - - if (!result.ok) { - return fail(name, result.error, `Install ${name} >= ${minimum}`); - } - - if (result.value.exitCode !== 0) { - return fail( - name, - (result.value.stderr || result.value.stdout).trim(), - `Install ${name} >= ${minimum}`, - ); - } - - const version = extractVersion(result.value.stdout); - if (!version) { - return fail(name, result.value.stdout.trim(), `Ensure ${name} reports a version`); - } - - return satisfiesMin(version, minimum) - ? pass(name, version, `${name} satisfies >= ${minimum}`) - : fail(name, version, `Upgrade ${name} to >= ${minimum}`); -} - -async function checkDocker(commandRunner: DoctorCommandRunner): Promise { - const result = await safeRun(commandRunner, "docker", ["info", "--format", "{{.ServerVersion}}"]); - - if (!result.ok) { - return fail("docker", result.error, "Start Docker and ensure docker is in PATH"); - } - - return result.value.exitCode === 0 - ? pass("docker", result.value.stdout.trim(), "Docker daemon is reachable") - : fail("docker", result.value.stderr.trim(), "Start Docker and rerun doctor"); -} - -async function checkPostgres(input: { - commandRunner: DoctorCommandRunner; - config: Config | undefined; - configError: unknown; - connectDatabase: (databaseUrl: string) => Promise; - dockerCommand: string; - cwd: string; -}): Promise { - if (!input.config) { - return fail("postgres", errorDetail(input.configError), "Fix Config before checking Postgres"); - } - - const compose = await safeRun( - input.commandRunner, - input.dockerCommand, - ["compose", "ps", "postgres"], - { - cwd: input.cwd, - }, - ); - - if (!compose.ok || compose.value.exitCode !== 0 || !compose.value.stdout.includes("postgres")) { - return fail( - "postgres", - compose.ok ? compose.value.stderr : compose.error, - "Run docker compose up -d postgres", - ); - } - - const ready = await safeRun(input.commandRunner, input.dockerCommand, [ - "compose", - "exec", - "-T", - "postgres", - "pg_isready", - "-U", - "devflow", - "-d", - "devflow", - ]); - - if (!ready.ok || ready.value.exitCode !== 0) { - return fail( - "postgres", - ready.ok ? ready.value.stderr : ready.error, - "Wait for Postgres healthcheck to pass", - ); - } - - try { - await input.connectDatabase(input.config.DATABASE_URL); - return pass("postgres", "connected", "Postgres is reachable and accepts DATABASE_URL"); - } catch (error) { - return fail("postgres", errorDetail(error), "Check DATABASE_URL and container health"); - } -} - -async function checkMigrations(input: { - config: Config | undefined; - configError: unknown; - countAppliedMigrations: (databaseUrl: string) => Promise; - countExpectedMigrations: (cwd: string) => Promise | number; - cwd: string; -}): Promise { - if (!input.config) { - return fail( - "drizzle_migrations", - errorDetail(input.configError), - "Fix Config before checking migrations", - ); - } - - try { - const [applied, expected] = await Promise.all([ - input.countAppliedMigrations(input.config.DATABASE_URL), - input.countExpectedMigrations(input.cwd), - ]); - - if (applied >= expected) { - return pass("drizzle_migrations", `${applied}/${expected}`, "No pending migrations"); - } - - return fail("drizzle_migrations", `${applied}/${expected}`, "Run pnpm db:migrate"); - } catch (error) { - return fail( - "drizzle_migrations", - errorDetail(error), - "Run pnpm db:migrate after Postgres is healthy", - ); - } -} - -async function checkWorkspaceRoot(config?: Config, configError?: unknown): Promise { - if (!config) { - return fail( - "workspace_root", - errorDetail(configError), - "Set WORKSPACE_ROOT to an existing writable path", - ); - } - - try { - await access(config.WORKSPACE_ROOT, constants.W_OK); - return pass("workspace_root", config.WORKSPACE_ROOT, "Workspace root is writable"); - } catch (error) { - return fail("workspace_root", errorDetail(error), "Create WORKSPACE_ROOT and make it writable"); - } -} - -function checkConfig(config?: Config, configError?: unknown): DoctorResult { - return config - ? pass("config", "valid", ".env resolved to a valid Config") - : fail( - "config", - errorDetail(configError), - "Set DATABASE_URL, WORKSPACE_ROOT, LOG_LEVEL, and TEMPORAL_ADDRESS", - ); -} - -async function checkOptionalBinary( - name: "codex" | "claude", - commandRunner: DoctorCommandRunner, -): Promise { - const result = await safeRun(commandRunner, name, ["--version"]); - - return result.ok && result.value.exitCode === 0 - ? pass(name, result.value.stdout.trim(), `${name} backend can be enabled`) - : warn(name, "not found", `${name} is only required for real backend opt-in`); -} - -async function checkWorkspaceDisk( - config: Config | undefined, - commandRunner: DoctorCommandRunner, -): Promise { - if (!config) { - return warn("workspace_disk", "unknown", "Fix Config before checking free disk"); - } - - const result = await safeRun(commandRunner, "df", ["-Pk", config.WORKSPACE_ROOT]); - if (!result.ok || result.value.exitCode !== 0) { - return warn( - "workspace_disk", - result.ok ? result.value.stderr : result.error, - "Ensure df is available", - ); - } - - const availableKb = Number.parseInt(result.value.stdout.trim().split(/\s+/).at(-3) ?? "", 10); - if (Number.isNaN(availableKb)) { - return warn("workspace_disk", result.value.stdout.trim(), "Unable to parse df output"); - } - - if (availableKb < FAIL_DISK_KB) { - return fail( - "workspace_disk", - `${availableKb} KB free`, - "Free at least 5GB under WORKSPACE_ROOT", - ); - } - - if (availableKb < WARN_DISK_KB || availableKb < MIN_DISK_KB) { - return warn( - "workspace_disk", - `${availableKb} KB free`, - "Free space is below the recommended 10GB", - ); - } - - return pass( - "workspace_disk", - `${availableKb} KB free`, - "Workspace partition has enough free space", - ); -} - -export function doctorExitCode(results: DoctorResult[]): 0 | 1 { - return results.some((result) => result.status === "fail") ? 1 : 0; -} - -export function formatDoctorJson(results: DoctorResult[]): string { - return `${JSON.stringify(results, null, 2)}\n`; -} - -export function formatDoctorTable(results: DoctorResult[]): string { - const nameWidth = Math.max(...results.map((result) => result.name.length), "check".length); - const statusWidth = "status".length; - const lines = [ - `${"check".padEnd(nameWidth)} ${"status".padEnd(statusWidth)} detail`, - `${"-".repeat(nameWidth)} ${"-".repeat(statusWidth)} ${"-".repeat(6)}`, - ...results.map( - (result) => - `${result.name.padEnd(nameWidth)} ${result.status.padEnd(statusWidth)} ${result.detail}${ - result.remediation ? ` (${result.remediation})` : "" - }`, - ), - ]; - - return `${lines.join("\n")}\n`; -} - -async function defaultCommandRunner( - command: string, - args: string[] = [], - options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, -): Promise { - try { - const result = await execFileAsync(command, args, { - cwd: options.cwd, - env: options.env, - timeout: 15_000, - }); - - return { - exitCode: 0, - stdout: result.stdout, - stderr: result.stderr, - }; - } catch (error) { - const maybeError = error as { - code?: string | number; - stdout?: string; - stderr?: string; - message?: string; - }; - return { - exitCode: typeof maybeError.code === "number" ? maybeError.code : 1, - stdout: maybeError.stdout ?? "", - stderr: maybeError.stderr || maybeError.message || String(error), - }; - } -} - -async function defaultConnectDatabase(databaseUrl: string): Promise { - const pool = new Pool({ connectionString: databaseUrl }); - try { - await pool.query("select 1"); - } finally { - await pool.end(); - } -} - -async function defaultCountAppliedMigrations(databaseUrl: string): Promise { - const pool = new Pool({ connectionString: databaseUrl }); - try { - const result = await pool.query<{ count: string }>( - "select count(*)::text as count from drizzle.__drizzle_migrations", - ); - - return Number.parseInt(result.rows[0]?.count ?? "0", 10); - } catch { - return 0; - } finally { - await pool.end(); - } -} - -async function defaultCountExpectedMigrations(cwd: string): Promise { - const journalPath = resolve(cwd, "packages/db/src/migrations/meta/_journal.json"); - const journal = JSON.parse(await readFile(journalPath, "utf8")) as { entries?: unknown[] }; - - return journal.entries?.length ?? 0; -} - -async function safeRun( - commandRunner: DoctorCommandRunner, - command: string, - args: string[] = [], - options?: { cwd?: string; env?: NodeJS.ProcessEnv }, -): Promise<{ ok: true; value: CommandResult } | { ok: false; error: string }> { - try { - return { ok: true, value: await commandRunner(command, args, options) }; - } catch (error) { - return { ok: false, error: errorDetail(error) }; - } -} - -function extractVersion(output: string): string | undefined { - return output.match(/\d+\.\d+\.\d+/)?.[0] ?? output.match(/\d+\.\d+/)?.[0]; -} - -function satisfiesMin(version: string, minimum: string): boolean { - return compareVersions(version, minimum) >= 0; -} - -function compareVersions(left: string, right: string): number { - const leftParts = left.split(".").map((part) => Number.parseInt(part, 10)); - const rightParts = right.split(".").map((part) => Number.parseInt(part, 10)); - - for (let index = 0; index < 3; index += 1) { - const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); - if (diff !== 0) { - return diff; - } - } - - return 0; -} - -function pass(name: string, detail: string, remediation: string): DoctorResult { - return { name, status: "pass", detail, remediation }; -} - -function fail(name: string, detail: string, remediation: string): DoctorResult { - return { name, status: "fail", detail, remediation }; -} - -function warn(name: string, detail: string, remediation: string): DoctorResult { - return { name, status: "warn", detail, remediation }; -} - -function errorDetail(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts deleted file mode 100644 index e5b3d6f..0000000 --- a/apps/cli/src/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node - -import { Command } from "commander"; - -import { doctorExitCode, formatDoctorJson, formatDoctorTable, runDoctor } from "./doctor.js"; - -const program = new Command(); - -program.name("devflow").description("Local agentic engineering workflow runner"); - -program - .command("doctor") - .description("Check local Devflow prerequisites") - .option("--json", "print machine-readable JSON") - .option("--quiet", "print only non-passing checks") - .action(async (options: { json?: boolean; quiet?: boolean }) => { - try { - const results = await runDoctor(); - const visibleResults = options.quiet - ? results.filter((result) => result.status !== "pass") - : results; - - if (options.json) { - process.stdout.write(formatDoctorJson(visibleResults)); - } else if (!options.quiet || visibleResults.length > 0) { - process.stdout.write(formatDoctorTable(visibleResults)); - } - - process.exitCode = doctorExitCode(results); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exitCode = 2; - } - }); - -await program.parseAsync(process.argv); diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json deleted file mode 100644 index 5f16ab1..0000000 --- a/apps/cli/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "types": ["node", "vitest"] - }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../../packages/core" }] -} diff --git a/apps/web/index.html b/apps/web/index.html deleted file mode 100644 index 23c73fa..0000000 --- a/apps/web/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Devflow - - -
- - - diff --git a/apps/web/package.json b/apps/web/package.json deleted file mode 100644 index 520877e..0000000 --- a/apps/web/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@devflow/web", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "dev": "vite --host 127.0.0.1", - "test": "cd ../.. && vitest run --project apps/web" - }, - "devDependencies": { - "vite": "6.0.3" - } -} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts deleted file mode 100644 index 8e67280..0000000 --- a/apps/web/src/api.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { normalizeApiBase } from "./view-model.js"; - -export interface ApiClient { - get(path: string): Promise; - post(path: string, body?: unknown): Promise; - sseUrl(path: string): string; -} - -export function createApiClient(baseValue: string | undefined): ApiClient { - const base = normalizeApiBase(baseValue); - return { - get: (path) => request("GET", `${base}${path}`), - post: (path, body) => request("POST", `${base}${path}`, body), - sseUrl: (path) => `${base}${path}`, - }; -} - -async function request(method: string, url: string, body?: unknown): Promise { - const init: RequestInit = { method }; - if (body !== undefined) { - init.headers = { "content-type": "application/json" }; - init.body = JSON.stringify(body); - } - const response = await fetch(url, init); - const payload = (await response.json().catch(() => ({}))) as unknown; - if (!response.ok) { - throw new Error(errorMessage(payload, response.status)); - } - return payload as T; -} - -function errorMessage(payload: unknown, status: number): string { - if (payload !== null && typeof payload === "object") { - const error = (payload as Record).error; - if (error !== null && typeof error === "object") { - const message = (error as Record).message; - if (typeof message === "string") { - return message; - } - } - } - return `HTTP ${status}`; -} diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts deleted file mode 100644 index 295ff13..0000000 --- a/apps/web/src/main.ts +++ /dev/null @@ -1,661 +0,0 @@ -import "./styles.css"; - -import { createApiClient } from "./api.js"; -import { - type ApprovalDecisionAction, - type DoctorCheck, - type RunSummary, - approvalDecisionActions, - compactPath, - formatDateTime, - highestDoctorStatus, - screenFromHash, - screens, - stateTone, -} from "./view-model.js"; - -interface TemplateSummary { - id: string; - name: string; - version: number; - hash: string; - definition: { phases?: unknown[]; roles?: unknown[] }; -} - -interface PersonaSummary { - id: string; - name: string; - version: number; - hash: string; - definition: { backend?: string; capabilities?: unknown[]; maxRiskLevel?: string }; -} - -interface RunStatus { - run: RunSummary & { - worktreeRoot: string; - finalReportPath: string | null; - }; - phases: Array<{ id: string; phaseKey: string; state: string; attempts: number }>; - approvals: Array<{ id: string; gateKey: string; state: string; phaseId: string | null }>; - eventsTail: Array<{ seq: string; type: string; ts: string; payload: unknown }>; -} - -interface SessionSummary { - id: string; - roleId: string; - backend: string; - cwd: string; - state: string; - expectedArtifactPath: string | null; - expectedSchema: string | null; - lastPromptAt: string | null; - recoveryAttempts: number; - tmuxSession: string | null; - tmuxWindow: string | null; -} - -const api = createApiClient(import.meta.env.VITE_API_BASE); -const appRoot = document.querySelector("#app"); - -if (appRoot === null) { - throw new Error("Missing #app root"); -} -const app = appRoot; - -let activeRunId: string | undefined; -let activeRunStatus: RunStatus | undefined; -let activeRunSessions: SessionSummary[] = []; -let connectedRunId: string | undefined; -let doctorChecks: DoctorCheck[] = []; -let eventSource: EventSource | undefined; -let liveEvents: string[] = []; -const approvalErrors = new Map(); -const approvalTokens = new Map(); - -window.addEventListener("hashchange", () => { - void render(); -}); - -void render(); - -async function render(): Promise { - const screen = screenFromHash(window.location.hash); - app.innerHTML = shell(screen, "Loading"); - try { - doctorChecks = await loadDoctorChecks(); - if (screen === "dashboard") { - await renderDashboard(); - } else if (screen === "run-detail") { - await renderRunDetailScreen(); - } else if (screen === "approvals") { - await renderApprovalsScreen(); - } else if (screen === "sessions") { - await renderSessionsScreen(); - } else if (screen === "templates") { - await renderTemplates(); - } else if (screen === "personas") { - await renderPersonas(); - } else { - await renderNewRun(); - } - } catch (error) { - app.innerHTML = shell(screen, errorBanner(error)); - } -} - -async function renderDashboard(): Promise { - const { runs, sessions, status } = await loadActiveRunData(); - app.innerHTML = shell( - "dashboard", - ` - ${doctorPanel()} -
-
-
-

Runs

- -
- ${runsTable(runs)} -
-
-
-

Run Detail

- ${activeRunId === undefined ? "" : `${escapeHtml(activeRunId)}`} -
- ${status === undefined ? empty("No run selected") : runDetail(status, sessions)} -
-
- `, - ); - bindDashboardActions(); - connectRunStream(activeRunId); -} - -async function renderRunDetailScreen(): Promise { - const { runs, sessions, status } = await loadActiveRunData(); - app.innerHTML = shell( - "run-detail", - ` -
-
-

Runs

- ${runsTable(runs)} -
-
-

Run Detail

${activeRunId === undefined ? "" : `${escapeHtml(activeRunId)}`}
- ${status === undefined ? empty("No run selected") : runDetail(status, sessions)} -
-
- `, - ); - bindDashboardActions(); - connectRunStream(activeRunId); -} - -async function renderApprovalsScreen(): Promise { - const { runs, status } = await loadActiveRunData(); - app.innerHTML = shell( - "approvals", - ` -
-
-

Runs

- ${runsTable(runs)} -
-
-

Approvals

${activeRunId === undefined ? "" : `${escapeHtml(activeRunId)}`}
- ${ - status === undefined || status.approvals.length === 0 - ? empty("No approvals") - : `${status.approvals.map(approvalRow).join("")}
` - } -
-
- `, - ); - bindDashboardActions(); - connectRunStream(activeRunId); -} - -async function renderSessionsScreen(): Promise { - const { runs, sessions } = await loadActiveRunData(); - app.innerHTML = shell( - "sessions", - ` -
-
-

Runs

- ${runsTable(runs)} -
-
-

TUI Sessions

${activeRunId === undefined ? "" : `${escapeHtml(activeRunId)}`}
- ${sessions.length === 0 ? empty("No sessions") : sessionsTable(sessions)} -
-
- `, - ); - bindDashboardActions(); - connectRunStream(activeRunId); -} - -async function renderTemplates(): Promise { - const { templates } = await api.get<{ templates: TemplateSummary[] }>("/api/templates"); - app.innerHTML = shell( - "templates", - ` -
-

Templates

${templates.length} loaded
- - - - ${templates - .map( - (template) => ` - - - - - - - - `, - ) - .join("")} - -
NameVersionRolesPhasesHash
${escapeHtml(template.name)}${template.version}${arrayLength(template.definition.roles)}${arrayLength(template.definition.phases)}${escapeHtml(template.hash.slice(0, 16))}
-
- `, - ); -} - -async function renderPersonas(): Promise { - const { personas } = await api.get<{ personas: PersonaSummary[] }>("/api/personas"); - app.innerHTML = shell( - "personas", - ` -
-

Personas

${personas.length} loaded
- - - - ${personas - .map( - (persona) => ` - - - - - - - - `, - ) - .join("")} - -
NameVersionBackendRiskCapabilities
${escapeHtml(persona.name)}${persona.version}${escapeHtml(persona.definition.backend ?? "")}${escapeHtml(persona.definition.maxRiskLevel ?? "")}${arrayLength(persona.definition.capabilities)}
-
- `, - ); -} - -async function renderNewRun(): Promise { - const [{ templates }, { personas }] = await Promise.all([ - api.get<{ templates: TemplateSummary[] }>("/api/templates"), - api.get<{ personas: PersonaSummary[] }>("/api/personas"), - ]); - app.innerHTML = shell( - "new-run", - ` -
-
-

New Run

- ${templates.length} templates · ${personas.length} personas -
-
- -
- - - - -
- -
-
-
- `, - ); - bindNewRunForm(); -} - -function shell(active: string, content: string): string { - return ` -
-
-

Devflow

-

Local workflow control plane

-
- -
-
${content}
- `; -} - -function doctorPanel(): string { - const status = highestDoctorStatus(doctorChecks); - const visible = doctorChecks.filter((check) => check.status !== "pass"); - return ` -
- Doctor - ${ - visible.length === 0 - ? "All visible checks pass" - : visible - .map((check) => `${escapeHtml(check.name)}: ${escapeHtml(check.detail)}`) - .join("") - } -
- `; -} - -function runsTable(runs: RunSummary[]): string { - if (runs.length === 0) { - return empty("No runs yet"); - } - return ` - - - - ${runs - .map( - (run) => ` - - - - - - - - `, - ) - .join("")} - -
StateRepoBranchCreated
${escapeHtml(run.state)}${escapeHtml(compactPath(run.repoPath))}${escapeHtml(run.baseBranch)}${escapeHtml(formatDateTime(run.createdAt))}
- `; -} - -function runDetail(status: RunStatus, sessions: SessionSummary[]): string { - return ` -
-
State${escapeHtml(status.run.state)}
-
Worktree${escapeHtml(compactPath(status.run.worktreeRoot))}
-
Final report${escapeHtml(status.run.finalReportPath ?? "")}
-
-

Phases

- - - - ${status.phases - .map( - (phase) => - ``, - ) - .join("")} - -
PhaseStateAttempts
${escapeHtml(phase.phaseKey)}${escapeHtml(phase.state)}${phase.attempts}
-

Approvals

- ${ - status.approvals.length === 0 - ? empty("No approvals") - : `${status.approvals.map(approvalRow).join("")}
` - } -

TUI Sessions

- ${sessions.length === 0 ? empty("No sessions") : sessionsTable(sessions)} -

Event Tail

-
${escapeHtml([...status.eventsTail.map((event) => `${event.seq} ${event.type}`), ...liveEvents].slice(-40).join("\n"))}
- `; -} - -function sessionsTable(sessions: SessionSummary[]): string { - return ` - - ${sessions.map(sessionRow).join("")} -
RoleBackendStateCWDArtifactRecovery
`; -} - -function sessionRow(session: SessionSummary): string { - const artifact = - session.expectedArtifactPath === null - ? "" - : `${session.expectedSchema ?? ""} ${compactPath(session.expectedArtifactPath)}`; - return ` - - ${escapeHtml(session.roleId)} - ${escapeHtml(session.backend)} - ${escapeHtml(session.state)} - ${escapeHtml(compactPath(session.cwd))} - ${escapeHtml(artifact)} - ${session.recoveryAttempts} - - `; -} - -function approvalRow(approval: RunStatus["approvals"][number]): string { - const disabled = approval.state !== "pending" ? "disabled" : ""; - const actionCells = approvalDecisionActions - .map((action) => approvalButton(approval.id, action, disabled)) - .join(""); - const error = approvalDecisionActions - .map((action) => approvalErrors.get(approvalActionKey(approval.id, action))) - .find((message) => message !== undefined); - return ` - - ${escapeHtml(approval.gateKey)} - ${escapeHtml(approval.state)} - - ${actionCells} - - - ${error === undefined ? "" : `${escapeHtml(error)}`} - `; -} - -function approvalButton( - approvalId: string, - action: ApprovalDecisionAction, - disabled: string, -): string { - const danger = action === "reject" || action === "abort" ? " danger" : ""; - return ``; -} - -function approvalActionLabel(action: ApprovalDecisionAction): string { - if (action === "request_changes") { - return "Request Changes"; - } - return action.slice(0, 1).toUpperCase() + action.slice(1); -} - -function bindDashboardActions(): void { - document.querySelector('[data-action="refresh"]')?.addEventListener("click", () => { - void render(); - }); - for (const button of document.querySelectorAll("[data-run-id]")) { - button.addEventListener("click", () => { - activeRunId = button.dataset.runId; - liveEvents = []; - void render(); - }); - } - for (const button of document.querySelectorAll("[data-approval]")) { - button.addEventListener("click", async () => { - const action = parseApprovalAction(button.dataset.action); - if (activeRunId === undefined || button.dataset.approval === undefined || action === null) { - return; - } - const key = approvalActionKey(button.dataset.approval, action); - const clientToken = approvalTokens.get(key) ?? crypto.randomUUID(); - approvalTokens.set(key, clientToken); - disableApprovalButtons(button.dataset.approval); - try { - await api.post(`/api/runs/${activeRunId}/approvals/${button.dataset.approval}`, { - action, - clientToken, - }); - approvalTokens.delete(key); - approvalErrors.delete(key); - await render(); - } catch (error) { - approvalErrors.set(key, error instanceof Error ? error.message : String(error)); - await render(); - } - }); - } -} - -function parseApprovalAction(value: string | undefined): ApprovalDecisionAction | null { - return approvalDecisionActions.includes(value as ApprovalDecisionAction) - ? (value as ApprovalDecisionAction) - : null; -} - -function approvalActionKey(approvalId: string, action: ApprovalDecisionAction): string { - return `${approvalId}:${action}`; -} - -function disableApprovalButtons(approvalId: string): void { - for (const button of document.querySelectorAll("[data-approval]")) { - if (button.dataset.approval === approvalId) { - button.disabled = true; - } - } -} - -function bindNewRunForm(): void { - document - .querySelector("#new-run-form") - ?.addEventListener("submit", async (event) => { - event.preventDefault(); - const form = event.currentTarget as HTMLFormElement; - const data = new FormData(form); - const status = document.querySelector("#form-status"); - try { - const scenariosText = stringFormValue(data, "scenarios"); - const body = { - baseBranch: stringFormValue(data, "baseBranch"), - repoPath: stringFormValue(data, "repoPath"), - requirementsMd: stringFormValue(data, "requirementsMd"), - templateName: stringFormValue(data, "templateName"), - templateVersion: Number(stringFormValue(data, "templateVersion")), - ...(scenariosText.length === 0 - ? {} - : { scenarios: JSON.parse(scenariosText) as unknown }), - }; - const result = await api.post<{ runId: string }>("/api/runs", body); - activeRunId = result.runId; - window.location.hash = "#/dashboard"; - } catch (error) { - if (status !== null) { - status.textContent = error instanceof Error ? error.message : String(error); - } - } - }); -} - -function connectRunStream(runId: string | undefined): void { - if (eventSource !== undefined && connectedRunId === runId) { - return; - } - eventSource?.close(); - eventSource = undefined; - connectedRunId = undefined; - if (runId === undefined) { - return; - } - const source = new EventSource(api.sseUrl(`/sse/runs/${runId}`)); - source.addEventListener("run.event_appended", (event) => { - const payload = JSON.parse(String((event as MessageEvent).data)) as { type?: string }; - liveEvents.push(`live ${payload.type ?? "event"}`); - if (liveEvents.length > 50) { - liveEvents = liveEvents.slice(-50); - } - void Promise.all([loadRunStatus(runId), loadRunSessions(runId)]).then(([status, sessions]) => { - activeRunStatus = status; - activeRunSessions = sessions; - const main = document.querySelector("main"); - const screen = screenFromHash(window.location.hash); - if ( - main !== null && - (screen === "dashboard" || - screen === "run-detail" || - screen === "approvals" || - screen === "sessions") - ) { - void render(); - } - }); - }); - source.addEventListener("transcript.chunk_appended", (event) => { - const payload = JSON.parse(String((event as MessageEvent).data)) as { content?: string }; - liveEvents.push(`transcript ${payload.content ?? ""}`); - }); - eventSource = source; - connectedRunId = runId; -} - -async function loadDoctorChecks(): Promise { - try { - const response = await api.get<{ checks: DoctorCheck[] }>("/api/doctor"); - return response.checks; - } catch { - return [ - { - detail: "API doctor endpoint unavailable", - name: "api", - remediation: "Start apps/api or configure VITE_API_BASE.", - status: "warn", - }, - ]; - } -} - -async function loadRunStatus(runId: string): Promise { - return api.get(`/api/runs/${runId}`); -} - -async function loadActiveRunData(): Promise<{ - runs: RunSummary[]; - sessions: SessionSummary[]; - status: RunStatus | undefined; -}> { - const { runs } = await api.get<{ runs: RunSummary[] }>("/api/runs"); - if (activeRunId === undefined && runs[0] !== undefined) { - activeRunId = runs[0].id; - } - if (activeRunId === undefined) { - activeRunStatus = undefined; - activeRunSessions = []; - return { runs, sessions: [], status: undefined }; - } - const [status, sessions] = await Promise.all([ - loadRunStatus(activeRunId), - loadRunSessions(activeRunId), - ]); - activeRunStatus = status; - activeRunSessions = sessions; - return { runs, sessions, status }; -} - -async function loadRunSessions(runId: string): Promise { - const response = await api.get<{ sessions: SessionSummary[] }>(`/api/runs/${runId}/sessions`); - return response.sessions; -} - -function screenLabel(screen: string): string { - if (screen === "new-run") { - return "New Run"; - } - if (screen === "run-detail") { - return "Run Detail"; - } - if (screen === "sessions") { - return "TUI Sessions"; - } - return screen - .split("-") - .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) - .join(" "); -} - -function stringFormValue(data: FormData, key: string): string { - const value = data.get(key); - return typeof value === "string" ? value : ""; -} - -function arrayLength(value: unknown): number { - return Array.isArray(value) ? value.length : 0; -} - -function empty(text: string): string { - return `
${escapeHtml(text)}
`; -} - -function errorBanner(error: unknown): string { - return `
Error${escapeHtml(error instanceof Error ? error.message : String(error))}
`; -} - -function escapeHtml(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css deleted file mode 100644 index b9d7bfe..0000000 --- a/apps/web/src/styles.css +++ /dev/null @@ -1,317 +0,0 @@ -:root { - color: #182026; - background: #f5f7f8; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", - sans-serif; - font-size: 14px; - letter-spacing: 0; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; -} - -button, -input, -textarea { - font: inherit; -} - -header { - align-items: center; - background: #ffffff; - border-bottom: 1px solid #d9e0e4; - display: flex; - justify-content: space-between; - min-height: 72px; - padding: 14px 24px; -} - -h1, -h2, -h3, -p { - margin: 0; -} - -h1 { - font-size: 20px; - line-height: 1.2; -} - -h2 { - font-size: 16px; -} - -h3 { - font-size: 13px; - margin: 18px 0 8px; - text-transform: uppercase; -} - -header p, -.section-title span, -.empty, -.detail-grid span { - color: #61717c; -} - -nav { - display: flex; - gap: 4px; -} - -nav a, -button { - border-radius: 6px; - text-decoration: none; -} - -nav a { - color: #36464f; - padding: 8px 10px; -} - -nav a.active { - background: #17324d; - color: #ffffff; -} - -main { - margin: 0 auto; - max-width: 1280px; - padding: 20px 24px 48px; -} - -.band { - background: #ffffff; - border: 1px solid #d9e0e4; - border-radius: 8px; - margin-top: 14px; - padding: 16px; -} - -.split { - display: grid; - gap: 16px; - grid-template-columns: minmax(360px, 0.9fr) minmax(420px, 1.1fr); -} - -.section-title, -.actions { - align-items: center; - display: flex; - gap: 10px; - justify-content: space-between; - margin-bottom: 12px; -} - -.doctor { - align-items: center; - border: 1px solid #d9e0e4; - border-radius: 8px; - display: flex; - gap: 12px; - padding: 10px 12px; -} - -.doctor.pass { - background: #eff8f2; - border-color: #b9dfc6; -} - -.doctor.warn { - background: #fff7e8; - border-color: #efcf91; -} - -.doctor.fail { - background: #fff0f0; - border-color: #e2a4a4; -} - -table { - border-collapse: collapse; - table-layout: fixed; - width: 100%; -} - -th, -td { - border-bottom: 1px solid #e5eaed; - overflow-wrap: anywhere; - padding: 9px 8px; - text-align: left; - vertical-align: middle; -} - -th { - color: #5e6d77; - font-size: 12px; - font-weight: 600; -} - -tr.selected { - background: #f0f5fa; -} - -button { - background: #17324d; - border: 1px solid #17324d; - color: #ffffff; - cursor: pointer; - min-height: 34px; - padding: 7px 12px; -} - -button.small { - min-height: 28px; - padding: 4px 8px; -} - -button.danger { - background: #7b2d2d; - border-color: #7b2d2d; -} - -button:disabled { - background: #c9d1d6; - border-color: #c9d1d6; - cursor: not-allowed; -} - -.pill { - border-radius: 999px; - display: inline-block; - font-size: 12px; - font-weight: 600; - min-width: 76px; - padding: 3px 8px; - text-align: center; -} - -.pill.active { - background: #e7f1fb; - color: #205a8c; -} - -.pill.blocked { - background: #fff0cc; - color: #735100; -} - -.pill.done { - background: #ddf2e3; - color: #236b38; -} - -.pill.failed { - background: #f8dddd; - color: #8d2b2b; -} - -.pill.neutral { - background: #e8edf0; - color: #4e5c65; -} - -.detail-grid { - display: grid; - gap: 10px; - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.detail-grid div { - border-bottom: 1px solid #e5eaed; - min-height: 58px; - padding: 4px 0 10px; -} - -.detail-grid span, -.detail-grid strong { - display: block; -} - -.mono, -pre { - font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; -} - -pre { - background: #11191f; - border-radius: 6px; - color: #d9e8ef; - min-height: 160px; - overflow: auto; - padding: 12px; - white-space: pre-wrap; -} - -.approval-actions { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.error { - color: #8d2b2b; - font-weight: 600; -} - -form { - display: grid; - gap: 14px; -} - -label { - color: #4c5b64; - display: grid; - gap: 6px; - font-weight: 600; -} - -input, -textarea { - border: 1px solid #cbd5da; - border-radius: 6px; - color: #17232b; - min-width: 0; - padding: 9px 10px; -} - -textarea { - resize: vertical; -} - -.form-grid { - display: grid; - gap: 12px; - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -#form-status { - color: #8d2b2b; -} - -@media (max-width: 900px) { - header, - .split, - .detail-grid, - .form-grid { - grid-template-columns: 1fr; - } - - header { - align-items: flex-start; - display: grid; - gap: 12px; - } - - nav { - flex-wrap: wrap; - } -} diff --git a/apps/web/src/view-model.test.ts b/apps/web/src/view-model.test.ts deleted file mode 100644 index 0f524f2..0000000 --- a/apps/web/src/view-model.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - approvalDecisionActions, - compactPath, - highestDoctorStatus, - normalizeApiBase, - screenFromHash, - stateTone, -} from "./view-model.js"; - -describe("web view model", () => { - it("normalizes navigation and API base values", () => { - expect(screenFromHash("#/templates")).toBe("templates"); - expect(screenFromHash("#/run-detail")).toBe("run-detail"); - expect(screenFromHash("#/approvals")).toBe("approvals"); - expect(screenFromHash("#/sessions")).toBe("sessions"); - expect(screenFromHash("#missing")).toBe("dashboard"); - expect(normalizeApiBase("http://127.0.0.1:3000///")).toBe("http://127.0.0.1:3000"); - expect(normalizeApiBase(undefined)).toBe(""); - }); - - it("maps operational state tones", () => { - expect(stateTone("executing")).toBe("active"); - expect(stateTone("awaiting_approval")).toBe("blocked"); - expect(stateTone("completed")).toBe("done"); - expect(stateTone("failed")).toBe("failed"); - expect(stateTone("created")).toBe("neutral"); - }); - - it("summarizes doctor severity and long paths", () => { - expect( - highestDoctorStatus([ - { name: "config", status: "pass", detail: "", remediation: "" }, - { name: "backend.codex", status: "warn", detail: "", remediation: "" }, - ]), - ).toBe("warn"); - expect(compactPath("/a/very/long/path/that/should/be/shortened/for/the/dashboard", 24)).toBe( - "/a/very/lon…e/dashboard", - ); - }); - - it("exposes every approval decision action required by the API contract", () => { - expect([...approvalDecisionActions]).toEqual(["approve", "request_changes", "reject", "abort"]); - }); -}); diff --git a/apps/web/src/view-model.ts b/apps/web/src/view-model.ts deleted file mode 100644 index 1a54797..0000000 --- a/apps/web/src/view-model.ts +++ /dev/null @@ -1,108 +0,0 @@ -export const screens = [ - "dashboard", - "run-detail", - "approvals", - "sessions", - "templates", - "personas", - "new-run", -] as const; -export type Screen = (typeof screens)[number]; - -export const approvalDecisionActions = ["approve", "request_changes", "reject", "abort"] as const; -export type ApprovalDecisionAction = (typeof approvalDecisionActions)[number]; - -export interface RunSummary { - id: string; - state: string; - repoPath: string; - baseBranch: string; - currentPhaseId: string | null; - createdAt: string; - startedAt: string | null; - endedAt: string | null; -} - -export interface DoctorCheck { - name: string; - status: "pass" | "warn" | "fail"; - detail: string; - remediation: string; -} - -export function screenFromHash(hash: string): Screen { - const candidate = hash.replace(/^#\/?/, ""); - return isScreen(candidate) ? candidate : "dashboard"; -} - -export function normalizeApiBase(value: string | undefined): string { - if (value === undefined || value.trim().length === 0) { - return ""; - } - return value.replace(/\/+$/, ""); -} - -export function stateTone(state: string): "active" | "blocked" | "done" | "failed" | "neutral" { - if (state === "completed") { - return "done"; - } - if (state === "failed" || state === "aborted" || state === "FAILED_NEEDS_HUMAN") { - return "failed"; - } - if ( - state === "paused" || - state === "awaiting_approval" || - state === "WAITING_FOR_APPROVAL" || - state === "ARTIFACT_TIMEOUT" || - state === "HUNG" || - state === "CRASHED" - ) { - return "blocked"; - } - if ( - state === "executing" || - state === "planning" || - state === "bound" || - state === "READY" || - state === "BUSY" || - state === "BOOTSTRAPPING" || - state === "RESUMING" || - state === "REBOOTSTRAPPED" - ) { - return "active"; - } - return "neutral"; -} - -export function compactPath(path: string, maxLength = 54): string { - if (path.length <= maxLength) { - return path; - } - const keep = Math.max(8, Math.floor((maxLength - 1) / 2)); - return `${path.slice(0, keep)}…${path.slice(-keep)}`; -} - -export function formatDateTime(value: string | null): string { - if (value === null) { - return ""; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } - return date.toLocaleString(); -} - -export function highestDoctorStatus(checks: readonly DoctorCheck[]): DoctorCheck["status"] { - if (checks.some((check) => check.status === "fail")) { - return "fail"; - } - if (checks.some((check) => check.status === "warn")) { - return "warn"; - } - return "pass"; -} - -function isScreen(value: string): value is Screen { - return screens.includes(value as Screen); -} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json deleted file mode 100644 index 20ed4c8..0000000 --- a/apps/web/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "emitDeclarationOnly": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "outDir": "dist", - "types": ["vite/client", "vitest"] - }, - "include": ["src/**/*.ts", "vite.config.ts"] -} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts deleted file mode 100644 index 78d31d4..0000000 --- a/apps/web/vite.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "vite"; - -export default defineConfig({ - server: { - proxy: { - "/api": "http://127.0.0.1:3000", - "/sse": "http://127.0.0.1:3000", - }, - }, -}); diff --git a/apps/worker/package.json b/apps/worker/package.json deleted file mode 100644 index 766682e..0000000 --- a/apps/worker/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@devflow/worker", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "tsup src/index.ts --format esm --clean --external @temporalio/worker --external @temporalio/client --external @temporalio/workflow", - "typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit", - "test": "cd ../.. && vitest run --project apps/worker" - }, - "dependencies": { - "@devflow/core": "workspace:*", - "@devflow/db": "workspace:*", - "@devflow/session": "workspace:*", - "@devflow/workflows": "workspace:*", - "@temporalio/client": "^1.17.1", - "@temporalio/worker": "^1.17.1" - } -} diff --git a/apps/worker/src/index.test.ts b/apps/worker/src/index.test.ts deleted file mode 100644 index 088adfe..0000000 --- a/apps/worker/src/index.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { mkdtempSync, realpathSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { DevflowError } from "@devflow/core"; -import { - type DbClient, - createDbClient, - runEvents, - runs, - tuiSessions, - workflowTemplates, -} from "@devflow/db"; -import { FakeSessionAdapter, type SessionAdapter, type SessionHandle } from "@devflow/session"; -import { eq, inArray } from "drizzle-orm"; -import { afterEach, describe, expect, it } from "vitest"; - -import { startWorker } from "./index.js"; - -const databaseUrl = - process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow"; - -class ResumeTrackingAdapter extends FakeSessionAdapter { - resumeAttempts = 0; - - override async resume(handle: SessionHandle): Promise { - this.resumeAttempts += 1; - return super.resume(handle); - } -} - -describe("startWorker", () => { - let client: DbClient | undefined; - const runIds: string[] = []; - const templateIds: string[] = []; - const tempRoots: string[] = []; - - afterEach(async () => { - if (client !== undefined) { - if (runIds.length > 0) { - await client.db.delete(runs).where(inArray(runs.id, [...runIds])); - } - if (templateIds.length > 0) { - await client.db - .delete(workflowTemplates) - .where(inArray(workflowTemplates.id, [...templateIds])); - } - await client.close(); - client = undefined; - } - for (const root of tempRoots.splice(0)) { - rmSync(root, { recursive: true, force: true }); - } - runIds.length = 0; - templateIds.length = 0; - }); - - it("initializes SessionManager recovery before accepting Temporal work", async () => { - client = createDbClient(databaseUrl); - const templateId = randomUUID(); - const runId = randomUUID(); - const sessionId = randomUUID(); - const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-repo-"))); - const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-worktree-"))); - tempRoots.push(repoPath, worktreeRoot); - templateIds.push(templateId); - runIds.push(runId); - - await client.db.insert(workflowTemplates).values({ - id: templateId, - name: `worker-recovery-${templateId}`, - version: 1, - hash: "f".repeat(64), - definition: { name: "worker-recovery", version: 1, roles: [], phases: [] }, - }); - await client.db.insert(runs).values({ - id: runId, - templateId, - templateHash: "f".repeat(64), - state: "executing", - repoPath, - baseBranch: "main", - worktreeRoot, - }); - await client.db.insert(tuiSessions).values({ - id: sessionId, - runId, - roleId: "spec_writer", - backend: "fake", - cwd: worktreeRoot, - state: "BOOTSTRAPPING", - }); - - const adapter = new ResumeTrackingAdapter({ - sessionIdFactory: () => sessionId, - writeDelayMs: 0, - }); - await adapter.start({ - runId, - roleId: "spec_writer", - backend: "fake", - cwd: worktreeRoot, - }); - - const worker = await startWorkerWhenLockFree({ - config: { - DATABASE_URL: databaseUrl, - LOG_LEVEL: "info", - TEMPORAL_ADDRESS: "localhost:7233", - WORKSPACE_ROOT: worktreeRoot, - MAX_CONCURRENT_RUNS: 4, - SESSION_MAX_HUNG_MS: 20 * 60 * 1000, - backends: [{ id: "fake", enabled: true }], - }, - dbClient: client, - recoveryRunIds: [runId], - sessionAdapter: adapter, - connectionFactory: async () => fakeConnection(), - workerFactory: async () => fakeWorker(), - }); - - try { - expect(worker.recovery).toEqual({ failedSessionIds: [], recoveredSessionIds: [sessionId] }); - expect(adapter.resumeAttempts).toBe(1); - const [session] = await client.db - .select({ state: tuiSessions.state }) - .from(tuiSessions) - .where(eq(tuiSessions.id, sessionId)); - expect(session).toEqual({ state: "READY" }); - const events = await client.db - .select({ type: runEvents.type }) - .from(runEvents) - .where(eq(runEvents.runId, runId)) - .orderBy(runEvents.seq); - expect(events.map((event) => event.type)).toEqual(["session.created", "session.ready"]); - } finally { - await worker.shutdown(); - } - }); - - it("releases acquired resources when SessionManager startup fails", async () => { - client = createDbClient(databaseUrl); - const adapter: SessionAdapter = new FakeSessionAdapter(); - const first = await startWorkerWhenLockFree({ - config: { - DATABASE_URL: databaseUrl, - LOG_LEVEL: "info", - TEMPORAL_ADDRESS: "localhost:7233", - WORKSPACE_ROOT: realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-workspace-"))), - MAX_CONCURRENT_RUNS: 4, - SESSION_MAX_HUNG_MS: 20 * 60 * 1000, - backends: [{ id: "fake", enabled: true }], - }, - dbClient: client, - recoveryRunIds: [], - sessionAdapter: adapter, - connectionFactory: async () => fakeConnection(), - workerFactory: async () => fakeWorker(), - }); - try { - await expect( - startWorker({ - config: { - DATABASE_URL: databaseUrl, - LOG_LEVEL: "info", - TEMPORAL_ADDRESS: "localhost:7233", - WORKSPACE_ROOT: realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-workspace-"))), - MAX_CONCURRENT_RUNS: 4, - SESSION_MAX_HUNG_MS: 20 * 60 * 1000, - backends: [{ id: "fake", enabled: true }], - }, - dbClient: client, - recoveryRunIds: [], - connectionFactory: async () => fakeConnection(), - workerFactory: async () => fakeWorker(), - }), - ).rejects.toMatchObject({ code: "session_manager_already_running" }); - } finally { - await first.shutdown(); - } - }); - - it("drains SessionManager resources when the Temporal worker run loop stops", async () => { - client = createDbClient(databaseUrl); - const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-run-"))); - tempRoots.push(workspaceRoot); - const connection = countingConnection(); - const runtime = countingWorker(); - const worker = await startWorkerWhenLockFree({ - config: { - DATABASE_URL: databaseUrl, - LOG_LEVEL: "info", - TEMPORAL_ADDRESS: "localhost:7233", - WORKSPACE_ROOT: workspaceRoot, - MAX_CONCURRENT_RUNS: 4, - SESSION_MAX_HUNG_MS: 20 * 60 * 1000, - backends: [{ id: "fake", enabled: true }], - }, - dbClient: client, - recoveryRunIds: [], - connectionFactory: async () => connection, - workerFactory: async () => runtime, - }); - - await worker.run(); - expect(runtime.runs).toBe(1); - expect(runtime.shutdowns).toBe(1); - expect(connection.closes).toBe(1); - - const next = await startWorkerWhenLockFree({ - config: { - DATABASE_URL: databaseUrl, - LOG_LEVEL: "info", - TEMPORAL_ADDRESS: "localhost:7233", - WORKSPACE_ROOT: workspaceRoot, - MAX_CONCURRENT_RUNS: 4, - SESSION_MAX_HUNG_MS: 20 * 60 * 1000, - backends: [{ id: "fake", enabled: true }], - }, - dbClient: client, - recoveryRunIds: [], - connectionFactory: async () => fakeConnection(), - workerFactory: async () => fakeWorker(), - }); - await next.shutdown(); - }); - - it("drains SessionManager resources when Temporal worker shutdown fails", async () => { - client = createDbClient(databaseUrl); - const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-shutdown-"))); - tempRoots.push(workspaceRoot); - const connection = countingConnection(); - const worker = await startWorkerWhenLockFree({ - config: { - DATABASE_URL: databaseUrl, - LOG_LEVEL: "info", - TEMPORAL_ADDRESS: "localhost:7233", - WORKSPACE_ROOT: workspaceRoot, - MAX_CONCURRENT_RUNS: 4, - SESSION_MAX_HUNG_MS: 20 * 60 * 1000, - backends: [{ id: "fake", enabled: true }], - }, - dbClient: client, - recoveryRunIds: [], - connectionFactory: async () => connection, - workerFactory: async () => failingShutdownWorker(), - }); - - await expect(worker.shutdown()).rejects.toThrow("worker shutdown failed"); - expect(connection.closes).toBe(1); - - const next = await startWorkerWhenLockFree({ - config: { - DATABASE_URL: databaseUrl, - LOG_LEVEL: "info", - TEMPORAL_ADDRESS: "localhost:7233", - WORKSPACE_ROOT: workspaceRoot, - MAX_CONCURRENT_RUNS: 4, - SESSION_MAX_HUNG_MS: 20 * 60 * 1000, - backends: [{ id: "fake", enabled: true }], - }, - dbClient: client, - recoveryRunIds: [], - connectionFactory: async () => fakeConnection(), - workerFactory: async () => fakeWorker(), - }); - await next.shutdown(); - }); -}); - -function fakeConnection() { - return { - close: async () => undefined, - }; -} - -function fakeWorker() { - return { - run: async () => undefined, - shutdown: () => undefined, - }; -} - -function countingConnection() { - return { - closes: 0, - async close() { - this.closes += 1; - }, - }; -} - -function countingWorker() { - return { - runs: 0, - shutdowns: 0, - async run() { - this.runs += 1; - }, - shutdown() { - this.shutdowns += 1; - }, - }; -} - -function failingShutdownWorker() { - return { - run: async () => undefined, - shutdown() { - throw new Error("worker shutdown failed"); - }, - }; -} - -async function startWorkerWhenLockFree(options: Parameters[0]) { - const deadline = Date.now() + 6_000; - let lastError: unknown; - while (Date.now() < deadline) { - try { - return await startWorker(options); - } catch (error) { - lastError = error; - if (!(error instanceof DevflowError) || error.code !== "session_manager_already_running") { - throw error; - } - await new Promise((resolveWait) => setTimeout(resolveWait, 50)); - } - } - throw lastError; -} diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts deleted file mode 100644 index 2820689..0000000 --- a/apps/worker/src/index.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { fileURLToPath } from "node:url"; - -import { type Config, DevflowError, getConfig } from "@devflow/core"; -import { type DbClient, createDbClient } from "@devflow/db"; -import { FakeSessionAdapter, type SessionAdapter, SessionManager } from "@devflow/session"; -import { NativeConnection, Worker } from "@temporalio/worker"; - -import { createDevflowActivities, temporalTaskQueue } from "@devflow/workflows"; - -interface WorkerConnection { - close(): Promise; -} - -interface WorkerRuntime { - run(): Promise; - shutdown(): void | Promise; -} - -export interface StartWorkerOptions { - config?: Config; - dbClient?: DbClient; - sessionAdapter?: SessionAdapter; - recoveryRunIds?: readonly string[]; - temporalAddress?: string; - taskQueue?: string; - connectionFactory?: (options: { address: string }) => Promise; - workerFactory?: (options: Parameters[0]) => Promise; -} - -export async function startWorker(options: StartWorkerOptions = {}) { - const config = options.config ?? getConfig(); - const ownedClient = options.dbClient === undefined; - const dbClient = options.dbClient ?? createDbClient(config.DATABASE_URL); - const sessionManager = new SessionManager({ - dbClient, - adapter: options.sessionAdapter ?? new FakeSessionAdapter(), - ...(options.recoveryRunIds === undefined ? {} : { recoveryRunIds: options.recoveryRunIds }), - }); - let connection: WorkerConnection | undefined; - let worker: WorkerRuntime | undefined; - - try { - const recovery = await sessionManager.initialize(); - connection = await (options.connectionFactory ?? NativeConnection.connect)({ - address: options.temporalAddress ?? config.TEMPORAL_ADDRESS, - }); - worker = await (options.workerFactory ?? Worker.create)({ - activities: createDevflowActivities({ - db: dbClient.db, - sessions: sessionManager, - workspaceRoot: config.WORKSPACE_ROOT, - availableBackends: config.backends, - maxConcurrentRuns: config.MAX_CONCURRENT_RUNS, - recovery: { maxHungMs: config.SESSION_MAX_HUNG_MS }, - }), - connection: connection as NativeConnection, - namespace: "devflow", - taskQueue: options.taskQueue ?? temporalTaskQueue, - workflowsPath: fileURLToPath( - new URL("../../../packages/workflows/src/workflow.ts", import.meta.url), - ), - }); - - const startedWorker = worker; - const startedConnection = connection; - if (startedWorker === undefined || startedConnection === undefined) { - throw new DevflowError("Temporal worker failed to initialize", { - class: "fatal", - code: "internal_state_corruption", - }); - } - let shutdownPromise: Promise | undefined; - const shutdown = () => { - shutdownPromise ??= (async () => { - let workerShutdownError: unknown; - try { - await Promise.resolve(startedWorker.shutdown()); - } catch (error) { - workerShutdownError = error; - } finally { - try { - await sessionManager.shutdown(); - } finally { - await startedConnection.close(); - if (ownedClient) { - await dbClient.close(); - } - } - } - if (workerShutdownError !== undefined) { - throw workerShutdownError; - } - })(); - return shutdownPromise; - }; - return { - recovery, - async run() { - try { - await startedWorker.run(); - } finally { - await shutdown(); - } - }, - shutdown, - }; - } catch (error) { - if (worker !== undefined) { - await Promise.resolve(worker.shutdown()).catch(() => undefined); - } - if (connection !== undefined) { - await connection.close().catch(() => undefined); - } - await sessionManager.shutdown().catch(() => undefined); - if (ownedClient) { - await dbClient.close().catch(() => undefined); - } - throw error; - } -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - startWorker() - .then(async (worker) => { - const requestShutdown = () => { - void worker.shutdown().catch((error: unknown) => { - console.error(error); - process.exitCode = 2; - }); - }; - process.once("SIGINT", requestShutdown); - process.once("SIGTERM", requestShutdown); - await worker.run(); - }) - .catch((error: unknown) => { - console.error(error); - process.exitCode = - error instanceof DevflowError && error.code === "session_manager_already_running" ? 3 : 2; - }); -} diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json deleted file mode 100644 index 227d2a1..0000000 --- a/apps/worker/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "types": ["node", "vitest"] - }, - "include": ["src/**/*.ts"], - "references": [ - { "path": "../../packages/core" }, - { "path": "../../packages/db" }, - { "path": "../../packages/session" }, - { "path": "../../packages/workflows" } - ] -} diff --git a/biome.json b/biome.json deleted file mode 100644 index d2504ef..0000000 --- a/biome.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "files": { - "ignoreUnknown": false, - "ignore": ["node_modules", "dist", "coverage", "data"] - }, - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 100 - }, - "organizeImports": { - "enabled": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "suspicious": { - "noExplicitAny": "error" - } - } - }, - "javascript": { - "formatter": { - "quoteStyle": "double", - "semicolons": "always", - "trailingCommas": "all" - } - }, - "json": { - "formatter": { - "trailingCommas": "none" - } - } -} diff --git a/drizzle.config.ts b/drizzle.config.ts deleted file mode 100644 index 43e16d4..0000000 --- a/drizzle.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import "dotenv/config"; -import { defineConfig } from "drizzle-kit"; - -export default defineConfig({ - schema: "./packages/db/src/schema/index.ts", - out: "./packages/db/src/migrations", - dialect: "postgresql", - dbCredentials: { - url: process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow", - }, -}); diff --git a/lefthook.yml b/lefthook.yml deleted file mode 100644 index 3357b0a..0000000 --- a/lefthook.yml +++ /dev/null @@ -1,12 +0,0 @@ -pre-commit: - parallel: false - commands: - biome: - glob: "*.{ts,tsx,js,jsx,json,jsonc,md,yml,yaml}" - run: npx pnpm@9.15.9 biome check --write {staged_files} - stage_fixed: true - typecheck: - run: npx pnpm@9.15.9 typecheck - test: - glob: "*.{ts,tsx,js,jsx}" - run: npx pnpm@9.15.9 vitest related --run --passWithNoTests {staged_files} diff --git a/package.json b/package.json deleted file mode 100644 index 704c665..0000000 --- a/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "devflow", - "version": "0.0.0", - "private": true, - "type": "module", - "packageManager": "pnpm@9.15.9", - "engines": { - "node": ">=22.0.0 <23", - "pnpm": ">=9.0.0 <10" - }, - "scripts": { - "build": "tsc -b && pnpm -r --if-present build", - "db:generate": "drizzle-kit generate", - "db:migrate": "tsx scripts/migrate.ts", - "db:seed": "tsx scripts/seed.ts", - "devflow": "tsx apps/cli/src/index.ts", - "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", - "test": "vitest run", - "test:watch": "vitest", - "coverage": "vitest run --coverage", - "lint": "biome check .", - "format": "biome check --write ." - }, - "devDependencies": { - "@biomejs/biome": "1.9.4", - "@types/node": "22.10.2", - "@types/pg": "8.20.0", - "@vitest/coverage-v8": "2.1.8", - "drizzle-kit": "0.31.10", - "lefthook": "2.1.6", - "tsup": "8.3.5", - "tsx": "4.19.2", - "typescript": "5.6.3", - "vite": "6.0.3", - "vitest": "2.1.8" - }, - "dependencies": { - "commander": "12.1.0", - "dotenv": "17.4.2", - "drizzle-orm": "0.45.2", - "pg": "8.20.0", - "zod": "3.24.1" - } -} diff --git a/packages/.gitkeep b/packages/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/packages/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/core/package.json b/packages/core/package.json deleted file mode 100644 index a503c96..0000000 --- a/packages/core/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@devflow/core", - "version": "0.0.0", - "private": true, - "type": "module", - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "scripts": { - "build": "tsup src/index.ts --format esm,cjs --clean && tsc -p tsconfig.build.json", - "typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit", - "test": "cd ../.. && vitest run --project packages/core" - }, - "dependencies": { - "ajv": "8.17.1", - "dotenv": "17.4.2", - "yaml": "2.6.1", - "zod": "3.24.1" - } -} diff --git a/packages/core/src/artifact-schema.test.ts b/packages/core/src/artifact-schema.test.ts deleted file mode 100644 index ca0e8f4..0000000 --- a/packages/core/src/artifact-schema.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; - -import { - clearArtifactSchemaCacheForTests, - loadSchema, - validateArtifact, -} from "./artifact-schema.js"; -import { DevflowError } from "./errors.js"; - -const artifactRoot = resolve( - dirname(fileURLToPath(import.meta.url)), - "../../../docs/schemas/artifacts", -); -const repoRoot = resolve(artifactRoot, "../../.."); -const hash64 = "a".repeat(64); -const runId = "00000000-0000-4000-8000-000000000001"; -const originalCwd = process.cwd(); - -describe("artifact schema registry", () => { - afterEach(() => { - process.chdir(originalCwd); - clearArtifactSchemaCacheForTests(); - }); - - it("loads the first locked artifact schemas from docs/schemas/artifacts", () => { - clearArtifactSchemaCacheForTests(); - - expect(loadSchema("dev/spec@1", { root: artifactRoot })).toMatchObject({ - $id: "dev/spec@1", - }); - expect(loadSchema("dev/phase-plan@1", { root: artifactRoot })).toMatchObject({ - $id: "dev/phase-plan@1", - }); - expect(loadSchema("common/final-report@1", { root: artifactRoot })).toMatchObject({ - $id: "common/final-report@1", - }); - expect(Object.isFrozen(loadSchema("dev/spec@1", { root: artifactRoot }))).toBe(true); - }); - - it("finds the default schema root from package subdirectories", () => { - process.chdir(resolve(repoRoot, "packages/core")); - - expect(loadSchema("dev/spec@1")).toMatchObject({ $id: "dev/spec@1" }); - }); - - it("validates dev/spec@1 artifacts and returns compact validation errors", () => { - expect( - validateArtifact( - "dev/spec@1", - { - summary: "Add a binding algorithm", - requirements: [{ id: "REQ-1", description: "Bind every role" }], - acceptanceCriteria: ["All roles have bindings"], - risks: [], - }, - { root: artifactRoot }, - ), - ).toEqual({ ok: true }); - - const result = validateArtifact( - "dev/spec@1", - { - summary: "Missing requirements", - requirements: [], - acceptanceCriteria: [], - risks: [], - }, - { root: artifactRoot }, - ); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errors.map((error) => error.keyword)).toContain("minItems"); - expect(result.errors[0]).toMatchObject({ - instancePath: expect.any(String), - schemaPath: expect.any(String), - params: expect.any(Object), - }); - } - }); - - it("validates dev/phase-plan@1 artifacts", () => { - expect( - validateArtifact( - "dev/phase-plan@1", - { - phases: [ - { - key: "implement", - title: "Implement", - objective: "Implement the requested behavior", - roles: ["implementer"], - expectedArtifact: { - path: "artifacts/spec.json", - schema: "dev/spec@1", - }, - tasks: [ - { - id: "task-1", - title: "Edit code", - role: "implementer", - writeSet: ["packages/core/src/**", "**/*.ts"], - }, - ], - }, - ], - }, - { root: artifactRoot }, - ), - ).toEqual({ ok: true }); - - const invalidSchemaId = validateArtifact( - "dev/phase-plan@1", - { - phases: [ - { - key: "implement", - title: "Implement", - objective: "Implement the requested behavior", - roles: ["implementer"], - expectedArtifact: { - path: "artifacts/spec.json", - schema: "../secret@1", - }, - }, - ], - }, - { root: artifactRoot }, - ); - - expect(invalidSchemaId.ok).toBe(false); - if (!invalidSchemaId.ok) { - expect(invalidSchemaId.errors.map((error) => error.keyword)).toContain("pattern"); - } - - for (const path of [ - "../../secrets.json", - "/etc/passwd", - "artifacts/../outside.json", - "C:/outside.json", - "ok\n/../../outside.json", - "ok\r/../../outside.json", - ]) { - const invalidPath = validateArtifact( - "dev/phase-plan@1", - { - phases: [ - { - key: "implement", - title: "Implement", - objective: "Implement the requested behavior", - roles: ["implementer"], - expectedArtifact: { - path, - schema: "dev/spec@1", - }, - }, - ], - }, - { root: artifactRoot }, - ); - - expect(invalidPath.ok).toBe(false); - if (!invalidPath.ok) { - expect(invalidPath.errors.map((error) => error.keyword)).toContain("pattern"); - } - } - - const missingWriteSet = validateArtifact( - "dev/phase-plan@1", - { - phases: [ - { - key: "implement", - title: "Implement", - objective: "Implement the requested behavior", - roles: ["implementer"], - tasks: [ - { - id: "task-1", - title: "Edit code", - role: "implementer", - }, - ], - }, - ], - }, - { root: artifactRoot }, - ); - - expect(missingWriteSet.ok).toBe(false); - if (!missingWriteSet.ok) { - expect(missingWriteSet.errors.map((error) => error.keyword)).toContain("required"); - } - - for (const writeSet of [ - "../../**", - "/etc/**", - "src/../secrets/**", - "C:/outside/**", - "src\n/../../outside", - "src\r/../../outside", - "..\\outside\\**", - "!/etc/**", - "!../*", - "{../*,packages/core/src/**}", - "{..,packages}/**", - "@(../*)", - "!(../*)", - "src/[ab]/**", - ]) { - const invalidWriteSet = validateArtifact( - "dev/phase-plan@1", - { - phases: [ - { - key: "implement", - title: "Implement", - objective: "Implement the requested behavior", - roles: ["implementer"], - tasks: [ - { - id: "task-1", - title: "Edit code", - role: "implementer", - writeSet: [writeSet], - }, - ], - }, - ], - }, - { root: artifactRoot }, - ); - - expect(invalidWriteSet.ok).toBe(false); - if (!invalidWriteSet.ok) { - expect(invalidWriteSet.errors.map((error) => error.keyword)).toContain("pattern"); - } - } - }); - - it("validates common/final-report@1 minimum fields", () => { - expect( - validateArtifact( - "common/final-report@1", - { - runId, - templateHash: hash64, - bindings: [{ roleId: "implementer", personaHash: hash64, backend: "fake" }], - inputs: {}, - phases: [], - approvals: [], - findings: [], - commands: [{ kind: "test", argv: ["pnpm", "test"], exit_code: 0 }], - artifacts: [], - events: { tail: [] }, - unresolved: [], - endedAt: "2026-05-09T00:00:00.000Z", - status: "completed", - }, - { root: artifactRoot }, - ), - ).toEqual({ ok: true }); - - const result = validateArtifact( - "common/final-report@1", - { - runId: "not-a-uuid", - templateHash: hash64, - bindings: [{ roleId: "implementer", personaHash: hash64, backend: "fake" }], - inputs: {}, - phases: [], - approvals: [], - findings: [], - commands: [], - artifacts: [], - events: { tail: [] }, - unresolved: [], - endedAt: "2026-99-99T99:99:99Z", - status: "executing", - }, - { root: artifactRoot }, - ); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errors.map((error) => error.keyword)).toEqual( - expect.arrayContaining(["format", "enum"]), - ); - } - }); - - it("fails fatally for unknown or malformed schema ids", () => { - expect(() => loadSchema("dev/unknown@1", { root: artifactRoot })).toThrow(DevflowError); - expect(() => loadSchema("../secret@1", { root: artifactRoot })).toThrow( - /artifact_schema_unknown/, - ); - }); - - it("fails fatally when schema files are malformed", () => { - const root = mkdtempSync(join(tmpdir(), "devflow-artifact-schemas-")); - const devDir = join(root, "dev"); - mkdirSync(devDir, { recursive: true }); - writeFileSync( - join(devDir, "bad@1.json"), - JSON.stringify({ - $schema: "https://json-schema.org/draft/2020-12/schema", - $id: "dev/wrong@1", - type: "object", - }), - ); - - expect(() => loadSchema("dev/bad@1", { root })).toThrow(/artifact_schema_load_failed/); - }); - - it("wraps registry root, JSON parse, and path layout load failures", () => { - expect(() => - loadSchema("dev/spec@1", { root: join(tmpdir(), "missing-artifact-root") }), - ).toThrow(/artifact_schema_load_failed/); - - const badJsonRoot = mkdtempSync(join(tmpdir(), "devflow-artifact-schemas-")); - mkdirSync(join(badJsonRoot, "dev"), { recursive: true }); - writeFileSync(join(badJsonRoot, "dev", "bad@1.json"), "{"); - expect(() => loadSchema("dev/bad@1", { root: badJsonRoot })).toThrow( - /artifact_schema_load_failed/, - ); - - const badPathRoot = mkdtempSync(join(tmpdir(), "devflow-artifact-schemas-")); - mkdirSync(join(badPathRoot, "bad"), { recursive: true }); - writeFileSync( - join(badPathRoot, "bad", "schema.json"), - JSON.stringify({ - $schema: "https://json-schema.org/draft/2020-12/schema", - $id: "bad/schema", - type: "object", - }), - ); - expect(() => loadSchema("dev/missing@1", { root: badPathRoot })).toThrow( - /artifact_schema_load_failed/, - ); - }); - - it("does not load schemas from a target-controlled cwd shadow root", () => { - const shadowRoot = mkdtempSync(join(tmpdir(), "devflow-shadow-schemas-")); - const shadowSchemaDir = join(shadowRoot, "docs", "schemas", "artifacts", "dev"); - mkdirSync(shadowSchemaDir, { recursive: true }); - writeFileSync(join(shadowRoot, "package.json"), JSON.stringify({ name: "devflow" })); - writeFileSync( - join(shadowSchemaDir, "spec@1.json"), - JSON.stringify({ - $schema: "https://json-schema.org/draft/2020-12/schema", - $id: "dev/spec@1", - title: "SHADOW", - type: "object", - }), - ); - - process.chdir(shadowRoot); - - expect(loadSchema("dev/spec@1")).toMatchObject({ title: "Devflow Development Specification" }); - }); -}); diff --git a/packages/core/src/artifact-schema.ts b/packages/core/src/artifact-schema.ts deleted file mode 100644 index d282d8e..0000000 --- a/packages/core/src/artifact-schema.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs"; -import { dirname, join, relative, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; -import { - Ajv2020, - type ErrorObject, - type FormatDefinition, - type ValidateFunction, -} from "ajv/dist/2020.js"; - -import { DevflowError } from "./errors.js"; -import type { JsonObject, JsonValue } from "./persona.js"; - -export type JsonSchema = JsonObject; - -export interface ValidationError { - instancePath: string; - schemaPath: string; - keyword: string; - message?: string; - params: JsonObject; -} - -export interface ArtifactSchemaOptions { - root?: string; -} - -interface CompiledArtifactSchema { - id: string; - schema: JsonSchema; - validate: ValidateFunction; - path: string; -} - -const schemaIdPattern = /^[a-z][a-z0-9_-]*\/[a-z][a-z0-9_-]*@[1-9]\d*$/; -const schemaRootSegments = ["docs", "schemas", "artifacts"] as const; -const registries = new Map>(); - -export function loadSchema(id: string, options: ArtifactSchemaOptions = {}): JsonSchema { - assertSchemaId(id); - const schema = registryFor(options).get(id); - if (!schema) { - throw artifactSchemaUnknown(id); - } - - return schema.schema; -} - -export function validateArtifact( - id: string, - data: unknown, - options: ArtifactSchemaOptions = {}, -): { ok: true } | { ok: false; errors: ValidationError[] } { - assertSchemaId(id); - const schema = registryFor(options).get(id); - if (!schema) { - throw artifactSchemaUnknown(id); - } - - if (schema.validate(data)) { - return { ok: true }; - } - - return { - ok: false, - errors: (schema.validate.errors ?? []).map(toValidationError), - }; -} - -export function clearArtifactSchemaCacheForTests(): void { - registries.clear(); -} - -function registryFor(options: ArtifactSchemaOptions): Map { - const root = resolveRegistryRoot(options.root ?? findDefaultSchemaRoot()); - const cached = registries.get(root); - if (cached) { - return cached; - } - - const registry = loadRegistry(root); - registries.set(root, registry); - return registry; -} - -function resolveRegistryRoot(root: string): string { - try { - return realpathSync(resolve(root)); - } catch (error) { - throw artifactSchemaLoadFailed(root, error); - } -} - -function findDefaultSchemaRoot(): string { - const moduleDirectory = currentModuleDirectory(); - if (moduleDirectory === undefined) { - throw artifactSchemaLoadFailed( - "default", - new Error("Could not resolve current module directory for artifact schemas"), - ); - } - - const packageRoot = findCorePackageRoot(moduleDirectory); - if (packageRoot === undefined) { - throw artifactSchemaLoadFailed( - "default", - new Error("Could not find @devflow/core package root for artifact schemas"), - ); - } - - return resolve(packageRoot, "../..", ...schemaRootSegments); -} - -function currentModuleDirectory(): string | undefined { - const stack = new Error().stack?.split("\n").slice(1) ?? []; - - for (const line of stack) { - const match = line.match(/\(?((?:file:\/\/)?\/[^):]+\.(?:cjs|mjs|js|ts)):\d+:\d+\)?/); - if (!match?.[1]) { - continue; - } - - const path = match[1].startsWith("file://") ? fileURLToPath(match[1]) : match[1]; - return dirname(path); - } - - return undefined; -} - -function findCorePackageRoot(startDirectory: string): string | undefined { - let current = resolve(startDirectory); - - while (true) { - if (isCorePackageRoot(current)) { - return current; - } - - const parent = dirname(current); - if (parent === current) { - return undefined; - } - - current = parent; - } -} - -function isCorePackageRoot(directory: string): boolean { - const packageJsonPath = join(directory, "package.json"); - if (!existsSync(packageJsonPath)) { - return false; - } - - try { - const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown }; - return packageJson.name === "@devflow/core"; - } catch { - return false; - } -} - -function addArtifactFormats(ajv: Ajv2020): void { - ajv.addFormat("uuid", uuidFormat); - ajv.addFormat("utc-date-time", utcDateTimeFormat); -} - -const uuidFormat: FormatDefinition = { - type: "string", - validate: (value: string) => - /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(value), -}; - -const utcDateTimeFormat: FormatDefinition = { - type: "string", - validate: isUtcDateTime, -}; - -function isUtcDateTime(value: string): boolean { - const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?Z$/); - if (!match) { - return false; - } - - const [, yearText, monthText, dayText, hourText, minuteText, secondText, millisecondText] = match; - const year = Number(yearText); - const month = Number(monthText); - const day = Number(dayText); - const hour = Number(hourText); - const minute = Number(minuteText); - const second = Number(secondText); - const millisecond = millisecondText === undefined ? 0 : Number(millisecondText); - const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millisecond)); - - return ( - date.getUTCFullYear() === year && - date.getUTCMonth() === month - 1 && - date.getUTCDate() === day && - date.getUTCHours() === hour && - date.getUTCMinutes() === minute && - date.getUTCSeconds() === second && - date.getUTCMilliseconds() === millisecond - ); -} - -function loadRegistry(root: string): Map { - const ajv = new Ajv2020({ allErrors: true, strict: true }); - addArtifactFormats(ajv); - const schemas = new Map(); - - for (const file of readSchemaFiles(root, root)) { - if (schemas.has(file.id)) { - throw artifactSchemaLoadFailed(file.id, new Error(`Duplicate artifact schema id ${file.id}`)); - } - - schemas.set(file.id, file); - } - - const registry = new Map(); - for (const file of schemas.values()) { - try { - registry.set(file.id, { - id: file.id, - schema: deepFreeze(file.schema), - validate: ajv.compile(file.schema), - path: file.path, - }); - } catch (error) { - throw artifactSchemaLoadFailed(file.id, error); - } - } - - return registry; -} - -interface JsonSchemaFile { - id: string; - schema: JsonSchema; - path: string; -} - -function readSchemaFiles(root: string, directory: string): JsonSchemaFile[] { - const files: JsonSchemaFile[] = []; - let entryNames: string[]; - try { - entryNames = readdirSync(directory).sort(); - } catch (error) { - throw artifactSchemaLoadFailed(directory, error); - } - - for (const entryName of entryNames) { - const path = join(directory, entryName); - let stat: ReturnType; - try { - stat = lstatSync(path); - } catch (error) { - throw artifactSchemaLoadFailed(path, error); - } - - if (stat.isSymbolicLink()) { - throw artifactSchemaLoadFailed( - entryName, - new Error("Artifact schema path must not be a symlink"), - ); - } - - if (stat.isDirectory()) { - files.push(...readSchemaFiles(root, path)); - continue; - } - - if (!entryName.endsWith(".json")) { - continue; - } - - const canonicalPath = resolveSchemaFilePath(path); - const id = schemaIdFromPath(root, canonicalPath); - const parsed = parseSchemaFile(id, canonicalPath); - if (!isJsonObject(parsed)) { - throw artifactSchemaLoadFailed(id, new Error("Artifact schema must be a JSON object")); - } - - if (parsed.$id !== id) { - throw artifactSchemaLoadFailed(id, new Error(`Artifact schema $id must equal ${id}`)); - } - - files.push({ id, schema: parsed, path: canonicalPath }); - } - - return files; -} - -function schemaIdFromPath(root: string, path: string) { - const relativePath = relative(root, path).split(sep).join("/"); - const id = relativePath.replace(/\.json$/, ""); - if (!schemaIdPattern.test(id)) { - throw artifactSchemaLoadFailed(id, new Error(`Invalid artifact schema path ${relativePath}`)); - } - return id; -} - -function assertSchemaId(id: string) { - if (!schemaIdPattern.test(id)) { - throw artifactSchemaUnknown(id); - } -} - -function toValidationError(error: ErrorObject): ValidationError { - return { - instancePath: error.instancePath, - schemaPath: error.schemaPath, - keyword: error.keyword, - ...(error.message === undefined ? {} : { message: error.message }), - params: toJsonObject(error.params), - }; -} - -function toJsonObject(value: Record): JsonObject { - return Object.fromEntries( - Object.entries(value).map(([key, childValue]) => [key, toJsonValue(childValue)]), - ); -} - -function toJsonValue(value: unknown): JsonValue { - if (value === null || typeof value === "string" || typeof value === "boolean") { - return value; - } - - if (typeof value === "number") { - return Number.isFinite(value) ? value : String(value); - } - - if (Array.isArray(value)) { - return value.map(toJsonValue); - } - - if (isJsonObject(value)) { - return toJsonObject(value); - } - - return String(value); -} - -function isJsonObject(value: unknown): value is JsonObject { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function resolveSchemaFilePath(path: string): string { - try { - return realpathSync(path); - } catch (error) { - throw artifactSchemaLoadFailed(path, error); - } -} - -function parseSchemaFile(id: string, path: string): unknown { - try { - return JSON.parse(readFileSync(path, "utf8")) as unknown; - } catch (error) { - throw artifactSchemaLoadFailed(id, error); - } -} - -function deepFreeze(value: T): T { - if (value !== null && typeof value === "object") { - Object.freeze(value); - for (const child of Object.values(value)) { - deepFreeze(child); - } - } - - return value; -} - -function artifactSchemaUnknown(id: string) { - return new DevflowError(`artifact_schema_unknown:${id}`, { - class: "fatal", - code: "artifact_schema_unknown", - recoveryHint: `Add docs/schemas/artifacts/${id}.json or update the template schema id.`, - }); -} - -function artifactSchemaLoadFailed(id: string, cause: unknown) { - return new DevflowError(`artifact_schema_load_failed:${id}`, { - class: "fatal", - code: "artifact_schema_load_failed", - cause, - recoveryHint: "Fix the artifact schema JSON document.", - }); -} diff --git a/packages/core/src/binding.test.ts b/packages/core/src/binding.test.ts deleted file mode 100644 index a1aacaf..0000000 --- a/packages/core/src/binding.test.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { z } from "zod"; - -import { type BindingOverride, bindTemplatePersonas } from "./binding.js"; -import type { BackendConfig } from "./config.js"; -import { DevflowError } from "./errors.js"; -import { hash } from "./hash.js"; -import { Persona } from "./persona.js"; -import { personaHash } from "./persona.js"; -import { Template } from "./template.js"; - -const enabledBackends: BackendConfig[] = [ - { id: "fake", enabled: true }, - { id: "claude", enabled: true, binaryPath: process.execPath }, - { id: "codex", enabled: true, binaryPath: process.execPath }, -]; - -describe("binding algorithm", () => { - it("auto-selects deterministically by preferred backend, version, name, then hash", () => { - const result = bindTemplatePersonas({ - runId: "run-1", - template: template({ - roles: [ - { - id: "implementer", - requiredCapabilities: ["code_edit"], - preferredBackends: ["claude", "fake"], - }, - ], - }), - personas: [ - persona({ name: "fake_v9", version: 9, backend: "fake", capabilities: ["code_edit"] }), - persona({ name: "claude_v1", version: 1, backend: "claude", capabilities: ["code_edit"] }), - persona({ - name: "claude_v2_b", - version: 2, - backend: "claude", - capabilities: ["code_edit"], - }), - persona({ - name: "claude_v2_a", - version: 2, - backend: "claude", - capabilities: ["code_edit"], - }), - ], - templateHash: "template-hash", - availableBackends: enabledBackends, - }); - - expect(result.bindings).toHaveLength(1); - expect(result.bindings[0]?.roleId).toBe("implementer"); - expect(result.bindings[0]?.persona.name).toBe("claude_v2_a"); - }); - - it("falls back to non-preferred personas only when preferred personas fail eligibility", () => { - const result = bindTemplatePersonas({ - runId: "run-1", - template: template({ - roles: [ - { - id: "implementer", - requiredCapabilities: ["code_edit"], - preferredBackends: ["claude"], - }, - ], - }), - personas: [ - persona({ name: "claude_reader", backend: "claude", capabilities: ["code_review"] }), - persona({ name: "fake_implementer", backend: "fake", capabilities: ["code_edit"] }), - ], - templateHash: "template-hash", - availableBackends: enabledBackends, - }); - - expect(result.bindings[0]?.persona.name).toBe("fake_implementer"); - }); - - it("does not fall back when preferred personas only fail allowed role checks", () => { - expect(() => - bindTemplatePersonas({ - runId: "run-1", - template: template({ - roles: [ - { - id: "implementer", - requiredCapabilities: ["code_edit"], - preferredBackends: ["claude"], - }, - ], - }), - personas: [ - persona({ - name: "claude_restricted", - backend: "claude", - capabilities: ["code_edit"], - allowedRoles: ["reviewer"], - }), - persona({ name: "fake_implementer", backend: "fake", capabilities: ["code_edit"] }), - ], - templateHash: "template-hash", - availableBackends: enabledBackends, - }), - ).toThrow(/no_eligible_persona/); - }); - - it("fails instead of falling back when the selected preferred backend is unavailable", () => { - expect(() => - bindTemplatePersonas({ - runId: "run-1", - template: template({ - roles: [ - { - id: "implementer", - requiredCapabilities: ["code_edit"], - preferredBackends: ["codex"], - }, - ], - }), - personas: [ - persona({ name: "codex_implementer", backend: "codex", capabilities: ["code_edit"] }), - persona({ name: "fake_implementer", backend: "fake", capabilities: ["code_edit"] }), - ], - templateHash: "template-hash", - availableBackends: [{ id: "fake", enabled: true }], - }), - ).toThrow(/backend_unavailable/); - }); - - it("classifies binding failures as human-required DevflowError instances", () => { - try { - bindTemplatePersonas({ - runId: "run-1", - template: template({ - roles: [ - { - id: "implementer", - requiredCapabilities: ["code_edit"], - preferredBackends: ["codex"], - }, - ], - }), - personas: [persona({ name: "codex_implementer", backend: "codex" })], - templateHash: "template-hash", - availableBackends: [{ id: "codex", enabled: true }], - }); - } catch (error) { - expect(error).toBeInstanceOf(DevflowError); - expect((error as DevflowError).class).toBe("human_required"); - expect((error as DevflowError).code).toBe("backend_unavailable"); - return; - } - - throw new Error("expected binding to fail"); - }); - - it("treats absolute backend paths as process-start resolved registry entries", () => { - const result = bindTemplatePersonas({ - runId: "run-1", - template: template({ - roles: [ - { - id: "implementer", - requiredCapabilities: ["code_edit"], - preferredBackends: ["codex"], - }, - ], - }), - personas: [persona({ name: "codex_implementer", backend: "codex" })], - templateHash: "template-hash", - availableBackends: [{ id: "codex", enabled: true, binaryPath: "/process/start/codex" }], - }); - - expect(result.bindings[0]?.backend).toBe("codex"); - }); - - it("supports persona overrides and backend-constraining overrides", () => { - const result = bindTemplatePersonas({ - runId: "run-1", - template: template({ - roles: [{ id: "reviewer", requiredCapabilities: ["code_review"] }], - }), - personas: [ - persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }), - persona({ name: "codex_reviewer", backend: "codex", capabilities: ["code_review"] }), - ], - overrides: { roles: { reviewer: { backend: "codex" } } }, - templateHash: "template-hash", - availableBackends: enabledBackends, - }); - - expect(result.bindings[0]?.persona.name).toBe("codex_reviewer"); - - const swappedPersona = bindTemplatePersonas({ - runId: "run-1", - template: template({ - roles: [{ id: "reviewer", requiredCapabilities: ["code_review"] }], - }), - personas: [ - persona({ name: "alpha_reviewer", backend: "fake", capabilities: ["code_review"] }), - persona({ name: "beta_reviewer", backend: "fake", capabilities: ["code_review"] }), - ], - overrides: { roles: { reviewer: { persona: "beta_reviewer" } } }, - templateHash: "template-hash", - availableBackends: enabledBackends, - }); - - expect(swappedPersona.bindings[0]?.persona.name).toBe("beta_reviewer"); - }); - - it("rejects typo override keys and normalizes explicit undefined override fields", () => { - const base = { - runId: "run-1", - template: template(), - personas: [persona({ name: "fake_implementer", backend: "fake" })], - templateHash: "template-hash", - availableBackends: enabledBackends, - }; - - expect(() => - bindTemplatePersonas({ - ...base, - overrides: { - roles: { - implementer: { backned: "codex" } as unknown as BindingOverride, - }, - }, - }), - ).toThrow(/Unrecognized key/); - - expect( - bindTemplatePersonas({ - ...base, - overrides: { - roles: { - implementer: { - persona: undefined, - backend: undefined, - } as unknown as BindingOverride, - }, - }, - }).bindings[0]?.persona.name, - ).toBe("fake_implementer"); - - expect(() => - bindTemplatePersonas({ - ...base, - overrides: { - roles: { - implmenter: { backend: "fake" }, - }, - }, - }), - ).toThrow(/unknown override role/); - }); - - it("computes binding hashes from the locked hash subject", () => { - const override = { backend: "fake" as const }; - const result = bindTemplatePersonas({ - runId: "run-1", - template: template({ - roles: [ - { - id: "reviewer", - requiredCapabilities: ["code_review"], - count: 2, - }, - ], - }), - personas: [ - persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }), - ], - overrides: { roles: { "reviewer#1": override } }, - templateHash: "template-hash", - availableBackends: enabledBackends, - }); - const binding = result.bindings.find((candidate) => candidate.roleId === "reviewer#1"); - const selectedPersonaHash = personaHash(binding?.persona); - - expect(binding?.bindingHash).toBe( - hash({ - runId: "run-1", - roleId: "reviewer#1", - templateHash: "template-hash", - personaHash: selectedPersonaHash, - backend: "fake", - override, - }), - ); - }); - - it("expands counted roles and enforces backend diversity after overrides", () => { - const result = bindTemplatePersonas({ - runId: "run-1", - template: template({ - roles: [ - { - id: "reviewer", - requiredCapabilities: ["code_review"], - count: 2, - diversity: { requireDifferentBackends: true }, - }, - ], - }), - personas: [ - persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }), - persona({ name: "codex_reviewer", backend: "codex", capabilities: ["code_review"] }), - ], - templateHash: "template-hash", - availableBackends: enabledBackends, - }); - - expect(result.bindings.map((binding) => binding.roleId)).toEqual(["reviewer#0", "reviewer#1"]); - expect(new Set(result.bindings.map((binding) => binding.backend)).size).toBe(2); - - const constrainedSecondInstance = bindTemplatePersonas({ - runId: "run-1", - template: template({ - roles: [ - { - id: "reviewer", - requiredCapabilities: ["code_review"], - count: 2, - diversity: { requireDifferentBackends: true }, - }, - ], - }), - personas: [ - persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }), - persona({ name: "codex_reviewer", backend: "codex", capabilities: ["code_review"] }), - ], - overrides: { roles: { "reviewer#1": { backend: "fake" } } }, - templateHash: "template-hash", - availableBackends: enabledBackends, - }); - - expect(constrainedSecondInstance.bindings.map((binding) => binding.backend)).toEqual([ - "codex", - "fake", - ]); - }); - - it("rejects unavailable backends, missing capabilities, role restrictions, and risk overflow", () => { - const base = { - runId: "run-1", - templateHash: "template-hash", - availableBackends: [{ id: "fake", enabled: true }] satisfies BackendConfig[], - }; - - expect(() => - bindTemplatePersonas({ - ...base, - template: template({ roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }] }), - personas: [persona({ name: "disabled", backend: "codex", capabilities: ["code_edit"] })], - }), - ).toThrow(/backend_unavailable/); - - expect(() => - bindTemplatePersonas({ - ...base, - template: template({ roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }] }), - personas: [persona({ name: "reviewer", backend: "fake", capabilities: ["code_review"] })], - }), - ).toThrow(/no_eligible_persona/); - - expect(() => - bindTemplatePersonas({ - ...base, - template: template({ roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }] }), - personas: [ - persona({ - name: "restricted", - backend: "fake", - capabilities: ["code_edit"], - allowedRoles: ["reviewer"], - }), - ], - }), - ).toThrow(/no_eligible_persona/); - - expect(() => - bindTemplatePersonas({ - ...base, - template: template({ - roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }], - phases: [{ key: "danger", title: "Danger", risk: "high", roles: ["implementer"] }], - }), - personas: [ - persona({ - name: "medium_only", - backend: "fake", - capabilities: ["code_edit"], - maxRiskLevel: "medium", - }), - ], - }), - ).toThrow(/no_eligible_persona/); - }); -}); - -function template(input: Partial> = {}): Template { - const roles = input.roles ?? [{ id: "implementer", requiredCapabilities: ["code_edit"] }]; - const phases = input.phases ?? [ - { key: "spec", title: "Spec", risk: "low", roles: [roles[0]?.id ?? "implementer"] }, - ]; - - return Template.parse({ - name: input.name ?? "development", - version: input.version ?? 1, - roles, - phases, - defaultGates: input.defaultGates ?? [], - }); -} - -function persona( - input: Partial> & Pick, "name">, -): Persona { - return Persona.parse({ - name: input.name, - version: input.version ?? 1, - backend: input.backend ?? "fake", - capabilities: input.capabilities ?? ["code_edit"], - maxRiskLevel: input.maxRiskLevel ?? "high", - promptConfig: input.promptConfig ?? {}, - modelConfig: input.modelConfig ?? {}, - ...(input.allowedRoles === undefined ? {} : { allowedRoles: input.allowedRoles }), - }); -} diff --git a/packages/core/src/binding.ts b/packages/core/src/binding.ts deleted file mode 100644 index f633deb..0000000 --- a/packages/core/src/binding.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { isAbsolute } from "node:path"; -import { z } from "zod"; - -import type { BackendConfig } from "./config.js"; -import { Backend } from "./enums.js"; -import { DevflowError } from "./errors.js"; -import { hash } from "./hash.js"; -import { type Persona, personaHash } from "./persona.js"; -import type { Template, TemplateRole } from "./template.js"; - -const riskRank = { low: 0, medium: 1, high: 2 } as const; - -export const BindingOverride = z - .object({ - persona: z.string().optional(), - backend: Backend.optional(), - }) - .strict(); - -export const BindingOverrides = z - .object({ - roles: z.record(BindingOverride).default({}), - }) - .strict(); - -export type BindingOverride = z.infer; -export type BindingOverrides = z.infer; - -export interface BindTemplatePersonasInput { - runId: string; - template: Template; - personas: Persona[]; - templateHash: string; - availableBackends: readonly BackendConfig[]; - overrides?: Partial; -} - -export interface RoleBinding { - roleId: string; - templateRoleId: string; - persona: Persona; - personaHash: string; - backend: Backend; - bindingHash: string; -} - -export interface BindingResult { - bindings: RoleBinding[]; -} - -export function bindTemplatePersonas(input: BindTemplatePersonasInput): BindingResult { - const overrides = BindingOverrides.parse(input.overrides ?? {}); - assertOverrideRoleKeys(input.template, overrides); - const bindings: RoleBinding[] = []; - - for (const role of input.template.roles) { - const assignments = selectRoleAssignments(input, role, overrides); - - for (const assignment of assignments) { - const { roleId, override, candidate } = assignment; - const personaHashValue = personaHash(candidate); - bindings.push({ - roleId, - templateRoleId: role.id, - persona: candidate, - personaHash: personaHashValue, - backend: candidate.backend, - bindingHash: hash({ - runId: input.runId, - roleId, - templateHash: input.templateHash, - personaHash: personaHashValue, - backend: candidate.backend, - override, - }), - }); - } - } - - return { bindings }; -} - -interface RoleAssignment { - roleId: string; - override: BindingOverride; - candidate: Persona; -} - -function selectRoleAssignments( - input: BindTemplatePersonasInput, - role: TemplateRole, - overrides: BindingOverrides, -): RoleAssignment[] { - const instances = roleInstances(role).map((roleId) => ({ - roleId, - override: normalizeOverride(overrides.roles[roleId] ?? overrides.roles[role.id]), - })); - const candidateLists = instances.map((instance) => ({ - ...instance, - candidates: candidatesForRoleInstance(input, role, instance.override), - })); - - for (const list of candidateLists) { - if (list.candidates.length === 0) { - throw noEligiblePersona(list.roleId); - } - } - - const assignments = assignCandidates( - candidateLists, - role.diversity?.requireDifferentBackends === true, - ); - if (assignments === undefined) { - throw noEligiblePersona(role.id, "diversity failed"); - } - - return instances.map((instance) => { - const candidate = assignments.get(instance.roleId); - if (candidate === undefined) { - throw noEligiblePersona(instance.roleId); - } - - return { ...instance, candidate }; - }); -} - -function roleInstances(role: TemplateRole): string[] { - if (role.count === 1) { - return [role.id]; - } - - return Array.from({ length: role.count }, (_, index) => `${role.id}#${index}`); -} - -function assertOverrideRoleKeys(template: Template, overrides: BindingOverrides) { - const validRoleIds = new Set(); - - for (const role of template.roles) { - validRoleIds.add(role.id); - - if (role.count > 1) { - for (let index = 0; index < role.count; index += 1) { - validRoleIds.add(`${role.id}#${index}`); - } - } - } - - for (const roleId of Object.keys(overrides.roles)) { - if (!validRoleIds.has(roleId)) { - throw noEligiblePersona(roleId, "unknown override role"); - } - } -} - -interface CandidateList { - roleId: string; - override?: BindingOverride | undefined; - candidates: Persona[]; -} - -function candidatesForRoleInstance( - input: BindTemplatePersonasInput, - role: TemplateRole, - override: BindingOverride | undefined, -): Persona[] { - const normalizedOverride = normalizeOverride(override); - const candidates: Persona[] = []; - const sortedCandidates = sortCandidates(input.personas, role) - .filter((persona) => - normalizedOverride.persona === undefined ? true : persona.name === normalizedOverride.persona, - ) - .filter((persona) => - normalizedOverride.backend === undefined - ? true - : persona.backend === normalizedOverride.backend, - ); - const selectableCandidates = hasOverrideConstraint(normalizedOverride) - ? sortedCandidates - : applyPreferredFallbackRule(sortedCandidates, role, input.template); - - for (const candidate of selectableCandidates) { - if (!isEligible(candidate, role, input.template)) { - continue; - } - - if (!isBackendAvailable(candidate.backend, input.availableBackends)) { - if (candidates.length === 0) { - throw backendUnavailable(candidate.backend); - } - - continue; - } - - candidates.push(candidate); - } - - return candidates; -} - -function hasOverrideConstraint(override: BindingOverride) { - return override.persona !== undefined || override.backend !== undefined; -} - -function applyPreferredFallbackRule( - candidates: Persona[], - role: TemplateRole, - template: Template, -): Persona[] { - if (role.preferredBackends.length === 0) { - return candidates; - } - - const preferredCandidates = candidates.filter((candidate) => - role.preferredBackends.includes(candidate.backend), - ); - if (preferredCandidates.length === 0) { - return candidates; - } - - const allPreferredFailCapabilityOrRisk = preferredCandidates.every( - (candidate) => !capabilitiesCovered(candidate, role) || !riskCovered(candidate, role, template), - ); - - return allPreferredFailCapabilityOrRisk ? candidates : preferredCandidates; -} - -function assignCandidates( - candidateLists: CandidateList[], - requireDifferentBackends: boolean, -): Map | undefined { - return assignCandidatesAt(candidateLists, requireDifferentBackends, 0, new Map(), new Set()); -} - -function assignCandidatesAt( - candidateLists: CandidateList[], - requireDifferentBackends: boolean, - index: number, - assignments: Map, - selectedBackends: Set, -): Map | undefined { - if (index >= candidateLists.length) { - return assignments; - } - - const candidateList = candidateLists[index]; - if (candidateList === undefined) { - return assignments; - } - - for (const candidate of candidateList.candidates) { - if (requireDifferentBackends && selectedBackends.has(candidate.backend)) { - continue; - } - - const nextAssignments = new Map(assignments); - const nextBackends = new Set(selectedBackends); - nextAssignments.set(candidateList.roleId, candidate); - nextBackends.add(candidate.backend); - - const result = assignCandidatesAt( - candidateLists, - requireDifferentBackends, - index + 1, - nextAssignments, - nextBackends, - ); - if (result !== undefined) { - return result; - } - } - - return undefined; -} - -function normalizeOverride(override: BindingOverride | undefined): BindingOverride { - const parsed = BindingOverride.parse(override ?? {}); - const normalized: BindingOverride = {}; - - if (parsed.persona !== undefined) { - normalized.persona = parsed.persona; - } - - if (parsed.backend !== undefined) { - normalized.backend = parsed.backend; - } - - return normalized; -} - -function sortCandidates(personas: Persona[], role: TemplateRole) { - return [...personas].sort((left, right) => { - const leftPreferredRank = preferredBackendRank(left.backend, role); - const rightPreferredRank = preferredBackendRank(right.backend, role); - - return ( - leftPreferredRank - rightPreferredRank || - right.version - left.version || - compareCodeUnits(left.name, right.name) || - compareCodeUnits(personaHash(left), personaHash(right)) - ); - }); -} - -function preferredBackendRank(backend: Backend, role: TemplateRole) { - const rank = role.preferredBackends.indexOf(backend); - if (rank >= 0) { - return rank; - } - - return role.preferredBackends.length; -} - -function isEligible(persona: Persona, role: TemplateRole, template: Template) { - return ( - roleAllowed(persona, role) && - capabilitiesCovered(persona, role) && - riskCovered(persona, role, template) - ); -} - -function roleAllowed(persona: Persona, role: TemplateRole) { - return persona.allowedRoles === undefined || persona.allowedRoles.includes(role.id); -} - -function capabilitiesCovered(persona: Persona, role: TemplateRole) { - const capabilities = new Set(persona.capabilities); - return role.requiredCapabilities.every((capability) => capabilities.has(capability)); -} - -function riskCovered(persona: Persona, role: TemplateRole, template: Template) { - const phaseRisk = template.phases - .filter((phase) => phase.roles.includes(role.id)) - .reduce((maxRisk, phase) => Math.max(maxRisk, riskRank[phase.risk]), riskRank.low); - - return phaseRisk <= riskRank[persona.maxRiskLevel]; -} - -function isBackendAvailable(backend: Backend, availableBackends: readonly BackendConfig[]) { - if (backend === "fake") { - return true; - } - - return availableBackends.some( - (backendConfig) => - backendConfig.id === backend && - backendConfig.enabled && - isResolvedBinaryPath(backendConfig.binaryPath), - ); -} - -function isResolvedBinaryPath(path: string | undefined) { - return typeof path === "string" && path.length > 0 && isAbsolute(path); -} - -function backendUnavailable(backend: string) { - return new DevflowError(`human_required:backend_unavailable:${backend}`, { - class: "human_required", - code: "backend_unavailable", - recoveryHint: `Enable ${backend} and ensure its binary resolves at process start.`, - }); -} - -function noEligiblePersona(roleId: string, reason?: string) { - return new DevflowError( - `human_required:no_eligible_persona:${roleId}${reason === undefined ? "" : `:${reason}`}`, - { - class: "human_required", - code: "no_eligible_persona", - recoveryHint: `Add or override a persona eligible for role ${roleId}.`, - }, - ); -} - -function compareCodeUnits(left: string, right: string) { - if (left < right) { - return -1; - } - - if (left > right) { - return 1; - } - - return 0; -} diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts deleted file mode 100644 index 908f69e..0000000 --- a/packages/core/src/config.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { chmodSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; - -import { loadConfigFromSources } from "./config.js"; -import { DevflowError } from "./errors.js"; - -describe("config loader", () => { - it("loads .env, .env.local, then process env in descending precedence", () => { - const root = mkdtempSync(join(tmpdir(), "devflow-config-")); - const workspace = join(root, "workspace"); - mkdirSync(workspace); - writeFileSync( - join(root, ".env"), - [ - "DATABASE_URL=postgres://env:env@localhost:5432/env", - "WORKSPACE_ROOT=workspace", - "LOG_LEVEL=warn", - "TEMPORAL_ADDRESS=localhost:7233", - ].join("\n"), - ); - writeFileSync(join(root, ".env.local"), "LOG_LEVEL=debug\n"); - - const config = loadConfigFromSources({ - cwd: root, - env: { - DATABASE_URL: "postgres://process:process@localhost:5432/process", - }, - }); - - expect(config.DATABASE_URL).toBe("postgres://process:process@localhost:5432/process"); - expect(config.LOG_LEVEL).toBe("debug"); - expect(config.WORKSPACE_ROOT).toBe(realpathSync(workspace)); - }); - - it("always exposes the fake backend as enabled", () => { - const root = mkdtempSync(join(tmpdir(), "devflow-config-")); - const workspace = join(root, "workspace"); - mkdirSync(workspace); - - const config = loadConfigFromSources({ - cwd: root, - env: { - DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", - WORKSPACE_ROOT: workspace, - LOG_LEVEL: "info", - TEMPORAL_ADDRESS: "localhost:7233", - }, - }); - - expect(config.backends).toContainEqual({ id: "fake", enabled: true }); - expect(config.SESSION_MAX_HUNG_MS).toBe(20 * 60 * 1000); - }); - - it("loads configurable session hung timeout", () => { - const root = mkdtempSync(join(tmpdir(), "devflow-config-")); - const workspace = join(root, "workspace"); - mkdirSync(workspace); - - const config = loadConfigFromSources({ - cwd: root, - env: { - DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", - WORKSPACE_ROOT: workspace, - LOG_LEVEL: "info", - TEMPORAL_ADDRESS: "localhost:7233", - SESSION_MAX_HUNG_MS: "2500", - }, - }); - - expect(config.SESSION_MAX_HUNG_MS).toBe(2500); - }); - - it("resolves backend binaries from PATH during config load", () => { - const root = mkdtempSync(join(tmpdir(), "devflow-config-")); - const workspace = join(root, "workspace"); - const binDir = join(root, "bin"); - const codexBin = join(binDir, "codex"); - mkdirSync(workspace); - mkdirSync(binDir); - writeFileSync(codexBin, "#!/bin/sh\nexit 0\n"); - chmodSync(codexBin, 0o755); - - const config = loadConfigFromSources({ - cwd: root, - env: { - DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", - WORKSPACE_ROOT: workspace, - LOG_LEVEL: "info", - TEMPORAL_ADDRESS: "localhost:7233", - PATH: binDir, - DEVFLOW_BACKENDS_JSON: JSON.stringify([{ id: "codex", enabled: true }]), - }, - }); - - expect(config.backends).toEqual([ - { id: "fake", enabled: true }, - { id: "codex", enabled: true, binaryPath: realpathSync(codexBin) }, - ]); - }); - - it("keeps enabled real backends unavailable when their binary cannot be resolved", () => { - const root = mkdtempSync(join(tmpdir(), "devflow-config-")); - const workspace = join(root, "workspace"); - const emptyBin = join(root, "empty-bin"); - mkdirSync(workspace); - mkdirSync(emptyBin); - - const config = loadConfigFromSources({ - cwd: root, - env: { - DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", - WORKSPACE_ROOT: workspace, - LOG_LEVEL: "info", - TEMPORAL_ADDRESS: "localhost:7233", - PATH: emptyBin, - DEVFLOW_BACKENDS_JSON: JSON.stringify([{ id: "codex", enabled: true }]), - }, - }); - - expect(config.backends).toEqual([ - { id: "fake", enabled: true }, - { id: "codex", enabled: true }, - ]); - }); - - it("requires LOG_LEVEL and classifies invalid config as fatal", () => { - const root = mkdtempSync(join(tmpdir(), "devflow-config-")); - const workspace = join(root, "workspace"); - mkdirSync(workspace); - - let caught: unknown; - try { - loadConfigFromSources({ - cwd: root, - env: { - DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", - WORKSPACE_ROOT: workspace, - }, - }); - } catch (error) { - caught = error; - } - - expect(caught).toBeInstanceOf(DevflowError); - expect((caught as DevflowError).class).toBe("fatal"); - expect((caught as DevflowError).code).toBe("config_invalid"); - expect((caught as DevflowError).cause).toBeDefined(); - }); - - it("requires TEMPORAL_ADDRESS at M5", () => { - const root = mkdtempSync(join(tmpdir(), "devflow-config-")); - const workspace = join(root, "workspace"); - mkdirSync(workspace); - - expect(() => - loadConfigFromSources({ - cwd: root, - env: { - DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", - WORKSPACE_ROOT: workspace, - LOG_LEVEL: "info", - }, - }), - ).toThrow(DevflowError); - }); - - it("classifies malformed backend JSON as invalid config", () => { - const root = mkdtempSync(join(tmpdir(), "devflow-config-")); - const workspace = join(root, "workspace"); - mkdirSync(workspace); - - expect(() => - loadConfigFromSources({ - cwd: root, - env: { - DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", - WORKSPACE_ROOT: workspace, - LOG_LEVEL: "info", - TEMPORAL_ADDRESS: "localhost:7233", - DEVFLOW_BACKENDS_JSON: "{", - }, - }), - ).toThrow(DevflowError); - }); - - it("freezes config and backend registrations", () => { - const root = mkdtempSync(join(tmpdir(), "devflow-config-")); - const workspace = join(root, "workspace"); - mkdirSync(workspace); - - const config = loadConfigFromSources({ - cwd: root, - env: { - DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", - WORKSPACE_ROOT: workspace, - LOG_LEVEL: "info", - TEMPORAL_ADDRESS: "localhost:7233", - }, - }); - - expect(Object.isFrozen(config)).toBe(true); - expect(Object.isFrozen(config.backends)).toBe(true); - expect(Object.isFrozen(config.backends[0])).toBe(true); - expect(() => { - (config.backends[0] as { enabled: boolean }).enabled = false; - }).toThrow(TypeError); - }); -}); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts deleted file mode 100644 index 12a6e4b..0000000 --- a/packages/core/src/config.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { constants, accessSync, existsSync, readFileSync, realpathSync, statSync } from "node:fs"; -import { delimiter, isAbsolute, resolve } from "node:path"; -import { parse } from "dotenv"; -import { z } from "zod"; - -import { Backend } from "./enums.js"; -import { DevflowError } from "./errors.js"; - -const LogLevel = z.enum(["trace", "debug", "info", "warn", "error"]); - -export const BackendConfig = z.object({ - id: Backend, - enabled: z.boolean(), - binaryPath: z.string().optional(), -}); - -export type BackendConfig = z.infer; - -const RawConfigSchema = z.object({ - DATABASE_URL: z.string().min(1), - WORKSPACE_ROOT: z.string().min(1), - LOG_LEVEL: LogLevel, - TEMPORAL_ADDRESS: z.string().min(1), - MAX_CONCURRENT_RUNS: z.coerce.number().int().positive().default(4), - SESSION_MAX_HUNG_MS: z.coerce - .number() - .int() - .positive() - .default(20 * 60 * 1000), - backends: z.array(BackendConfig).default([{ id: "fake", enabled: true }]), -}); - -type RawConfig = z.infer; - -export type Config = Omit & { - readonly WORKSPACE_ROOT: string; - readonly backends: readonly BackendConfig[]; -}; - -export const ConfigSchema = RawConfigSchema.transform( - (value): Config => - finalizeConfig(value, { - cwd: process.cwd(), - pathEnv: process.env.PATH, - }), -); - -export interface LoadConfigOptions { - cwd?: string; - env?: Record; -} - -function readEnvFile(cwd: string, fileName: string): Record { - const path = resolve(cwd, fileName); - - if (!existsSync(path)) { - return {}; - } - - return parse(readFileSync(path)); -} - -export function loadConfigFromSources(options: LoadConfigOptions = {}): Config { - try { - const cwd = options.cwd ?? process.cwd(); - const env = options.env ?? process.env; - const raw = { - ...readEnvFile(cwd, ".env"), - ...readEnvFile(cwd, ".env.local"), - ...env, - }; - const normalizedRaw = normalizeRawConfig(raw); - - if (typeof normalizedRaw.WORKSPACE_ROOT === "string") { - normalizedRaw.WORKSPACE_ROOT = resolve(cwd, normalizedRaw.WORKSPACE_ROOT); - } - - return finalizeConfig(RawConfigSchema.parse(normalizedRaw), { - cwd, - pathEnv: env.PATH ?? process.env.PATH, - }); - } catch (error) { - if (error instanceof DevflowError) { - throw error; - } - - throw configInvalid(error); - } -} - -function normalizeRawConfig(raw: Record): Record { - const backendsJson = raw.DEVFLOW_BACKENDS_JSON; - - if (!backendsJson) { - return raw; - } - - return { - ...raw, - backends: JSON.parse(backendsJson) as unknown, - }; -} - -let cachedConfig: Config | undefined; - -export function getConfig(): Config { - cachedConfig ??= loadConfigFromSources(); - return cachedConfig; -} - -function finalizeConfig( - value: RawConfig, - options: { cwd: string; pathEnv: string | undefined }, -): Config { - const canonicalWorkspaceRoot = realpathSync(resolve(value.WORKSPACE_ROOT)); - - return Object.freeze({ - ...value, - WORKSPACE_ROOT: canonicalWorkspaceRoot, - backends: Object.freeze(normalizeBackends(value.backends, options)), - }); -} - -function normalizeBackends( - backends: BackendConfig[], - options: { cwd: string; pathEnv: string | undefined }, -): BackendConfig[] { - const normalized = backends.map((backend) => normalizeBackend(backend, options)); - const hasFakeBackend = normalized.some((backend) => backend.id === "fake"); - - if (hasFakeBackend) { - return normalized; - } - - return [freezeBackend({ id: "fake", enabled: true }), ...normalized]; -} - -function normalizeBackend( - backend: BackendConfig, - options: { cwd: string; pathEnv: string | undefined }, -): BackendConfig { - if (backend.id === "fake") { - return freezeBackend({ id: "fake", enabled: true }); - } - - if (!backend.enabled) { - return freezeBackend({ id: backend.id, enabled: false }); - } - - const resolvedPath = resolveBinaryPath(backend.binaryPath ?? backend.id, options); - if (resolvedPath === undefined) { - return freezeBackend({ id: backend.id, enabled: true }); - } - - return freezeBackend({ id: backend.id, enabled: true, binaryPath: resolvedPath }); -} - -function resolveBinaryPath( - binaryPath: string, - options: { cwd: string; pathEnv: string | undefined }, -): string | undefined { - if (binaryPath.includes("/")) { - const candidate = isAbsolute(binaryPath) ? binaryPath : resolve(options.cwd, binaryPath); - return executableRealpath(candidate); - } - - for (const pathEntry of (options.pathEnv ?? "").split(delimiter)) { - if (pathEntry.length === 0) { - continue; - } - - const candidate = resolve(pathEntry, binaryPath); - const resolved = executableRealpath(candidate); - if (resolved !== undefined) { - return resolved; - } - } - - return undefined; -} - -function executableRealpath(path: string): string | undefined { - try { - const resolved = realpathSync(path); - if (!statSync(resolved).isFile()) { - return undefined; - } - - accessSync(resolved, constants.X_OK); - return resolved; - } catch { - return undefined; - } -} - -function freezeBackend(backend: BackendConfig): BackendConfig { - return Object.freeze(backend); -} - -function configInvalid(cause: unknown) { - return new DevflowError("config_invalid", { - class: "fatal", - code: "config_invalid", - cause, - recoveryHint: "Fix .env, .env.local, environment variables, and backend registrations.", - }); -} diff --git a/packages/core/src/enums.test.ts b/packages/core/src/enums.test.ts deleted file mode 100644 index f113a0d..0000000 --- a/packages/core/src/enums.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - ApprovalDecisionActionValues, - BackendValues, - CapabilityValues, - RiskLevelValues, -} from "./enums.js"; - -describe("core enums", () => { - it("keeps approval decisions separate from run pause controls", () => { - expect(ApprovalDecisionActionValues).toEqual(["approve", "reject", "request_changes", "abort"]); - expect(ApprovalDecisionActionValues).not.toContain("pause"); - }); - - it("exports the locked backend, risk, and capability sets", () => { - expect(BackendValues).toEqual(["codex", "claude", "fake"]); - expect(RiskLevelValues).toEqual(["low", "medium", "high"]); - expect(CapabilityValues).toContain("test_first_development"); - expect(CapabilityValues).toContain("backtest_run"); - }); -}); diff --git a/packages/core/src/enums.ts b/packages/core/src/enums.ts deleted file mode 100644 index 56ef0d0..0000000 --- a/packages/core/src/enums.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { z } from "zod"; - -export const BackendValues = ["codex", "claude", "fake"] as const; -export const Backend = z.enum(BackendValues); -export type Backend = z.infer; - -export const CapabilityValues = [ - "spec_write", - "phase_planning", - "task_dag_planning", - "code_edit", - "test_first_development", - "code_review", - "evidence_check", - "command_execute", - "backtest_run", - "metric_extract", - "failure_mining", - "objective_eval", - "final_report_compose", -] as const; -export const Capability = z.enum(CapabilityValues); -export type Capability = z.infer; - -export const RiskLevelValues = ["low", "medium", "high"] as const; -export const RiskLevel = z.enum(RiskLevelValues); -export type RiskLevel = z.infer; - -export const ApprovalDecisionActionValues = [ - "approve", - "reject", - "request_changes", - "abort", -] as const; -export const ApprovalDecisionAction = z.enum(ApprovalDecisionActionValues); -export type ApprovalDecisionAction = z.infer; - -export const ApprovalStateValues = [ - "pending", - "approved", - "rejected", - "changes_requested", - "aborted", - "paused", -] as const; -export const ApprovalState = z.enum(ApprovalStateValues); -export type ApprovalState = z.infer; - -export const RunStateValues = [ - "created", - "bound", - "planning", - "awaiting_approval", - "executing", - "paused", - "completed", - "failed", - "aborted", -] as const; -export const RunState = z.enum(RunStateValues); -export type RunState = z.infer; - -export const RunPhaseStateValues = [ - "pending", - "running", - "awaiting_artifact", - "validating", - "awaiting_approval", - "completed", - "failed", - "skipped", -] as const; -export const RunPhaseState = z.enum(RunPhaseStateValues); -export type RunPhaseState = z.infer; - -export const SessionStateValues = [ - "CREATED", - "BOOTSTRAPPING", - "READY", - "BUSY", - "WAITING_FOR_APPROVAL", - "ARTIFACT_TIMEOUT", - "HUNG", - "CRASHED", - "RESUMING", - "REBOOTSTRAPPED", - "FAILED_NEEDS_HUMAN", -] as const; -export const SessionState = z.enum(SessionStateValues); -export type SessionState = z.infer; diff --git a/packages/core/src/errors.test.ts b/packages/core/src/errors.test.ts deleted file mode 100644 index 96fd639..0000000 --- a/packages/core/src/errors.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { DevflowError } from "./errors.js"; - -describe("DevflowError", () => { - it("carries stable classification metadata", () => { - const error = new DevflowError("blocked", { - class: "human_required", - code: "destructive_command_blocked", - recoveryHint: "Ask for approval before running rm -rf", - }); - - expect(error.class).toBe("human_required"); - expect(error.code).toBe("destructive_command_blocked"); - expect(error.recoveryHint).toContain("approval"); - }); -}); diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts deleted file mode 100644 index 8632e7e..0000000 --- a/packages/core/src/errors.ts +++ /dev/null @@ -1,30 +0,0 @@ -export type ErrorClass = "recoverable" | "human_required" | "fatal"; - -export interface DevflowErrorOptions { - class: ErrorClass; - code: string; - runId?: string; - phaseId?: string; - recoveryHint?: string; - cause?: unknown; -} - -export class DevflowError extends Error { - readonly class: ErrorClass; - readonly code: string; - readonly runId: string | undefined; - readonly phaseId: string | undefined; - readonly recoveryHint: string | undefined; - override readonly cause: unknown; - - constructor(message: string, options: DevflowErrorOptions) { - super(message, { cause: options.cause }); - this.name = "DevflowError"; - this.class = options.class; - this.code = options.code; - this.runId = options.runId; - this.phaseId = options.phaseId; - this.recoveryHint = options.recoveryHint; - this.cause = options.cause; - } -} diff --git a/packages/core/src/hash.test.ts b/packages/core/src/hash.test.ts deleted file mode 100644 index 6133c90..0000000 --- a/packages/core/src/hash.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { canonicalize, hash } from "./hash.js"; - -describe("content hashing", () => { - it("canonicalizes object keys lexicographically while preserving array order", () => { - expect(canonicalize({ z: 1, a: [{ b: true, a: null }] })).toBe( - '{"a":[{"a":null,"b":true}],"z":1}', - ); - }); - - it("hashes equivalent object key orders to the same sha256 hex", () => { - const left = hash({ z: 1, a: 2 }); - const right = hash({ a: 2, z: 1 }); - - expect(left).toBe(right); - expect(left).toMatch(/^[a-f0-9]{64}$/); - }); - - it("preserves own __proto__ keys while canonicalizing objects", () => { - const withProtoKey = JSON.parse('{"__proto__":{"x":1},"a":2}') as unknown; - - expect(canonicalize(withProtoKey)).toBe('{"__proto__":{"x":1},"a":2}'); - expect(hash(withProtoKey)).not.toBe(hash({ a: 2 })); - }); - - it("rejects hidden object keys that would be ignored by JSON rendering", () => { - const withSymbol = { a: 1, [Symbol("x")]: 2 }; - const withHidden = { a: 1 }; - Object.defineProperty(withHidden, "hidden", { value: 2, enumerable: false }); - - expect(() => canonicalize(withSymbol)).toThrow(/non-enumerable or symbol/); - expect(() => canonicalize(withHidden)).toThrow(/non-enumerable or symbol/); - }); - - it("rejects non-index array object keys that would be ignored by JSON rendering", () => { - const withStringKey = [1] as number[] & { extra?: number }; - withStringKey.extra = 2; - const withSymbol = [1] as unknown[]; - Object.defineProperty(withSymbol, Symbol("x"), { value: 2, enumerable: true }); - - expect(() => canonicalize(withStringKey)).toThrow(/non-index array/); - expect(() => canonicalize(withSymbol)).toThrow(/non-index array/); - }); - - it("renders the shortest round-trippable number literals without plus signs", () => { - expect(canonicalize([100, 1000, 11000, 123000, 1e20, 1e21, 0.000001, 0.0000001])).toBe( - "[100,1e3,11e3,123e3,1e20,1e21,1e-6,1e-7]", - ); - }); - - it("rejects values that are not JSON-safe", () => { - const sparse = Array(3); - sparse[0] = 1; - sparse[2] = 3; - - expect(() => canonicalize({ date: new Date("2026-05-09T00:00:00Z") })).toThrow( - /non-plain object/, - ); - expect(() => canonicalize({ missing: undefined })).toThrow(/undefined/); - expect(() => canonicalize(sparse)).toThrow(/sparse array/); - }); -}); diff --git a/packages/core/src/hash.ts b/packages/core/src/hash.ts deleted file mode 100644 index ed86a6e..0000000 --- a/packages/core/src/hash.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { createHash } from "node:crypto"; - -type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; - -export function canonicalize(value: unknown): string { - return renderCanonical(assertJsonValue(value)); -} - -export function hash(value: unknown): string { - return createHash("sha256").update(canonicalize(value)).digest("hex"); -} - -function renderCanonical(value: JsonValue): string { - if (value === null || typeof value === "boolean" || typeof value === "string") { - return JSON.stringify(value); - } - - if (typeof value === "number") { - return renderCanonicalNumber(value); - } - - if (Array.isArray(value)) { - return `[${value.map((item) => renderCanonical(item)).join(",")}]`; - } - - return `{${Object.keys(value) - .sort() - .map((key) => `${JSON.stringify(key)}:${renderCanonical(value[key] as JsonValue)}`) - .join(",")}}`; -} - -function assertJsonValue(value: unknown): JsonValue { - if ( - value === null || - typeof value === "boolean" || - typeof value === "string" || - Array.isArray(value) - ) { - if (Array.isArray(value)) { - assertOnlyArrayIndexKeys(value); - const arrayValue: JsonValue[] = []; - for (let index = 0; index < value.length; index += 1) { - if (!(index in value)) { - throw new TypeError(`Cannot canonicalize sparse array at index ${index}`); - } - - arrayValue.push(assertJsonValue(value[index])); - } - - return arrayValue; - } - - return value; - } - - if (typeof value === "number") { - if (!Number.isFinite(value)) { - throw new TypeError("Cannot canonicalize non-finite numbers"); - } - - return value; - } - - if (typeof value === "object") { - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - throw new TypeError("Cannot canonicalize non-plain object"); - } - - assertOnlyEnumerableStringKeys(value); - const objectValue: Record = Object.create(null) as Record; - - for (const [key, childValue] of Object.entries(value as Record)) { - if (childValue === undefined) { - throw new TypeError(`Cannot canonicalize undefined at key ${key}`); - } - - objectValue[key] = assertJsonValue(childValue); - } - - return objectValue; - } - - throw new TypeError(`Cannot canonicalize ${typeof value}`); -} - -function assertOnlyEnumerableStringKeys(value: object) { - const enumerableStringKeys = Object.keys(value); - const ownKeys = Reflect.ownKeys(value); - const hasOnlyEnumerableStringKeys = - ownKeys.length === enumerableStringKeys.length && - ownKeys.every( - (key) => typeof key === "string" && Object.prototype.propertyIsEnumerable.call(value, key), - ); - - if (!hasOnlyEnumerableStringKeys) { - throw new TypeError("Cannot canonicalize non-enumerable or symbol object keys"); - } -} - -function assertOnlyArrayIndexKeys(value: unknown[]) { - for (const key of Reflect.ownKeys(value)) { - if (key === "length") { - continue; - } - - if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) { - throw new TypeError("Cannot canonicalize non-index array object keys"); - } - - const index = Number(key); - if (!Number.isInteger(index) || index < 0 || index >= value.length || String(index) !== key) { - throw new TypeError("Cannot canonicalize non-index array object keys"); - } - } -} - -function renderCanonicalNumber(value: number): string { - if (Object.is(value, -0) || value === 0) { - return "0"; - } - - const candidates = new Set(); - addNumberCandidate(candidates, value, value.toString()); - const jsonCandidate = JSON.stringify(value); - if (jsonCandidate !== undefined) { - addNumberCandidate(candidates, value, jsonCandidate); - } - - for (let precision = 1; precision <= 17; precision += 1) { - addNumberCandidate(candidates, value, value.toPrecision(precision)); - addNumberCandidate(candidates, value, value.toExponential(precision - 1)); - } - - const [best] = [...candidates].sort( - (left, right) => left.length - right.length || compareCodeUnits(left, right), - ); - if (!best) { - throw new TypeError(`Cannot canonicalize number ${value}`); - } - - return best; -} - -function addNumberCandidate(candidates: Set, value: number, raw: string) { - const candidate = normalizeNumberLiteral(raw); - for (const equivalent of expandNumberLiteral(candidate)) { - if (Number(equivalent) === value) { - candidates.add(equivalent); - } - } -} - -function compareCodeUnits(left: string, right: string) { - if (left < right) { - return -1; - } - - if (left > right) { - return 1; - } - - return 0; -} - -function normalizeNumberLiteral(raw: string): string { - const [mantissaText, exponentText] = raw.toLowerCase().split("e"); - const mantissa = normalizeDecimal(mantissaText ?? ""); - if (exponentText === undefined) { - return mantissa; - } - - const exponent = normalizeExponent(exponentText); - if (exponent === "0") { - return mantissa; - } - - return `${mantissa}e${exponent}`; -} - -function normalizeDecimal(raw: string): string { - if (!raw.includes(".")) { - return raw; - } - - const trimmed = raw.replace(/0+$/, "").replace(/\.$/, ""); - if (trimmed === "-0") { - return "0"; - } - - return trimmed; -} - -function normalizeExponent(raw: string): string { - const sign = raw.startsWith("-") ? "-" : ""; - const unsigned = raw.replace(/^[+-]/, "").replace(/^0+/, ""); - if (unsigned === "") { - return "0"; - } - - return `${sign}${unsigned}`; -} - -function expandNumberLiteral(raw: string): string[] { - const parsed = parseNumberLiteral(raw); - if (!parsed) { - return [raw]; - } - - const plain = renderPlainDecimal(parsed); - const candidates = new Set([plain]); - - for (let integerDigits = 1; integerDigits <= parsed.digits.length; integerDigits += 1) { - const exponent = parsed.power + parsed.digits.length - integerDigits; - if (exponent === 0) { - continue; - } - - const mantissa = - integerDigits === parsed.digits.length - ? parsed.digits - : `${parsed.digits.slice(0, integerDigits)}.${parsed.digits.slice(integerDigits)}`; - candidates.add(`${parsed.sign}${mantissa}e${exponent}`); - } - - return [...candidates]; -} - -function parseNumberLiteral(raw: string) { - const match = raw.match(/^(-?)(\d+)(?:\.(\d+))?(?:e(-?\d+))?$/); - if (!match) { - return undefined; - } - - const [, sign, integerPart, fractionalPart = "", exponentPart] = match; - let digits = `${integerPart}${fractionalPart}`; - let power = (exponentPart === undefined ? 0 : Number(exponentPart)) - fractionalPart.length; - digits = digits.replace(/^0+/, ""); - if (digits === "") { - return { sign: "", digits: "0", power: 0 }; - } - - const lengthBeforeTrailingTrim = digits.length; - digits = digits.replace(/0+$/, ""); - power += lengthBeforeTrailingTrim - digits.length; - - return { sign: sign ?? "", digits, power }; -} - -function renderPlainDecimal(parsed: { sign: string; digits: string; power: number }) { - if (parsed.digits === "0") { - return "0"; - } - - if (parsed.power >= 0) { - return `${parsed.sign}${parsed.digits}${"0".repeat(parsed.power)}`; - } - - const pointIndex = parsed.digits.length + parsed.power; - if (pointIndex > 0) { - return `${parsed.sign}${parsed.digits.slice(0, pointIndex)}.${parsed.digits.slice(pointIndex)}`; - } - - return `${parsed.sign}0.${"0".repeat(-pointIndex)}${parsed.digits}`; -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts deleted file mode 100644 index 5e27efc..0000000 --- a/packages/core/src/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export * from "./artifact-schema.js"; -export * from "./config.js"; -export * from "./binding.js"; -export * from "./enums.js"; -export * from "./errors.js"; -export * from "./hash.js"; -export * from "./persona.js"; -export * from "./prompt-envelope.js"; -export * from "./registry-loader.js"; -export * from "./run-event.js"; -export * from "./template.js"; -export * from "./version.js"; diff --git a/packages/core/src/persona.ts b/packages/core/src/persona.ts deleted file mode 100644 index 72b3cb4..0000000 --- a/packages/core/src/persona.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { z } from "zod"; - -import { Backend, Capability, RiskLevel } from "./enums.js"; -import { hash } from "./hash.js"; -import { DbIntVersion } from "./version.js"; - -export type JsonValue = - | null - | boolean - | number - | string - | JsonValue[] - | { [key: string]: JsonValue }; - -export type JsonObject = { [key: string]: JsonValue }; - -export const JsonObject: z.ZodType = z.lazy(() => - z - .custom>(isPlainJsonRecordInput, { - message: "expected plain JSON object", - }) - .superRefine((value, context) => { - for (const key of Reflect.ownKeys(value)) { - if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: "expected plain JSON object", - }); - continue; - } - - if (!isSafeJsonObjectKey(key)) { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: "reserved object key", - path: [key], - }); - } - } - }) - .pipe(z.record(z.string(), JsonValue)), -); - -export const JsonValue: z.ZodType = z.lazy(() => - z.union([z.null(), z.boolean(), z.number().finite(), z.string(), JsonArray, JsonObject]), -); - -export const JsonArray: z.ZodType = z.lazy(() => - z - .custom(Array.isArray, { message: "expected JSON array" }) - .superRefine((value, context) => { - for (const key of Reflect.ownKeys(value)) { - if (key === "length") { - continue; - } - - if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: "expected JSON array", - }); - continue; - } - - const index = Number(key); - if ( - !Number.isInteger(index) || - index < 0 || - index >= value.length || - String(index) !== key - ) { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: "expected JSON array", - }); - } - } - }) - .pipe(z.array(JsonValue)), -); - -export const Persona = z - .object({ - name: z.string().min(1), - version: DbIntVersion, - backend: Backend, - capabilities: z.array(Capability), - maxRiskLevel: RiskLevel, - allowedRoles: z.array(z.string().min(1)).optional(), - promptConfig: z - .object({ - systemPrompt: z.string().optional(), - instructionsPrelude: z.string().optional(), - }) - .strict() - .default({}) - .transform((value) => { - const promptConfig: { systemPrompt?: string; instructionsPrelude?: string } = {}; - if (value.systemPrompt !== undefined) { - promptConfig.systemPrompt = value.systemPrompt; - } - if (value.instructionsPrelude !== undefined) { - promptConfig.instructionsPrelude = value.instructionsPrelude; - } - - return promptConfig; - }), - modelConfig: JsonObject.default({}), - }) - .strict(); - -export type Persona = z.infer; - -export function personaHash(persona: Persona | undefined): string { - if (!persona) { - throw new TypeError("persona is required"); - } - - const hashSubject = { - name: persona.name, - version: persona.version, - capabilities: persona.capabilities, - backend: persona.backend, - maxRiskLevel: persona.maxRiskLevel, - promptConfig: persona.promptConfig, - modelConfig: persona.modelConfig, - }; - - return hash( - persona.allowedRoles === undefined - ? hashSubject - : { ...hashSubject, allowedRoles: persona.allowedRoles }, - ); -} - -function isSafeJsonObjectKey(key: string) { - return key !== "__proto__" && key !== "constructor" && key !== "prototype"; -} - -function isPlainJsonRecordInput(value: unknown) { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - return false; - } - - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} diff --git a/packages/core/src/prompt-envelope.test.ts b/packages/core/src/prompt-envelope.test.ts deleted file mode 100644 index 54a592b..0000000 --- a/packages/core/src/prompt-envelope.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { PromptEnvelope, renderPromptEnvelope } from "./prompt-envelope.js"; - -describe("prompt envelope", () => { - it("validates and renders the locked wire markers", () => { - const envelope = PromptEnvelope.parse({ - uuid: "00000000-0000-4000-8000-000000000000", - runId: "11111111-1111-4111-8111-111111111111", - roleId: "planner", - phaseKey: "plan", - attempt: 0, - expectedArtifact: "/tmp/devflow/spec.json", - expectedSchema: "dev/spec@1", - dedupKey: "a".repeat(64), - instructions: "Write the spec.", - }); - - expect(renderPromptEnvelope(envelope)).toContain( - "DEVFLOW_PROMPT_BEGIN 00000000-0000-4000-8000-000000000000", - ); - expect(renderPromptEnvelope(envelope)).toContain("Phase: plan"); - expect(renderPromptEnvelope(envelope)).toContain("DEVFLOW_PROMPT_END"); - }); -}); diff --git a/packages/core/src/prompt-envelope.ts b/packages/core/src/prompt-envelope.ts deleted file mode 100644 index 4ccf304..0000000 --- a/packages/core/src/prompt-envelope.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from "zod"; - -export const PromptEnvelope = z.object({ - uuid: z.string().uuid(), - runId: z.string().uuid(), - roleId: z.string().min(1), - phaseKey: z.string().min(1), - attempt: z.number().int().nonnegative(), - expectedArtifact: z.string().min(1), - expectedSchema: z.string().min(1), - dedupKey: z.string().regex(/^[a-f0-9]{64}$/), - instructions: z.string(), -}); - -export type PromptEnvelope = z.infer; - -export function renderPromptEnvelope(envelope: PromptEnvelope): string { - return [ - `DEVFLOW_PROMPT_BEGIN ${envelope.uuid}`, - `Run: ${envelope.runId}`, - `Role: ${envelope.roleId}`, - `Phase: ${envelope.phaseKey}`, - `Attempt: ${envelope.attempt}`, - `Expected artifact: ${envelope.expectedArtifact}`, - `Expected schema: ${envelope.expectedSchema}`, - `Dedup-Key: ${envelope.dedupKey}`, - "Instructions:", - envelope.instructions, - `DEVFLOW_PROMPT_END ${envelope.uuid}`, - ].join("\n"); -} diff --git a/packages/core/src/registry-loader.test.ts b/packages/core/src/registry-loader.test.ts deleted file mode 100644 index f7ffd4d..0000000 --- a/packages/core/src/registry-loader.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; - -import { Persona } from "./persona.js"; -import { - assertNoReferencedRegistryDeletions, - buildRegistrySeedPlan, - loadPersonaFiles, - loadTemplateFiles, - personaHash, - templateHash, -} from "./registry-loader.js"; - -function makeRoot() { - return mkdtempSync(join(tmpdir(), "devflow-registry-")); -} - -describe("registry loader", () => { - it("loads versioned persona YAML files and computes stable hashes", () => { - const root = makeRoot(); - const dir = join(root, "personas"); - mkdirSync(dir, { recursive: true }); - writeFileSync( - join(dir, "fake_developer@1.yaml"), - [ - "name: fake_developer", - "version: 1", - "backend: fake", - "capabilities:", - " - code_edit", - "maxRiskLevel: medium", - ].join("\n"), - ); - - const [entry] = loadPersonaFiles(dir); - - expect(entry?.name).toBe("fake_developer"); - expect(entry?.version).toBe(1); - expect(entry?.path).toBe(realpathSync(join(dir, "fake_developer@1.yaml"))); - expect(entry?.hash).toMatch(/^[a-f0-9]{64}$/); - expect(entry?.hash).toBe(personaHash(entry?.definition)); - }); - - it("rejects non-canonical template filenames and filename identity mismatches", () => { - const root = makeRoot(); - const dir = join(root, "templates"); - mkdirSync(dir, { recursive: true }); - writeFileSync( - join(dir, "development@1.yaml"), - [ - "name: development", - "version: 1", - "roles:", - " - id: implementer", - " requiredCapabilities:", - " - code_edit", - "phases:", - " - key: spec", - " title: Spec", - " risk: low", - " roles:", - " - implementer", - ].join("\n"), - ); - writeFileSync( - join(dir, "development@01.yaml"), - ["name: development", "version: 1", "roles: []", "phases: []"].join("\n"), - ); - writeFileSync( - join(dir, "actual_name@2.yml"), - ["name: actual_name", "version: 2", "roles: []", "phases: []"].join("\n"), - ); - - expect(() => loadTemplateFiles(dir)).toThrow(/registry filename/); - - const validDir = join(root, "valid-templates"); - mkdirSync(validDir); - writeFileSync(join(validDir, "development@1.yaml"), readDevelopmentTemplate()); - const [entry] = loadTemplateFiles(validDir); - - expect(entry?.hash).toBe(templateHash(entry?.definition)); - - const mismatchDir = join(root, "mismatched-templates"); - mkdirSync(mismatchDir); - writeFileSync(join(mismatchDir, "wrong@1.yaml"), readDevelopmentTemplate()); - - expect(() => loadTemplateFiles(mismatchDir)).toThrow(/identity mismatch/); - }); - - it("rejects registry versions outside the database integer range", () => { - const root = makeRoot(); - const personaDir = join(root, "personas"); - mkdirSync(personaDir); - writeFileSync( - join(personaDir, "fake@2147483648.yaml"), - [ - "name: fake", - "version: 2147483648", - "backend: fake", - "capabilities: []", - "maxRiskLevel: low", - ].join("\n"), - ); - - expect(() => loadPersonaFiles(personaDir)).toThrow(/less than or equal/); - }); - - it("rejects unknown template and persona keys instead of silently stripping them", () => { - const root = makeRoot(); - const personaDir = join(root, "personas"); - const templateDir = join(root, "templates"); - mkdirSync(personaDir); - mkdirSync(templateDir); - writeFileSync( - join(personaDir, "fake@1.yaml"), - [ - "name: fake", - "version: 1", - "backend: fake", - "capabilities: []", - "maxRiskLevel: low", - "typo: accepted", - ].join("\n"), - ); - writeFileSync( - join(templateDir, "development@1.yaml"), - ["name: development", "version: 1", "roles: []", "phases: []", "typo: accepted"].join("\n"), - ); - - expect(() => loadPersonaFiles(personaDir)).toThrow(/Unrecognized key/); - expect(() => loadTemplateFiles(templateDir)).toThrow(/Unrecognized key/); - }); - - it("rejects persona model config values that cannot be content-hashed as JSON", () => { - const root = makeRoot(); - const personaDir = join(root, "personas"); - mkdirSync(personaDir); - writeFileSync( - join(personaDir, "fake@1.yaml"), - [ - "name: fake", - "version: 1", - "backend: fake", - "capabilities: []", - "maxRiskLevel: low", - "modelConfig:", - " temperature: .nan", - ].join("\n"), - ); - - expect(() => loadPersonaFiles(personaDir)).toThrow(/finite|number|expected plain JSON object/); - }); - - it("rejects persona model config keys that would mutate object prototypes", () => { - const root = makeRoot(); - const personaDir = join(root, "personas"); - mkdirSync(personaDir); - writeFileSync( - join(personaDir, "fake@1.yaml"), - [ - "name: fake", - "version: 1", - "backend: fake", - "capabilities: []", - "maxRiskLevel: low", - "modelConfig:", - ' "__proto__":', - " x: 1", - ].join("\n"), - ); - - expect(() => loadPersonaFiles(personaDir)).toThrow(/reserved object key/); - }); - - it("rejects non-plain programmatic model config objects", () => { - class ModelConfig { - readonly inherited = true; - } - - expect(() => - Persona.parse({ - name: "fake", - version: 1, - backend: "fake", - capabilities: [], - maxRiskLevel: "low", - modelConfig: new ModelConfig(), - }), - ).toThrow(/expected plain JSON object/); - }); - - it("rejects programmatic model config arrays with non-index keys", () => { - const array = [1] as unknown[] & { extra?: number }; - array.extra = 2; - - expect(() => - Persona.parse({ - name: "fake", - version: 1, - backend: "fake", - capabilities: [], - maxRiskLevel: "low", - modelConfig: { array }, - }), - ).toThrow(/expected JSON array/); - }); - - it("rejects symlinked registry files", () => { - const root = makeRoot(); - const dir = join(root, "personas"); - mkdirSync(dir); - const target = join(root, "target.yaml"); - writeFileSync( - target, - ["name: fake", "version: 1", "backend: fake", "capabilities: []", "maxRiskLevel: low"].join( - "\n", - ), - ); - symlinkSync(target, join(dir, "fake@1.yaml")); - - expect(() => loadPersonaFiles(dir)).toThrow(/not a symlink/); - }); - - it("builds seed actions and fails on published hash mismatch", () => { - const root = makeRoot(); - const personaDir = join(root, "personas"); - mkdirSync(personaDir); - writeFileSync( - join(personaDir, "fake@1.yaml"), - ["name: fake", "version: 1", "backend: fake", "capabilities: []", "maxRiskLevel: low"].join( - "\n", - ), - ); - const [entry] = loadPersonaFiles(personaDir); - if (!entry) { - throw new Error("expected persona registry entry"); - } - - expect(buildRegistrySeedPlan([entry], [])).toEqual({ - unchanged: [], - inserts: [entry], - missingReferenced: [], - missingUnreferenced: [], - }); - expect(() => - buildRegistrySeedPlan( - [entry], - [{ name: "fake", version: 1, hash: "different", referencedByRun: false }], - ), - ).toThrow(/published registry entry was modified/); - }); - - it("reports published registry rows that no longer have YAML files", () => { - const plan = buildRegistrySeedPlan( - [], - [ - { name: "unused", version: 1, hash: "abc", referencedByRun: false }, - { name: "referenced", version: 1, hash: "def", referencedByRun: true }, - ], - ); - - expect(plan).toEqual({ - inserts: [], - missingReferenced: [{ name: "referenced", version: 1, hash: "def", referencedByRun: true }], - missingUnreferenced: [{ name: "unused", version: 1, hash: "abc", referencedByRun: false }], - unchanged: [], - }); - }); - - it("rejects referenced published registry deletions", () => { - const plan = buildRegistrySeedPlan( - [], - [{ name: "referenced", version: 1, hash: "def", referencedByRun: true }], - ); - - expect(() => assertNoReferencedRegistryDeletions("persona", plan)).toThrow(/referenced@1/); - }); -}); - -function readDevelopmentTemplate() { - return [ - "name: development", - "version: 1", - "roles:", - " - id: implementer", - " requiredCapabilities:", - " - code_edit", - "phases:", - " - key: spec", - " title: Spec", - " risk: low", - " roles:", - " - implementer", - ].join("\n"); -} diff --git a/packages/core/src/registry-loader.ts b/packages/core/src/registry-loader.ts deleted file mode 100644 index 5f1f4a1..0000000 --- a/packages/core/src/registry-loader.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs"; -import { basename, join } from "node:path"; -import { parse } from "yaml"; - -import { Persona, personaHash } from "./persona.js"; -import { Template, templateHash } from "./template.js"; - -export interface RegistryEntry { - name: string; - version: number; - hash: string; - definition: TDefinition; - path: string; -} - -export interface PublishedRegistryRow { - name: string; - version: number; - hash: string; - referencedByRun: boolean; -} - -export interface RegistrySeedPlan { - inserts: RegistryEntry[]; - missingReferenced: PublishedRegistryRow[]; - missingUnreferenced: PublishedRegistryRow[]; - unchanged: RegistryEntry[]; -} - -export type RegistryKind = "persona" | "template"; - -export function loadPersonaFiles(directory: string): RegistryEntry[] { - return loadVersionedYamlFiles(directory, Persona, personaHash); -} - -export function loadTemplateFiles(directory: string): RegistryEntry