diff --git a/apps/api/package.json b/apps/api/package.json index 1002b61..2c90752 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,8 @@ "@devflow/run-engine": "workspace:*", "@devflow/session": "workspace:*", "@devflow/workflows": "workspace:*", - "@temporalio/client": "^1.17.1" + "@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 new file mode 100644 index 0000000..262bb60 --- /dev/null +++ b/apps/api/src/http.test.ts @@ -0,0 +1,952 @@ +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 new file mode 100644 index 0000000..e710b8b --- /dev/null +++ b/apps/api/src/http.ts @@ -0,0 +1,615 @@ +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.ts b/apps/api/src/index.ts index 97f1d92..01bec7e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -15,8 +15,11 @@ import { 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 { @@ -62,10 +65,57 @@ export interface StartTemporalApiResult { 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; @@ -254,7 +304,7 @@ function dbOnlySessionRuntime(): SessionRuntime { } if (isDirectEntry(import.meta.url, process.argv)) { - startApi() + startHttpApi() .then(async (api) => { await waitForShutdownSignal(); await api.stop(); diff --git a/apps/api/src/sse.ts b/apps/api/src/sse.ts new file mode 100644 index 0000000..2566f3f --- /dev/null +++ b/apps/api/src/sse.ts @@ -0,0 +1,443 @@ +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/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..23c73fa --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + Devflow + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..520877e --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,14 @@ +{ + "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 new file mode 100644 index 0000000..8e67280 --- /dev/null +++ b/apps/web/src/api.ts @@ -0,0 +1,43 @@ +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 new file mode 100644 index 0000000..295ff13 --- /dev/null +++ b/apps/web/src/main.ts @@ -0,0 +1,661 @@ +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 new file mode 100644 index 0000000..b9d7bfe --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,317 @@ +: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 new file mode 100644 index 0000000..0f524f2 --- /dev/null +++ b/apps/web/src/view-model.test.ts @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..1a54797 --- /dev/null +++ b/apps/web/src/view-model.ts @@ -0,0 +1,108 @@ +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 new file mode 100644 index 0000000..20ed4c8 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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 new file mode 100644 index 0000000..78d31d4 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,10 @@ +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/docs/plan.md b/docs/plan.md index f0ce057..5fd908e 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1460,9 +1460,16 @@ Events: Reconnect: -- `Last-Event-ID` is last `run_events.seq`. -- server replays `seq > lastSeq`. -- non-run-event SSE types are not replayed; state is re-derived by fetch. +- Run-scoped `/sse/runs/:runId`: + - `Last-Event-ID` is last `run_events.seq` for that run. + - server replays `run.event_appended` for `seq > lastSeq`. + - derived non-`run.event_appended` SSE types are not replayed for historical rows; state is re-derived by fetch. +- Global `/sse/global`: + - `Last-Event-ID` is last global `run_events.id`, because `run_events.seq` is only monotonic within a run. + - fresh connects start at the latest global event id and emit only new summary events. + - reconnects replay rows with `id > lastId`. + - global stream emits only scope=`both` events: `run.state_changed`, `approval.created`, `approval.resolved`. + - global stream never emits `run.event_appended`. ## 18. Errors @@ -1768,6 +1775,9 @@ M5+: | CC-33 | API-side already-applied `reject` / `abort` replay tried to dispose sessions through DB-only replay validation runtime | API replay side effects are report-repair only; worker-side decision application owns session disposal | | CC-34 | Closed-workflow approval settlement waited for reports but did not replay approval side effects | settlement now verifies the requested decision, replays side effects, then waits for the terminal report | | CC-35 | Baseline-protected BUSY replay recorded synthetic prompt proof before the baseline wait was durable | baseline replay no longer records synthetic prompt events; replay without real prompt proof keeps treating existing files as stale | +| CC-36 | SSE reconnect wording used per-run `seq` for global stream even though `seq` is not globally monotonic | `/sse/runs/:runId` uses per-run `seq`; `/sse/global` uses global `run_events.id` and emits only scope=`both` summary events | +| CC-37 | Run SSE replay could emit historical derived events after the first page | run SSE drains historical rows up to a high-water `seq` with only `run.event_appended`, then switches to live derived events | +| CC-38 | Normal phase start changed run state to `planning` / `executing` without a summary event source | `phase.started` payload includes `runState`; SSE derives `run.state_changed` from that live event | ### Future Open Questions diff --git a/packages/run-engine/src/engine.ts b/packages/run-engine/src/engine.ts index 7f42078..314de95 100644 --- a/packages/run-engine/src/engine.ts +++ b/packages/run-engine/src/engine.ts @@ -648,7 +648,7 @@ export class DbRunEngine implements RunEngine { await eventRepository.appendInTransaction(tx, { runId, type: "run.resumed", - payload: { cause }, + payload: { cause, resumedTo: nextState }, idempotencyKey: `run.resumed:${runId}:${cause}`, }); shouldAdvance = nextState === "executing" || nextState === "planning"; @@ -1103,7 +1103,7 @@ export class DbRunEngine implements RunEngine { await eventRepository.appendInTransaction(tx, { runId, type: "run.resumed", - payload: { cause: `approval:${approvalRequestId}:${action}` }, + payload: { cause: `approval:${approvalRequestId}:${action}`, resumedTo: "executing" }, idempotencyKey: `run.resumed:${runId}:approval:${approvalRequestId}:${action}`, }); return { replayed: false }; @@ -1125,7 +1125,7 @@ export class DbRunEngine implements RunEngine { await eventRepository.appendInTransaction(tx, { runId, type: "run.resumed", - payload: { cause: `approval:${approvalRequestId}:${action}` }, + payload: { cause: `approval:${approvalRequestId}:${action}`, resumedTo: "planning" }, idempotencyKey: `run.resumed:${runId}:approval:${approvalRequestId}:${action}`, }); return { replayed: false }; diff --git a/packages/run-engine/src/fake-phase-harness.ts b/packages/run-engine/src/fake-phase-harness.ts index f5d5f66..197604a 100644 --- a/packages/run-engine/src/fake-phase-harness.ts +++ b/packages/run-engine/src/fake-phase-harness.ts @@ -1280,7 +1280,12 @@ async function tryStartPhaseAndRecord( runId: input.runId, phaseId: input.phaseId, type: "phase.started", - payload: { phaseKey: input.phaseKey, attempt: updatedPhase.attempts, ...payload }, + payload: { + phaseKey: input.phaseKey, + attempt: updatedPhase.attempts, + runState: run.state, + ...payload, + }, idempotencyKey: `phase.started:${input.phaseId}:${updatedPhase.attempts}`, }); return updatedPhase.attempts; @@ -1657,7 +1662,14 @@ async function requestWorkflowApproval( }); } - await appendHumanGateRequestedEventInTransaction(input, eventRepository, tx, request, gateKey); + await appendHumanGateRequestedEventInTransaction(input, eventRepository, tx, request, gateKey, { + runState: "awaiting_approval", + phaseState: "awaiting_approval", + sessionState: "WAITING_FOR_APPROVAL", + sessionId, + roleId: input.roleId, + phaseKey: input.phaseKey, + }); }); } @@ -2158,6 +2170,15 @@ interface ArtifactRecord { validationError: unknown; } +interface ApprovalRequestedStatePayload { + runState?: string; + phaseState?: string; + sessionState?: string; + sessionId?: string; + roleId?: string; + phaseKey?: string; +} + async function waitForAndValidateArtifact( input: CanonicalRunSingleFakePhaseInput, eventRepository: RunEventRepository, @@ -2879,6 +2900,7 @@ async function appendHumanGateRequestedEventInTransaction( tx: TransactionDb, request: HumanGateRequest, gateKey: string, + statePayload: ApprovalRequestedStatePayload = {}, ) { await eventRepository.appendInTransaction(tx, { runId: input.runId, @@ -2888,6 +2910,7 @@ async function appendHumanGateRequestedEventInTransaction( approvalRequestId: request.id, approvalIdempotencyKey: request.idempotencyKey, gateKey, + ...statePayload, }, idempotencyKey: `approval.requested:${request.idempotencyKey}`, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f21232a..391d4a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,9 +75,15 @@ importers: '@devflow/workflows': specifier: workspace:* version: link:../../packages/workflows + '@fastify/sensible': + specifier: '6' + version: 6.0.4 '@temporalio/client': specifier: ^1.17.1 version: 1.17.1 + fastify: + specifier: '5' + version: 5.8.5 apps/cli: dependencies: @@ -94,6 +100,12 @@ importers: specifier: 3.24.1 version: 3.24.1 + apps/web: + devDependencies: + vite: + specifier: 6.0.3 + version: 6.0.3(@types/node@22.10.2)(terser@5.47.1)(tsx@4.21.0)(yaml@2.6.1) + apps/worker: dependencies: '@devflow/core': @@ -1163,6 +1175,27 @@ packages: cpu: [x64] os: [win32] + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/sensible@6.0.4': + resolution: {integrity: sha512-1vxcCUlPMew6WroK8fq+LVOwbsLtX+lmuRuqpcp6eYqu6vmkLwbKTdBWAZwbeaSgCfW4tzUpTIHLLvTiQQ1BwQ==} + '@grpc/grpc-js@1.14.3': resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} engines: {node: '>=12.10.0'} @@ -1319,6 +1352,13 @@ packages: peerDependencies: tslib: '2' + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1718,6 +1758,9 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn-import-phases@1.0.4: resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} engines: {node: '>=10.13.0'} @@ -1737,6 +1780,14 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@5.1.0: resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} peerDependencies: @@ -1768,6 +1819,13 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1850,6 +1908,14 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1867,6 +1933,14 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -2054,12 +2128,30 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2069,10 +2161,18 @@ packages: picomatch: optional: true + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} @@ -2116,6 +2216,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + hyperdyperid@1.2.0: resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} engines: {node: '>=10.18'} @@ -2124,6 +2228,13 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -2158,6 +2269,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -2215,6 +2329,9 @@ packages: resolution: {integrity: sha512-w9sBoR0mdN+kJc3SB85VzpiAAl451/rxdCRcZlwW71QLjkeH3EBQFgc4VMj5apePychYDHAlqEWTB8J8JK/j1Q==} hasBin: true + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2255,6 +2372,10 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + memfs@4.57.2: resolution: {integrity: sha512-2nWzSsJzrukurSDna4Z0WywuScK4Id3tSKejgu74u8KCdW4uNrseKRSIDg75C6Yw5ZRqBe0F0EtMNlTbUq8bAQ==} peerDependencies: @@ -2267,6 +2388,10 @@ packages: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -2308,6 +2433,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -2367,6 +2496,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -2409,6 +2548,12 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + proto3-json-serializer@2.0.2: resolution: {integrity: sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==} engines: {node: '>=14.0.0'} @@ -2425,10 +2570,20 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2444,6 +2599,17 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rollup@4.60.3: resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2452,6 +2618,14 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2459,11 +2633,20 @@ packages: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@7.8.0: resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} engines: {node: '>=10'} hasBin: true + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2479,6 +2662,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2512,6 +2698,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -2619,6 +2809,10 @@ packages: peerDependencies: tslib: ^2 + thread-stream@4.1.0: + resolution: {integrity: sha512-Bw6h2iBDt16v6iHLChBIoVYU8CBo9GPsW8TG7h1hRVhqKhIkH6N8qkxNSmiOZTKsCLPbtWG4ViWLkU6KeKXpig==} + engines: {node: '>=20'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2641,6 +2835,14 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} @@ -2689,6 +2891,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typescript@5.6.3: resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} engines: {node: '>=14.17'} @@ -2710,6 +2916,10 @@ packages: resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-node@2.1.8: resolution: {integrity: sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -3384,6 +3594,39 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.2 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + + '@fastify/sensible@6.0.4': + dependencies: + '@lukeed/ms': 2.0.2 + dequal: 2.0.3 + fastify-plugin: 5.1.0 + forwarded: 0.2.0 + http-errors: 2.0.1 + type-is: 2.0.1 + vary: 1.1.2 + '@grpc/grpc-js@1.14.3': dependencies: '@grpc/proto-loader': 0.8.1 @@ -3555,6 +3798,10 @@ snapshots: '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) tslib: 2.8.1 + '@lukeed/ms@2.0.2': {} + + '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -4001,6 +4248,8 @@ snapshots: dependencies: event-target-shim: 5.0.1 + abstract-logging@2.0.1: {} + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -4011,6 +4260,10 @@ snapshots: optionalDependencies: ajv: 8.17.1 + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv-keywords@5.1.0(ajv@8.17.1): dependencies: ajv: 8.17.1 @@ -4037,6 +4290,13 @@ snapshots: assertion-error@2.0.1: {} + atomic-sleep@1.0.0: {} + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -4106,6 +4366,10 @@ snapshots: consola@3.4.2: {} + content-type@1.0.5: {} + + cookie@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4118,6 +4382,10 @@ snapshots: deep-eql@5.0.2: {} + depd@2.0.0: {} + + dequal@2.0.3: {} + dotenv@17.4.2: {} drizzle-kit@0.31.10: @@ -4338,19 +4606,66 @@ snapshots: expect-type@1.3.0: {} + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + fast-uri@3.1.2: {} + fastify-plugin@5.1.0: {} + + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.0 + toad-cache: 3.7.0 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 signal-exit: 4.1.0 + forwarded@0.2.0: {} + fs-monkey@1.1.0: {} fsevents@2.3.3: @@ -4385,12 +4700,24 @@ snapshots: html-escaper@2.0.2: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + hyperdyperid@1.2.0: {} iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 + inherits@2.0.4: {} + + ipaddr.js@2.4.0: {} + is-fullwidth-code-point@3.0.0: {} isexe@2.0.0: {} @@ -4430,6 +4757,10 @@ snapshots: joycon@3.1.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + json-schema-traverse@1.0.0: {} lefthook-darwin-arm64@2.1.6: @@ -4475,6 +4806,12 @@ snapshots: lefthook-windows-arm64: 2.1.6 lefthook-windows-x64: 2.1.6 + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -4507,6 +4844,8 @@ snapshots: dependencies: semver: 7.8.0 + media-typer@1.1.0: {} + memfs@4.57.2(tslib@2.8.1): dependencies: '@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1) @@ -4528,6 +4867,10 @@ snapshots: mime-db@1.54.0: {} + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -4558,6 +4901,8 @@ snapshots: object-assign@4.1.1: {} + on-exit-leak-free@2.1.2: {} + package-json-from-dist@1.0.1: {} path-key@3.1.1: {} @@ -4610,6 +4955,26 @@ snapshots: picomatch@4.0.4: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.1.0 + pirates@4.0.7: {} postcss-load-config@6.0.1(postcss@8.5.14)(tsx@4.19.2)(yaml@2.6.1): @@ -4636,6 +5001,10 @@ snapshots: dependencies: xtend: 4.0.2 + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + proto3-json-serializer@2.0.2: dependencies: protobufjs: 7.5.7 @@ -4672,8 +5041,14 @@ snapshots: punycode@2.3.1: {} + quick-format-unescaped@4.0.4: {} + readdirp@4.1.2: {} + real-require@0.2.0: {} + + real-require@1.0.0: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -4682,6 +5057,12 @@ snapshots: resolve-pkg-maps@1.0.0: {} + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + rollup@4.60.3: dependencies: '@types/estree': 1.0.8 @@ -4717,6 +5098,12 @@ snapshots: dependencies: tslib: 2.8.1 + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} schema-utils@4.3.3: @@ -4726,8 +5113,14 @@ snapshots: ajv-formats: 2.1.1(ajv@8.17.1) ajv-keywords: 5.1.0(ajv@8.17.1) + secure-json-parse@4.1.0: {} + semver@7.8.0: {} + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -4738,6 +5131,10 @@ snapshots: signal-exit@4.1.0: {} + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map-loader@4.0.2(webpack@5.106.2(@swc/core@1.15.33)(esbuild@0.24.2)(postcss@8.5.14)): @@ -4763,6 +5160,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} string-width@4.2.3: @@ -4848,6 +5247,10 @@ snapshots: dependencies: tslib: 2.8.1 + thread-stream@4.1.0: + dependencies: + real-require: 1.0.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -4863,6 +5266,10 @@ snapshots: tinyspy@3.0.2: {} + toad-cache@3.7.0: {} + + toidentifier@1.0.1: {} + tr46@1.0.1: dependencies: punycode: 2.3.1 @@ -4919,6 +5326,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@5.6.3: {} undici-types@6.20.0: {} @@ -4935,6 +5348,8 @@ snapshots: uuid@11.1.1: {} + vary@1.1.2: {} + vite-node@2.1.8(@types/node@22.10.2)(terser@5.47.1): dependencies: cac: 6.7.14 @@ -4975,6 +5390,18 @@ snapshots: tsx: 4.19.2 yaml: 2.6.1 + vite@6.0.3(@types/node@22.10.2)(terser@5.47.1)(tsx@4.21.0)(yaml@2.6.1): + dependencies: + esbuild: 0.24.2 + postcss: 8.5.14 + rollup: 4.60.3 + optionalDependencies: + '@types/node': 22.10.2 + fsevents: 2.3.3 + terser: 5.47.1 + tsx: 4.21.0 + yaml: 2.6.1 + vitest@2.1.8(@types/node@22.10.2)(terser@5.47.1): dependencies: '@vitest/expect': 2.1.8 diff --git a/tsconfig.json b/tsconfig.json index 2f3cf5c..9adf4f4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ { "path": "./packages/workflows" }, { "path": "./apps/api" }, { "path": "./apps/cli" }, + { "path": "./apps/web" }, { "path": "./apps/worker" } ] } diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 0f530cb..e8bde4d 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -5,8 +5,9 @@ "composite": false, "declaration": false, "declarationMap": false, + "lib": ["ES2022", "DOM", "DOM.Iterable"], "noEmit": true, - "types": ["node", "vitest"], + "types": ["node", "vite/client", "vitest"], "paths": { "@devflow/core": ["packages/core/src/index.ts"], "@devflow/db": ["packages/db/src/index.ts"], diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 8cf06f8..69c5af5 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -31,5 +31,6 @@ export default defineWorkspace([ nodeProject("packages/workflows", ["packages/workflows/src/**/*.test.ts"]), nodeProject("apps/api", ["apps/api/src/**/*.test.ts"]), nodeProject("apps/cli", ["apps/cli/src/**/*.test.ts"]), + nodeProject("apps/web", ["apps/web/src/**/*.test.ts"]), nodeProject("apps/worker", ["apps/worker/src/**/*.test.ts"]), ]);