feat: add minimum API and web GUI

This commit is contained in:
chungyeong
2026-05-14 01:16:41 +09:00
parent e5020a59f0
commit c9fed71cc9
21 changed files with 3757 additions and 11 deletions

View File

@@ -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"
}
}

952
apps/api/src/http.test.ts Normal file
View File

@@ -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<void> {
this.approvalSignals.push({
action,
approvalRequestId,
clientToken,
...(comment === undefined ? {} : { comment }),
runId,
});
}
async pauseRun(_runId: string): Promise<void> {
return;
}
async resumeRun(_runId: string): Promise<void> {
return;
}
async abortRun(_runId: string, _reason: string): Promise<void> {
return;
}
async getStatus(runId: string): Promise<RunStatus> {
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<void> {
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<string> {
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<string> {
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<void>,
headers?: Record<string, string>,
): Promise<string> {
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);
});
}

615
apps/api/src/http.ts Normal file
View File

@@ -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<FastifyInstance> {
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<boolean> {
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<T>(
db: Database,
key: string,
operation: () => Promise<T>,
): Promise<T> {
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<RunStartInput["overrides"]>;
}
if (isRecord(record.scenarios)) {
input.scenarios = record.scenarios as NonNullable<RunStartInput["scenarios"]>;
}
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<string, unknown> {
if (!isRecord(value)) {
throw badRequest("Request body must be a JSON object", "invalid_request_body");
}
return value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
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();
}
}

View File

@@ -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<StartApiResult> {
return startTemporalApi(options);
}
export async function startHttpApi(options: StartHttpApiOptions = {}): Promise<StartHttpApiResult> {
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<StartM4ApiResult> {
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();

443
apps/api/src/sse.ts Normal file
View File

@@ -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<RunEventRow[]> {
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<bigint> {
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<RunEventRow[]> {
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<bigint> {
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<TranscriptChunkRow[]> {
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<bigint> {
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<string, unknown>)[key];
return typeof value === "string" ? value : null;
}

12
apps/web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Devflow</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

14
apps/web/package.json Normal file
View File

@@ -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"
}
}

43
apps/web/src/api.ts Normal file
View File

@@ -0,0 +1,43 @@
import { normalizeApiBase } from "./view-model.js";
export interface ApiClient {
get<T>(path: string): Promise<T>;
post<T>(path: string, body?: unknown): Promise<T>;
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<T>(method: string, url: string, body?: unknown): Promise<T> {
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<string, unknown>).error;
if (error !== null && typeof error === "object") {
const message = (error as Record<string, unknown>).message;
if (typeof message === "string") {
return message;
}
}
}
return `HTTP ${status}`;
}

661
apps/web/src/main.ts Normal file
View File

@@ -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<HTMLDivElement>("#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<string, string>();
const approvalTokens = new Map<string, string>();
window.addEventListener("hashchange", () => {
void render();
});
void render();
async function render(): Promise<void> {
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<void> {
const { runs, sessions, status } = await loadActiveRunData();
app.innerHTML = shell(
"dashboard",
`
${doctorPanel()}
<section class="band split">
<div>
<div class="section-title">
<h2>Runs</h2>
<button class="small" data-action="refresh">Refresh</button>
</div>
${runsTable(runs)}
</div>
<div>
<div class="section-title">
<h2>Run Detail</h2>
${activeRunId === undefined ? "" : `<span class="mono">${escapeHtml(activeRunId)}</span>`}
</div>
${status === undefined ? empty("No run selected") : runDetail(status, sessions)}
</div>
</section>
`,
);
bindDashboardActions();
connectRunStream(activeRunId);
}
async function renderRunDetailScreen(): Promise<void> {
const { runs, sessions, status } = await loadActiveRunData();
app.innerHTML = shell(
"run-detail",
`
<section class="band split">
<div>
<div class="section-title"><h2>Runs</h2><button class="small" data-action="refresh">Refresh</button></div>
${runsTable(runs)}
</div>
<div>
<div class="section-title"><h2>Run Detail</h2>${activeRunId === undefined ? "" : `<span class="mono">${escapeHtml(activeRunId)}</span>`}</div>
${status === undefined ? empty("No run selected") : runDetail(status, sessions)}
</div>
</section>
`,
);
bindDashboardActions();
connectRunStream(activeRunId);
}
async function renderApprovalsScreen(): Promise<void> {
const { runs, status } = await loadActiveRunData();
app.innerHTML = shell(
"approvals",
`
<section class="band split">
<div>
<div class="section-title"><h2>Runs</h2><button class="small" data-action="refresh">Refresh</button></div>
${runsTable(runs)}
</div>
<div>
<div class="section-title"><h2>Approvals</h2>${activeRunId === undefined ? "" : `<span class="mono">${escapeHtml(activeRunId)}</span>`}</div>
${
status === undefined || status.approvals.length === 0
? empty("No approvals")
: `<table><tbody>${status.approvals.map(approvalRow).join("")}</tbody></table>`
}
</div>
</section>
`,
);
bindDashboardActions();
connectRunStream(activeRunId);
}
async function renderSessionsScreen(): Promise<void> {
const { runs, sessions } = await loadActiveRunData();
app.innerHTML = shell(
"sessions",
`
<section class="band split">
<div>
<div class="section-title"><h2>Runs</h2><button class="small" data-action="refresh">Refresh</button></div>
${runsTable(runs)}
</div>
<div>
<div class="section-title"><h2>TUI Sessions</h2>${activeRunId === undefined ? "" : `<span class="mono">${escapeHtml(activeRunId)}</span>`}</div>
${sessions.length === 0 ? empty("No sessions") : sessionsTable(sessions)}
</div>
</section>
`,
);
bindDashboardActions();
connectRunStream(activeRunId);
}
async function renderTemplates(): Promise<void> {
const { templates } = await api.get<{ templates: TemplateSummary[] }>("/api/templates");
app.innerHTML = shell(
"templates",
`
<section class="band">
<div class="section-title"><h2>Templates</h2><span>${templates.length} loaded</span></div>
<table>
<thead><tr><th>Name</th><th>Version</th><th>Roles</th><th>Phases</th><th>Hash</th></tr></thead>
<tbody>
${templates
.map(
(template) => `
<tr>
<td>${escapeHtml(template.name)}</td>
<td>${template.version}</td>
<td>${arrayLength(template.definition.roles)}</td>
<td>${arrayLength(template.definition.phases)}</td>
<td class="mono">${escapeHtml(template.hash.slice(0, 16))}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</section>
`,
);
}
async function renderPersonas(): Promise<void> {
const { personas } = await api.get<{ personas: PersonaSummary[] }>("/api/personas");
app.innerHTML = shell(
"personas",
`
<section class="band">
<div class="section-title"><h2>Personas</h2><span>${personas.length} loaded</span></div>
<table>
<thead><tr><th>Name</th><th>Version</th><th>Backend</th><th>Risk</th><th>Capabilities</th></tr></thead>
<tbody>
${personas
.map(
(persona) => `
<tr>
<td>${escapeHtml(persona.name)}</td>
<td>${persona.version}</td>
<td>${escapeHtml(persona.definition.backend ?? "")}</td>
<td>${escapeHtml(persona.definition.maxRiskLevel ?? "")}</td>
<td>${arrayLength(persona.definition.capabilities)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</section>
`,
);
}
async function renderNewRun(): Promise<void> {
const [{ templates }, { personas }] = await Promise.all([
api.get<{ templates: TemplateSummary[] }>("/api/templates"),
api.get<{ personas: PersonaSummary[] }>("/api/personas"),
]);
app.innerHTML = shell(
"new-run",
`
<section class="band new-run">
<div class="section-title">
<h2>New Run</h2>
<span>${templates.length} templates · ${personas.length} personas</span>
</div>
<form id="new-run-form">
<label>Requirements<textarea name="requirementsMd" rows="8" required></textarea></label>
<div class="form-grid">
<label>Repository path<input name="repoPath" required /></label>
<label>Base branch<input name="baseBranch" value="main" required /></label>
<label>Template name<input name="templateName" value="development" /></label>
<label>Template version<input name="templateVersion" type="number" value="1" min="1" /></label>
</div>
<label>Scenarios JSON<textarea name="scenarios" rows="5" placeholder='{"spec":"ok","phase_plan":"ok"}'></textarea></label>
<div class="actions"><button type="submit">Start Run</button><span id="form-status"></span></div>
</form>
</section>
`,
);
bindNewRunForm();
}
function shell(active: string, content: string): string {
return `
<header>
<div>
<h1>Devflow</h1>
<p>Local workflow control plane</p>
</div>
<nav>
${screens
.map(
(screen) =>
`<a class="${screen === active ? "active" : ""}" href="#/${screen}">${screenLabel(screen)}</a>`,
)
.join("")}
</nav>
</header>
<main>${content}</main>
`;
}
function doctorPanel(): string {
const status = highestDoctorStatus(doctorChecks);
const visible = doctorChecks.filter((check) => check.status !== "pass");
return `
<section class="doctor ${status}">
<strong>Doctor</strong>
${
visible.length === 0
? "<span>All visible checks pass</span>"
: visible
.map((check) => `<span>${escapeHtml(check.name)}: ${escapeHtml(check.detail)}</span>`)
.join("")
}
</section>
`;
}
function runsTable(runs: RunSummary[]): string {
if (runs.length === 0) {
return empty("No runs yet");
}
return `
<table>
<thead><tr><th>State</th><th>Repo</th><th>Branch</th><th>Created</th><th></th></tr></thead>
<tbody>
${runs
.map(
(run) => `
<tr class="${run.id === activeRunId ? "selected" : ""}">
<td><span class="pill ${stateTone(run.state)}">${escapeHtml(run.state)}</span></td>
<td title="${escapeHtml(run.repoPath)}">${escapeHtml(compactPath(run.repoPath))}</td>
<td>${escapeHtml(run.baseBranch)}</td>
<td>${escapeHtml(formatDateTime(run.createdAt))}</td>
<td><button class="small" data-run-id="${escapeHtml(run.id)}">Open</button></td>
</tr>
`,
)
.join("")}
</tbody>
</table>
`;
}
function runDetail(status: RunStatus, sessions: SessionSummary[]): string {
return `
<div class="detail-grid">
<div><span>State</span><strong class="pill ${stateTone(status.run.state)}">${escapeHtml(status.run.state)}</strong></div>
<div><span>Worktree</span><strong title="${escapeHtml(status.run.worktreeRoot)}">${escapeHtml(compactPath(status.run.worktreeRoot))}</strong></div>
<div><span>Final report</span><strong>${escapeHtml(status.run.finalReportPath ?? "")}</strong></div>
</div>
<h3>Phases</h3>
<table>
<thead><tr><th>Phase</th><th>State</th><th>Attempts</th></tr></thead>
<tbody>
${status.phases
.map(
(phase) =>
`<tr><td>${escapeHtml(phase.phaseKey)}</td><td>${escapeHtml(phase.state)}</td><td>${phase.attempts}</td></tr>`,
)
.join("")}
</tbody>
</table>
<h3>Approvals</h3>
${
status.approvals.length === 0
? empty("No approvals")
: `<table><tbody>${status.approvals.map(approvalRow).join("")}</tbody></table>`
}
<h3>TUI Sessions</h3>
${sessions.length === 0 ? empty("No sessions") : sessionsTable(sessions)}
<h3>Event Tail</h3>
<pre>${escapeHtml([...status.eventsTail.map((event) => `${event.seq} ${event.type}`), ...liveEvents].slice(-40).join("\n"))}</pre>
`;
}
function sessionsTable(sessions: SessionSummary[]): string {
return `<table>
<thead><tr><th>Role</th><th>Backend</th><th>State</th><th>CWD</th><th>Artifact</th><th>Recovery</th></tr></thead>
<tbody>${sessions.map(sessionRow).join("")}</tbody>
</table>`;
}
function sessionRow(session: SessionSummary): string {
const artifact =
session.expectedArtifactPath === null
? ""
: `${session.expectedSchema ?? ""} ${compactPath(session.expectedArtifactPath)}`;
return `
<tr>
<td>${escapeHtml(session.roleId)}</td>
<td>${escapeHtml(session.backend)}</td>
<td><span class="pill ${stateTone(session.state)}">${escapeHtml(session.state)}</span></td>
<td title="${escapeHtml(session.cwd)}">${escapeHtml(compactPath(session.cwd))}</td>
<td title="${escapeHtml(session.expectedArtifactPath ?? "")}">${escapeHtml(artifact)}</td>
<td>${session.recoveryAttempts}</td>
</tr>
`;
}
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 `
<tr>
<td>${escapeHtml(approval.gateKey)}</td>
<td>${escapeHtml(approval.state)}</td>
<td class="approval-actions">
${actionCells}
</td>
</tr>
${error === undefined ? "" : `<tr><td colspan="3" class="error">${escapeHtml(error)}</td></tr>`}
`;
}
function approvalButton(
approvalId: string,
action: ApprovalDecisionAction,
disabled: string,
): string {
const danger = action === "reject" || action === "abort" ? " danger" : "";
return `<button class="small${danger}" data-approval="${escapeHtml(approvalId)}" data-action="${action}" ${disabled}>${approvalActionLabel(action)}</button>`;
}
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<HTMLButtonElement>("[data-run-id]")) {
button.addEventListener("click", () => {
activeRunId = button.dataset.runId;
liveEvents = [];
void render();
});
}
for (const button of document.querySelectorAll<HTMLButtonElement>("[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<HTMLButtonElement>("[data-approval]")) {
if (button.dataset.approval === approvalId) {
button.disabled = true;
}
}
}
function bindNewRunForm(): void {
document
.querySelector<HTMLFormElement>("#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<DoctorCheck[]> {
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<RunStatus> {
return api.get<RunStatus>(`/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<SessionSummary[]> {
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 `<div class="empty">${escapeHtml(text)}</div>`;
}
function errorBanner(error: unknown): string {
return `<section class="doctor fail"><strong>Error</strong><span>${escapeHtml(error instanceof Error ? error.message : String(error))}</span></section>`;
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

317
apps/web/src/styles.css Normal file
View File

@@ -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;
}
}

View File

@@ -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"]);
});
});

108
apps/web/src/view-model.ts Normal file
View File

@@ -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);
}

10
apps/web/tsconfig.json Normal file
View File

@@ -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"]
}

10
apps/web/vite.config.ts Normal file
View File

@@ -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",
},
},
});

View File

@@ -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

View File

@@ -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 };

View File

@@ -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}`,
});

427
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -13,6 +13,7 @@
{ "path": "./packages/workflows" },
{ "path": "./apps/api" },
{ "path": "./apps/cli" },
{ "path": "./apps/web" },
{ "path": "./apps/worker" }
]
}

View File

@@ -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"],

View File

@@ -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"]),
]);