chore: Step 0 — purge TS monorepo per plan-v4 (Python rewrite complete)

Removes the pre-Python-rewrite TypeScript implementation in full. All domain
functionality has been re-implemented in Python under my-deepagent/ (Step 1~15,
579 unit/integration tests + 1 real-OpenRouter E2E PASS).

Deleted directories (4):
- apps/      — TS api/cli/web/worker apps; replaced by my-deepagent/src/my_deepagent/cli/
- packages/  — TS core/db/run-engine/session/workflows packages; replaced by my-deepagent/src/my_deepagent/{config,enums,persona,workflow,binding,persistence,engine,session,…}
- tests/     — TS workspace smoke tests + fixtures; replaced by my-deepagent/tests/{unit,integration}/
- scripts/   — migrate.ts, seed.ts; replaced by my-deepagent alembic + persistence/db.py

Deleted files (10):
- pnpm-lock.yaml, pnpm-workspace.yaml, package.json
- biome.json, lefthook.yml, vitest.workspace.ts, drizzle.config.ts
- tsconfig.base.json, tsconfig.json, tsconfig.typecheck.json
- .nvmrc

Recovery point:
- Tag `pre-python-rewrite` at c9fed71 — `git checkout pre-python-rewrite -- <path>`
  retrieves any historical TS file if ever needed.

Preserved (per plan-v4-draft.md):
- docs/                      — plan.md (v3 r13, will be patched to v4 r1 next), schemas/
- docker-compose.yml         — Postgres + Temporal containers (still relevant for M5)
- .env.example               — base env contract
- my-deepagent/              — Python implementation
- my-deepagent-seed/         — v0.1.0 bootstrap kit (PoC + seed yaml/json), kept as
                               historical reference; pruning is a separate decision.

.gitignore rewritten for Python-only project (.venv, __pycache__, *.sqlite3, …)
with Node entries dropped.

--no-verify: lefthook (TS-only) was just deleted with this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chungyeong
2026-05-16 17:11:41 +09:00
parent 733c9be0bd
commit 0e61b2d907
127 changed files with 23 additions and 38553 deletions

28
.gitignore vendored
View File

@@ -1,12 +1,30 @@
node_modules/
dist/
coverage/
.turbo/
.DS_Store
.env
.env.local
.env.*.local
*.log
*.tsbuildinfo
data/
!.gitkeep
# Python
__pycache__/
*.py[cod]
*$py.class
.venv/
.python-version
*.egg-info/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
htmlcov/
# Build / IDE
dist/
build/
.idea/
.vscode/
# SQLite local
*.sqlite3
*.sqlite3-journal

1
.nvmrc
View File

@@ -1 +0,0 @@
22

View File

@@ -1 +0,0 @@

View File

@@ -1,21 +0,0 @@
{
"name": "@devflow/api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsup src/index.ts --format esm --clean",
"typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit",
"test": "cd ../.. && vitest run --project apps/api"
},
"dependencies": {
"@devflow/core": "workspace:*",
"@devflow/db": "workspace:*",
"@devflow/run-engine": "workspace:*",
"@devflow/session": "workspace:*",
"@devflow/workflows": "workspace:*",
"@fastify/sensible": "6",
"@temporalio/client": "^1.17.1",
"fastify": "5"
}
}

View File

@@ -1,952 +0,0 @@
import { randomUUID } from "node:crypto";
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { get } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ApprovalDecisionAction } from "@devflow/core";
import {
type DbClient,
RunEventRepository,
agentPersonas,
approvalDecisions,
approvalRequests,
createDbClient,
runInputs,
runs,
tuiSessions,
workflowTemplates,
} from "@devflow/db";
import type { RunEngine, RunStartInput, RunStatus } from "@devflow/run-engine";
import { and, eq, inArray } from "drizzle-orm";
import { afterEach, describe, expect, it } from "vitest";
import { createHttpApi } from "./http.js";
import { formatSseMessage, runEventMessages } from "./sse.js";
const databaseUrl =
process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow";
class RecordingEngine implements RunEngine {
readonly approvalSignals: Array<{
action: ApprovalDecisionAction;
approvalRequestId: string;
clientToken: string;
comment?: string;
runId: string;
}> = [];
readonly startedRuns: RunStartInput[] = [];
constructor(
protected readonly db: DbClient["db"],
private readonly runId = randomUUID(),
) {}
async startRun(input: RunStartInput): Promise<{ runId: string }> {
this.startedRuns.push(input);
return { runId: this.runId };
}
async signalApproval(
runId: string,
approvalRequestId: string,
action: ApprovalDecisionAction,
clientToken: string,
comment?: string,
): Promise<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);
});
}

View File

@@ -1,615 +0,0 @@
import { ApprovalDecisionAction, DevflowError, getConfig } from "@devflow/core";
import type { DbClient } from "@devflow/db";
import {
agentPersonas,
approvalDecisions,
runs,
tuiSessions,
tuiTranscriptChunks,
workflowTemplates,
} from "@devflow/db";
import { type RunEngine, type RunStartInput, readRunStatus } from "@devflow/run-engine";
import sensible from "@fastify/sensible";
import { and, desc, eq, sql } from "drizzle-orm";
import Fastify, { type FastifyInstance, type FastifyReply } from "fastify";
import {
formatSseComment,
formatSseMessage,
latestGlobalRunEventId,
latestRunEventSeq,
latestTranscriptChunkId,
openSseResponse,
readGlobalRunEventRows,
readRunEventRows,
readTranscriptChunkRows,
runEventMessages,
transcriptChunkMessage,
} from "./sse.js";
type Database = DbClient["db"];
export interface HttpApiOptions {
db: Database;
engine: RunEngine;
heartbeatMs?: number;
pollMs?: number;
}
export async function createHttpApi(options: HttpApiOptions): Promise<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();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,334 +0,0 @@
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { type BackendConfig, getConfig } from "@devflow/core";
import { DevflowError } from "@devflow/core";
import { type DbClient, createDbClient } from "@devflow/db";
import { DbRunEngine, type RunEngine, readRunStatus } from "@devflow/run-engine";
import {
FakeSessionAdapter,
type SessionAdapter,
SessionManager,
type SessionManagerRecoveryResult,
type SessionRuntime,
} from "@devflow/session";
import { TemporalRunEngine, temporalNamespace } from "@devflow/workflows";
import { Connection, WorkflowClient } from "@temporalio/client";
import { createHttpApi } from "./http.js";
import { recoverM4ApiStartup, startM4SessionManager } from "./startup.js";
export * from "./http.js";
export * from "./sse.js";
export * from "./startup.js";
export interface StartM4ApiOptions {
dbClient?: DbClient;
workspaceRoot?: string;
availableBackends?: readonly BackendConfig[];
recoveryRunIds?: readonly string[];
sessionAdapter?: SessionAdapter;
sessionManager?: SessionManager;
runEngine?: RunEngine;
maxConcurrentRuns?: number;
sessionMaxHungMs?: number;
}
export interface StartM4ApiResult {
recovery: Awaited<ReturnType<typeof recoverM4ApiStartup>>;
sessionRecovery: SessionManagerRecoveryResult;
sessionManager: SessionManager;
engine: RunEngine;
finalReportRecovery: string[];
stop(): Promise<void>;
}
export interface StartTemporalApiOptions {
dbClient?: DbClient;
temporalClient?: WorkflowClient;
temporalAddress?: string;
taskQueue?: string;
workflowIdPrefix?: string;
awaitRunStart?: boolean;
awaitSignals?: boolean;
availableBackends?: readonly BackendConfig[];
maxConcurrentRuns?: number;
workspaceRoot?: string;
sessionMaxHungMs?: number;
}
export interface StartTemporalApiResult {
engine: RunEngine;
stop(): Promise<void>;
}
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;
const dbClient =
options.dbClient ?? createDbClient(config?.DATABASE_URL ?? getConfig().DATABASE_URL);
const sessionMaxHungMs = options.sessionMaxHungMs ?? config?.SESSION_MAX_HUNG_MS;
const sessionManager =
options.sessionManager ??
new SessionManager({
dbClient,
adapter: options.sessionAdapter ?? new FakeSessionAdapter(),
...(options.recoveryRunIds === undefined ? {} : { recoveryRunIds: options.recoveryRunIds }),
});
const engine =
options.runEngine ??
new DbRunEngine({
db: dbClient.db,
sessions: sessionManager,
workspaceRoot: options.workspaceRoot ?? config?.WORKSPACE_ROOT ?? getConfig().WORKSPACE_ROOT,
...(options.availableBackends === undefined
? config?.backends === undefined
? {}
: { availableBackends: config.backends }
: { availableBackends: options.availableBackends }),
...(options.maxConcurrentRuns === undefined
? {}
: { maxConcurrentRuns: options.maxConcurrentRuns }),
...(sessionMaxHungMs === undefined ? {} : { recovery: { maxHungMs: sessionMaxHungMs } }),
});
try {
await sessionManager.acquireLock();
const recovery = await recoverM4ApiStartup(
dbClient.db,
options.recoveryRunIds === undefined ? {} : { runIds: options.recoveryRunIds },
);
const sessionRecovery = await startM4SessionManager(sessionManager);
const finalReportRecovery =
engine instanceof DbRunEngine
? await engine.recoverMissingFinalReports(
options.recoveryRunIds === undefined ? {} : { runIds: options.recoveryRunIds },
)
: [];
return {
engine,
finalReportRecovery,
recovery,
sessionRecovery,
sessionManager,
async stop() {
try {
await sessionManager.shutdown();
} finally {
if (ownedClient) {
await dbClient.close();
}
}
},
};
} catch (error) {
if (options.sessionManager === undefined) {
await sessionManager.shutdown().catch(() => undefined);
}
if (ownedClient) {
await dbClient.close();
}
throw error;
}
}
export async function startTemporalApi(
options: StartTemporalApiOptions = {},
): Promise<StartTemporalApiResult> {
const ownedClient = options.dbClient === undefined;
const config =
options.dbClient === undefined || options.temporalClient === undefined
? getConfig()
: undefined;
const dbClient =
options.dbClient ?? createDbClient(config?.DATABASE_URL ?? getConfig().DATABASE_URL);
const ownedTemporalClient = options.temporalClient === undefined;
let connection: Connection | undefined;
let temporalClient: WorkflowClient;
if (options.temporalClient === undefined) {
connection = await Connection.connect({
address: options.temporalAddress ?? config?.TEMPORAL_ADDRESS ?? getConfig().TEMPORAL_ADDRESS,
});
temporalClient = new WorkflowClient({ connection, namespace: temporalNamespace });
} else {
temporalClient = options.temporalClient;
}
const replayValidationWorkspaceRoot =
options.workspaceRoot ?? config?.WORKSPACE_ROOT ?? getConfig().WORKSPACE_ROOT;
const replayValidationBackends = options.availableBackends ?? config?.backends;
const replayValidationMaxConcurrentRuns =
options.maxConcurrentRuns ?? config?.MAX_CONCURRENT_RUNS;
const replayValidationSessionMaxHungMs = options.sessionMaxHungMs ?? config?.SESSION_MAX_HUNG_MS;
const replayValidationEngine = new DbRunEngine({
db: dbClient.db,
sessions: dbOnlySessionRuntime(),
workspaceRoot: replayValidationWorkspaceRoot,
...(replayValidationBackends === undefined
? {}
: { availableBackends: replayValidationBackends }),
...(replayValidationMaxConcurrentRuns === undefined
? {}
: { maxConcurrentRuns: replayValidationMaxConcurrentRuns }),
...(replayValidationSessionMaxHungMs === undefined
? {}
: { recovery: { maxHungMs: replayValidationSessionMaxHungMs } }),
});
const engine = new TemporalRunEngine({
client: temporalClient,
startReplayValidator: {
validateStartReplay: (input) => replayValidationEngine.validatePreparedRunInput(input),
},
approvalSignalReader: {
readApprovalSignalResult: (runId, approvalRequestId, action, clientToken) =>
replayValidationEngine.readApprovalSignalResult(
runId,
approvalRequestId,
action,
clientToken,
),
validateApprovalSignalInput: (runId, approvalRequestId, action, clientToken) =>
replayValidationEngine.validateApprovalSignalInput(
runId,
approvalRequestId,
action,
clientToken,
),
replayAppliedApprovalSideEffects: (runId, action) =>
replayValidationEngine.replayAppliedApprovalSideEffects(runId, action, {
disposeSessions: false,
}),
},
controlValidator: {
validateResumeSignalInput: (runId) => replayValidationEngine.validateResumeSignalInput(runId),
},
statusReader: {
getStatus: (runId) => readRunStatus(dbClient.db, runId),
},
...(options.taskQueue === undefined ? {} : { taskQueue: options.taskQueue }),
...(options.workflowIdPrefix === undefined
? {}
: { workflowIdPrefix: options.workflowIdPrefix }),
...(options.awaitRunStart === undefined ? {} : { awaitRunStart: options.awaitRunStart }),
...(options.awaitSignals === undefined ? {} : { awaitSignals: options.awaitSignals }),
});
return {
engine,
async stop() {
if (ownedTemporalClient) {
await connection?.close();
}
if (ownedClient) {
await dbClient.close();
}
},
};
}
function dbOnlySessionRuntime(): SessionRuntime {
const rejectMutation = (operation: string) =>
Promise.reject(
new DevflowError("API replay validation cannot mutate TUI sessions", {
class: "fatal",
code: "internal_state_corruption",
recoveryHint: operation,
}),
);
return {
trackOperation: (operation) => operation,
start: () => rejectMutation("start"),
sendPrompt: () => rejectMutation("sendPrompt"),
probe: () => rejectMutation("probe"),
resume: () => rejectMutation("resume"),
rebootstrap: () => rejectMutation("rebootstrap"),
async *capture() {
yield await rejectMutation("capture");
},
dispose: () => rejectMutation("dispose"),
};
}
if (isDirectEntry(import.meta.url, process.argv)) {
startHttpApi()
.then(async (api) => {
await waitForShutdownSignal();
await api.stop();
})
.catch((error: unknown) => {
console.error(error);
process.exitCode =
error instanceof DevflowError && error.code === "session_manager_already_running" ? 3 : 2;
});
}
function isDirectEntry(importMetaUrl: string, argv: readonly string[]): boolean {
const entry = argv[1];
return entry !== undefined && resolve(entry) === fileURLToPath(importMetaUrl);
}
function waitForShutdownSignal(): Promise<void> {
return new Promise((resolveSignal) => {
const resolveOnce = () => {
process.off("SIGINT", resolveOnce);
process.off("SIGTERM", resolveOnce);
resolveSignal();
};
process.once("SIGINT", resolveOnce);
process.once("SIGTERM", resolveOnce);
});
}

View File

@@ -1,443 +0,0 @@
import type { ServerResponse } from "node:http";
import { and, asc, desc, eq, gt, lte } from "drizzle-orm";
import type { DbClient } from "@devflow/db";
import { runEvents, tuiSessions, tuiTranscriptChunks } from "@devflow/db";
type Database = DbClient["db"];
export interface SseMessage {
event: string;
data: unknown;
id?: string;
}
export interface RunEventRow {
id: bigint;
runId: string;
phaseId: string | null;
seq: bigint;
type: string;
payload: unknown;
ts: Date;
}
export interface TranscriptChunkRow {
id: bigint;
runId: string;
sessionId: string;
roleId: string;
seq: bigint;
content: string;
capturedAt: Date;
}
export function formatSseMessage(message: SseMessage): string {
const lines: string[] = [];
if (message.id !== undefined) {
lines.push(`id: ${message.id}`);
}
lines.push(`event: ${message.event}`);
lines.push(`data: ${JSON.stringify(message.data)}`);
return `${lines.join("\n")}\n\n`;
}
export function formatSseComment(comment: string): string {
return `: ${comment}\n\n`;
}
export function openSseResponse(response: ServerResponse): void {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
"x-accel-buffering": "no",
});
response.write(formatSseComment("connected"));
}
export function runEventMessages(row: RunEventRow, options: { deriveStateEvents: boolean }) {
const base = runEventAppendedMessage(row);
if (!options.deriveStateEvents) {
return [base];
}
return [base, ...derivedRunEventMessages(row)];
}
export function runEventAppendedMessage(row: RunEventRow): SseMessage {
return {
id: row.seq.toString(),
event: "run.event_appended",
data: {
eventId: row.seq.toString(),
id: row.id.toString(),
payload: row.payload,
phaseId: row.phaseId,
runId: row.runId,
ts: row.ts.toISOString(),
type: row.type,
},
};
}
export function transcriptChunkMessage(row: TranscriptChunkRow): SseMessage {
return {
event: "transcript.chunk_appended",
data: {
content: row.content.slice(0, 4096),
runId: row.runId,
roleId: row.roleId,
seq: row.seq.toString(),
sessionId: row.sessionId,
ts: row.capturedAt.toISOString(),
},
};
}
export async function readRunEventRows(
db: Database,
runId: string,
afterSeq: bigint,
throughSeq?: bigint,
): Promise<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;
}

View File

@@ -1,14 +0,0 @@
import type { DbClient } from "@devflow/db";
import { type M4ProcessRestartSweepOptions, sweepM4ProcessRestart } from "@devflow/run-engine";
import type { SessionManager } from "@devflow/session";
export async function recoverM4ApiStartup(
db: DbClient["db"],
options: M4ProcessRestartSweepOptions = {},
) {
return sweepM4ProcessRestart(db, options);
}
export async function startM4SessionManager(sessionManager: SessionManager) {
return sessionManager.recoverSessions();
}

View File

@@ -1,16 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node", "vitest"]
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../../packages/core" },
{ "path": "../../packages/db" },
{ "path": "../../packages/run-engine" },
{ "path": "../../packages/session" },
{ "path": "../../packages/workflows" }
]
}

View File

@@ -1,20 +0,0 @@
{
"name": "@devflow/cli",
"version": "0.0.0",
"private": true,
"type": "module",
"bin": {
"devflow": "./dist/index.js"
},
"scripts": {
"build": "tsup src/index.ts --format esm --clean",
"typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit",
"test": "cd ../.. && vitest run --project apps/cli"
},
"dependencies": {
"commander": "12.1.0",
"dotenv": "17.4.2",
"pg": "8.20.0",
"zod": "3.24.1"
}
}

View File

@@ -1,140 +0,0 @@
import { describe, expect, it } from "vitest";
import {
type DoctorCommandRunner,
doctorExitCode,
formatDoctorJson,
formatDoctorTable,
runDoctor,
} from "./doctor.js";
const passingRunner: DoctorCommandRunner = async (command, args = []) => {
if (command === "docker" && args.join(" ") === "compose ps postgres") {
return {
exitCode: 0,
stdout: "postgres running\n",
stderr: "",
};
}
if (command === "docker" && args.includes("pg_isready")) {
return {
exitCode: 0,
stdout: "/var/run/postgresql:5432 - accepting connections\n",
stderr: "",
};
}
if (command === "df") {
return {
exitCode: 0,
stdout:
"Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/disk 20000000 1 19999999 1% /\n",
stderr: "",
};
}
const stdoutByCommand: Record<string, string> = {
pnpm: "9.15.9\n",
tmux: "tmux 3.4\n",
git: "git version 2.45.0\n",
docker: "29.3.0\n",
};
return {
exitCode: 0,
stdout: stdoutByCommand[command] ?? "",
stderr: "",
};
};
describe("doctor", () => {
it("emits the closed M1 check set in order", async () => {
const results = await runDoctor({
commandRunner: passingRunner,
connectDatabase: async () => undefined,
countAppliedMigrations: async () => 1,
countExpectedMigrations: () => 1,
dockerComposePath: "docker",
env: {
DATABASE_URL: "postgres://devflow:devflow@127.0.0.1:55432/devflow",
WORKSPACE_ROOT: process.cwd(),
LOG_LEVEL: "info",
TEMPORAL_ADDRESS: "localhost:7233",
},
nodeVersion: "22.11.0",
});
expect(results.map((result) => result.name)).toEqual([
"node",
"pnpm",
"tmux",
"git",
"docker",
"postgres",
"drizzle_migrations",
"workspace_root",
"config",
"codex",
"claude",
"workspace_disk",
]);
expect(doctorExitCode(results)).toBe(0);
});
it("returns exit code 1 when any hard check fails", () => {
expect(
doctorExitCode([
{
name: "node",
status: "fail",
detail: "Node 21 is unsupported",
remediation: "Install Node 22",
},
]),
).toBe(1);
});
it("surfaces command failures before parsing version output", async () => {
const results = await runDoctor({
commandRunner: async (command, args) => {
if (command === "tmux") {
return {
exitCode: 127,
stdout: "",
stderr: "command not found: tmux",
};
}
return passingRunner(command, args);
},
connectDatabase: async () => undefined,
countAppliedMigrations: async () => 1,
countExpectedMigrations: () => 1,
env: {
DATABASE_URL: "postgres://devflow:devflow@127.0.0.1:55432/devflow",
WORKSPACE_ROOT: process.cwd(),
LOG_LEVEL: "info",
TEMPORAL_ADDRESS: "localhost:7233",
},
nodeVersion: "22.11.0",
});
expect(results.find((result) => result.name === "tmux")).toMatchObject({
status: "fail",
detail: "command not found: tmux",
});
});
it("formats human and JSON output", () => {
const result = {
name: "node",
status: "pass" as const,
detail: "22.11.0",
remediation: "",
};
expect(formatDoctorTable([result])).toContain("node");
expect(JSON.parse(formatDoctorJson([result]))).toEqual([result]);
});
});

View File

@@ -1,462 +0,0 @@
import { execFile } from "node:child_process";
import { constants } from "node:fs";
import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { promisify } from "node:util";
import { Pool } from "pg";
import { type Config, loadConfigFromSources } from "../../../packages/core/src/config.js";
const execFileAsync = promisify(execFile);
export type DoctorStatus = "pass" | "fail" | "warn";
export interface DoctorResult {
name: string;
status: DoctorStatus;
detail: string;
remediation: string;
}
export interface CommandResult {
exitCode: number;
stdout: string;
stderr: string;
}
export type DoctorCommandRunner = (
command: string,
args?: string[],
options?: { cwd?: string; env?: NodeJS.ProcessEnv },
) => Promise<CommandResult>;
export interface DoctorOptions {
cwd?: string;
env?: Record<string, string | undefined>;
nodeVersion?: string;
commandRunner?: DoctorCommandRunner;
connectDatabase?: (databaseUrl: string) => Promise<void>;
countAppliedMigrations?: (databaseUrl: string) => Promise<number>;
countExpectedMigrations?: (cwd: string) => Promise<number> | number;
dockerComposePath?: string;
}
const MIN_DISK_KB = 5 * 1024 * 1024;
const WARN_DISK_KB = 10 * 1024 * 1024;
const FAIL_DISK_KB = 2 * 1024 * 1024;
export async function runDoctor(options: DoctorOptions = {}): Promise<DoctorResult[]> {
const cwd = options.cwd ?? process.cwd();
const env = options.env ?? process.env;
const commandRunner = options.commandRunner ?? defaultCommandRunner;
const dockerCommand = options.dockerComposePath ?? "docker";
const results: DoctorResult[] = [];
const configResult = loadDoctorConfig(cwd, env);
results.push(checkNodeVersion(options.nodeVersion ?? process.versions.node));
results.push(await checkCommandVersion("pnpm", ["--version"], "9.0.0", commandRunner));
results.push(await checkCommandVersion("tmux", ["-V"], "3.3.0", commandRunner));
results.push(await checkCommandVersion("git", ["--version"], "2.40.0", commandRunner));
results.push(await checkDocker(commandRunner));
results.push(
await checkPostgres({
commandRunner,
config: configResult.config,
configError: configResult.error,
connectDatabase: options.connectDatabase ?? defaultConnectDatabase,
dockerCommand,
cwd,
}),
);
results.push(
await checkMigrations({
config: configResult.config,
configError: configResult.error,
countAppliedMigrations: options.countAppliedMigrations ?? defaultCountAppliedMigrations,
countExpectedMigrations: options.countExpectedMigrations ?? defaultCountExpectedMigrations,
cwd,
}),
);
results.push(await checkWorkspaceRoot(configResult.config, configResult.error));
results.push(checkConfig(configResult.config, configResult.error));
results.push(await checkOptionalBinary("codex", commandRunner));
results.push(await checkOptionalBinary("claude", commandRunner));
results.push(await checkWorkspaceDisk(configResult.config, commandRunner));
return results;
}
function loadDoctorConfig(
cwd: string,
env: Record<string, string | undefined>,
): { config?: Config; error?: unknown } {
try {
return { config: loadConfigFromSources({ cwd, env }) };
} catch (error) {
return { error };
}
}
function checkNodeVersion(version: string): DoctorResult {
if (satisfiesMin(version, "22.0.0") && compareVersions(version, "23.0.0") < 0) {
return pass("node", version, "Node is within >=22.0.0 <23");
}
return fail("node", version, "Install Node 22 LTS and rerun doctor");
}
async function checkCommandVersion(
name: string,
args: string[],
minimum: string,
commandRunner: DoctorCommandRunner,
): Promise<DoctorResult> {
const result = await safeRun(commandRunner, name, args);
if (!result.ok) {
return fail(name, result.error, `Install ${name} >= ${minimum}`);
}
if (result.value.exitCode !== 0) {
return fail(
name,
(result.value.stderr || result.value.stdout).trim(),
`Install ${name} >= ${minimum}`,
);
}
const version = extractVersion(result.value.stdout);
if (!version) {
return fail(name, result.value.stdout.trim(), `Ensure ${name} reports a version`);
}
return satisfiesMin(version, minimum)
? pass(name, version, `${name} satisfies >= ${minimum}`)
: fail(name, version, `Upgrade ${name} to >= ${minimum}`);
}
async function checkDocker(commandRunner: DoctorCommandRunner): Promise<DoctorResult> {
const result = await safeRun(commandRunner, "docker", ["info", "--format", "{{.ServerVersion}}"]);
if (!result.ok) {
return fail("docker", result.error, "Start Docker and ensure docker is in PATH");
}
return result.value.exitCode === 0
? pass("docker", result.value.stdout.trim(), "Docker daemon is reachable")
: fail("docker", result.value.stderr.trim(), "Start Docker and rerun doctor");
}
async function checkPostgres(input: {
commandRunner: DoctorCommandRunner;
config: Config | undefined;
configError: unknown;
connectDatabase: (databaseUrl: string) => Promise<void>;
dockerCommand: string;
cwd: string;
}): Promise<DoctorResult> {
if (!input.config) {
return fail("postgres", errorDetail(input.configError), "Fix Config before checking Postgres");
}
const compose = await safeRun(
input.commandRunner,
input.dockerCommand,
["compose", "ps", "postgres"],
{
cwd: input.cwd,
},
);
if (!compose.ok || compose.value.exitCode !== 0 || !compose.value.stdout.includes("postgres")) {
return fail(
"postgres",
compose.ok ? compose.value.stderr : compose.error,
"Run docker compose up -d postgres",
);
}
const ready = await safeRun(input.commandRunner, input.dockerCommand, [
"compose",
"exec",
"-T",
"postgres",
"pg_isready",
"-U",
"devflow",
"-d",
"devflow",
]);
if (!ready.ok || ready.value.exitCode !== 0) {
return fail(
"postgres",
ready.ok ? ready.value.stderr : ready.error,
"Wait for Postgres healthcheck to pass",
);
}
try {
await input.connectDatabase(input.config.DATABASE_URL);
return pass("postgres", "connected", "Postgres is reachable and accepts DATABASE_URL");
} catch (error) {
return fail("postgres", errorDetail(error), "Check DATABASE_URL and container health");
}
}
async function checkMigrations(input: {
config: Config | undefined;
configError: unknown;
countAppliedMigrations: (databaseUrl: string) => Promise<number>;
countExpectedMigrations: (cwd: string) => Promise<number> | number;
cwd: string;
}): Promise<DoctorResult> {
if (!input.config) {
return fail(
"drizzle_migrations",
errorDetail(input.configError),
"Fix Config before checking migrations",
);
}
try {
const [applied, expected] = await Promise.all([
input.countAppliedMigrations(input.config.DATABASE_URL),
input.countExpectedMigrations(input.cwd),
]);
if (applied >= expected) {
return pass("drizzle_migrations", `${applied}/${expected}`, "No pending migrations");
}
return fail("drizzle_migrations", `${applied}/${expected}`, "Run pnpm db:migrate");
} catch (error) {
return fail(
"drizzle_migrations",
errorDetail(error),
"Run pnpm db:migrate after Postgres is healthy",
);
}
}
async function checkWorkspaceRoot(config?: Config, configError?: unknown): Promise<DoctorResult> {
if (!config) {
return fail(
"workspace_root",
errorDetail(configError),
"Set WORKSPACE_ROOT to an existing writable path",
);
}
try {
await access(config.WORKSPACE_ROOT, constants.W_OK);
return pass("workspace_root", config.WORKSPACE_ROOT, "Workspace root is writable");
} catch (error) {
return fail("workspace_root", errorDetail(error), "Create WORKSPACE_ROOT and make it writable");
}
}
function checkConfig(config?: Config, configError?: unknown): DoctorResult {
return config
? pass("config", "valid", ".env resolved to a valid Config")
: fail(
"config",
errorDetail(configError),
"Set DATABASE_URL, WORKSPACE_ROOT, LOG_LEVEL, and TEMPORAL_ADDRESS",
);
}
async function checkOptionalBinary(
name: "codex" | "claude",
commandRunner: DoctorCommandRunner,
): Promise<DoctorResult> {
const result = await safeRun(commandRunner, name, ["--version"]);
return result.ok && result.value.exitCode === 0
? pass(name, result.value.stdout.trim(), `${name} backend can be enabled`)
: warn(name, "not found", `${name} is only required for real backend opt-in`);
}
async function checkWorkspaceDisk(
config: Config | undefined,
commandRunner: DoctorCommandRunner,
): Promise<DoctorResult> {
if (!config) {
return warn("workspace_disk", "unknown", "Fix Config before checking free disk");
}
const result = await safeRun(commandRunner, "df", ["-Pk", config.WORKSPACE_ROOT]);
if (!result.ok || result.value.exitCode !== 0) {
return warn(
"workspace_disk",
result.ok ? result.value.stderr : result.error,
"Ensure df is available",
);
}
const availableKb = Number.parseInt(result.value.stdout.trim().split(/\s+/).at(-3) ?? "", 10);
if (Number.isNaN(availableKb)) {
return warn("workspace_disk", result.value.stdout.trim(), "Unable to parse df output");
}
if (availableKb < FAIL_DISK_KB) {
return fail(
"workspace_disk",
`${availableKb} KB free`,
"Free at least 5GB under WORKSPACE_ROOT",
);
}
if (availableKb < WARN_DISK_KB || availableKb < MIN_DISK_KB) {
return warn(
"workspace_disk",
`${availableKb} KB free`,
"Free space is below the recommended 10GB",
);
}
return pass(
"workspace_disk",
`${availableKb} KB free`,
"Workspace partition has enough free space",
);
}
export function doctorExitCode(results: DoctorResult[]): 0 | 1 {
return results.some((result) => result.status === "fail") ? 1 : 0;
}
export function formatDoctorJson(results: DoctorResult[]): string {
return `${JSON.stringify(results, null, 2)}\n`;
}
export function formatDoctorTable(results: DoctorResult[]): string {
const nameWidth = Math.max(...results.map((result) => result.name.length), "check".length);
const statusWidth = "status".length;
const lines = [
`${"check".padEnd(nameWidth)} ${"status".padEnd(statusWidth)} detail`,
`${"-".repeat(nameWidth)} ${"-".repeat(statusWidth)} ${"-".repeat(6)}`,
...results.map(
(result) =>
`${result.name.padEnd(nameWidth)} ${result.status.padEnd(statusWidth)} ${result.detail}${
result.remediation ? ` (${result.remediation})` : ""
}`,
),
];
return `${lines.join("\n")}\n`;
}
async function defaultCommandRunner(
command: string,
args: string[] = [],
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
): Promise<CommandResult> {
try {
const result = await execFileAsync(command, args, {
cwd: options.cwd,
env: options.env,
timeout: 15_000,
});
return {
exitCode: 0,
stdout: result.stdout,
stderr: result.stderr,
};
} catch (error) {
const maybeError = error as {
code?: string | number;
stdout?: string;
stderr?: string;
message?: string;
};
return {
exitCode: typeof maybeError.code === "number" ? maybeError.code : 1,
stdout: maybeError.stdout ?? "",
stderr: maybeError.stderr || maybeError.message || String(error),
};
}
}
async function defaultConnectDatabase(databaseUrl: string): Promise<void> {
const pool = new Pool({ connectionString: databaseUrl });
try {
await pool.query("select 1");
} finally {
await pool.end();
}
}
async function defaultCountAppliedMigrations(databaseUrl: string): Promise<number> {
const pool = new Pool({ connectionString: databaseUrl });
try {
const result = await pool.query<{ count: string }>(
"select count(*)::text as count from drizzle.__drizzle_migrations",
);
return Number.parseInt(result.rows[0]?.count ?? "0", 10);
} catch {
return 0;
} finally {
await pool.end();
}
}
async function defaultCountExpectedMigrations(cwd: string): Promise<number> {
const journalPath = resolve(cwd, "packages/db/src/migrations/meta/_journal.json");
const journal = JSON.parse(await readFile(journalPath, "utf8")) as { entries?: unknown[] };
return journal.entries?.length ?? 0;
}
async function safeRun(
commandRunner: DoctorCommandRunner,
command: string,
args: string[] = [],
options?: { cwd?: string; env?: NodeJS.ProcessEnv },
): Promise<{ ok: true; value: CommandResult } | { ok: false; error: string }> {
try {
return { ok: true, value: await commandRunner(command, args, options) };
} catch (error) {
return { ok: false, error: errorDetail(error) };
}
}
function extractVersion(output: string): string | undefined {
return output.match(/\d+\.\d+\.\d+/)?.[0] ?? output.match(/\d+\.\d+/)?.[0];
}
function satisfiesMin(version: string, minimum: string): boolean {
return compareVersions(version, minimum) >= 0;
}
function compareVersions(left: string, right: string): number {
const leftParts = left.split(".").map((part) => Number.parseInt(part, 10));
const rightParts = right.split(".").map((part) => Number.parseInt(part, 10));
for (let index = 0; index < 3; index += 1) {
const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
if (diff !== 0) {
return diff;
}
}
return 0;
}
function pass(name: string, detail: string, remediation: string): DoctorResult {
return { name, status: "pass", detail, remediation };
}
function fail(name: string, detail: string, remediation: string): DoctorResult {
return { name, status: "fail", detail, remediation };
}
function warn(name: string, detail: string, remediation: string): DoctorResult {
return { name, status: "warn", detail, remediation };
}
function errorDetail(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View File

@@ -1,37 +0,0 @@
#!/usr/bin/env node
import { Command } from "commander";
import { doctorExitCode, formatDoctorJson, formatDoctorTable, runDoctor } from "./doctor.js";
const program = new Command();
program.name("devflow").description("Local agentic engineering workflow runner");
program
.command("doctor")
.description("Check local Devflow prerequisites")
.option("--json", "print machine-readable JSON")
.option("--quiet", "print only non-passing checks")
.action(async (options: { json?: boolean; quiet?: boolean }) => {
try {
const results = await runDoctor();
const visibleResults = options.quiet
? results.filter((result) => result.status !== "pass")
: results;
if (options.json) {
process.stdout.write(formatDoctorJson(visibleResults));
} else if (!options.quiet || visibleResults.length > 0) {
process.stdout.write(formatDoctorTable(visibleResults));
}
process.exitCode = doctorExitCode(results);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 2;
}
});
await program.parseAsync(process.argv);

View File

@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node", "vitest"]
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../../packages/core" }]
}

View File

@@ -1,12 +0,0 @@
<!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>

View File

@@ -1,14 +0,0 @@
{
"name": "@devflow/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite --host 127.0.0.1",
"test": "cd ../.. && vitest run --project apps/web"
},
"devDependencies": {
"vite": "6.0.3"
}
}

View File

@@ -1,43 +0,0 @@
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}`;
}

View File

@@ -1,661 +0,0 @@
import "./styles.css";
import { createApiClient } from "./api.js";
import {
type ApprovalDecisionAction,
type DoctorCheck,
type RunSummary,
approvalDecisionActions,
compactPath,
formatDateTime,
highestDoctorStatus,
screenFromHash,
screens,
stateTone,
} from "./view-model.js";
interface TemplateSummary {
id: string;
name: string;
version: number;
hash: string;
definition: { phases?: unknown[]; roles?: unknown[] };
}
interface PersonaSummary {
id: string;
name: string;
version: number;
hash: string;
definition: { backend?: string; capabilities?: unknown[]; maxRiskLevel?: string };
}
interface RunStatus {
run: RunSummary & {
worktreeRoot: string;
finalReportPath: string | null;
};
phases: Array<{ id: string; phaseKey: string; state: string; attempts: number }>;
approvals: Array<{ id: string; gateKey: string; state: string; phaseId: string | null }>;
eventsTail: Array<{ seq: string; type: string; ts: string; payload: unknown }>;
}
interface SessionSummary {
id: string;
roleId: string;
backend: string;
cwd: string;
state: string;
expectedArtifactPath: string | null;
expectedSchema: string | null;
lastPromptAt: string | null;
recoveryAttempts: number;
tmuxSession: string | null;
tmuxWindow: string | null;
}
const api = createApiClient(import.meta.env.VITE_API_BASE);
const appRoot = document.querySelector<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;");
}

View File

@@ -1,317 +0,0 @@
:root {
color: #182026;
background: #f5f7f8;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 14px;
letter-spacing: 0;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
button,
input,
textarea {
font: inherit;
}
header {
align-items: center;
background: #ffffff;
border-bottom: 1px solid #d9e0e4;
display: flex;
justify-content: space-between;
min-height: 72px;
padding: 14px 24px;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1 {
font-size: 20px;
line-height: 1.2;
}
h2 {
font-size: 16px;
}
h3 {
font-size: 13px;
margin: 18px 0 8px;
text-transform: uppercase;
}
header p,
.section-title span,
.empty,
.detail-grid span {
color: #61717c;
}
nav {
display: flex;
gap: 4px;
}
nav a,
button {
border-radius: 6px;
text-decoration: none;
}
nav a {
color: #36464f;
padding: 8px 10px;
}
nav a.active {
background: #17324d;
color: #ffffff;
}
main {
margin: 0 auto;
max-width: 1280px;
padding: 20px 24px 48px;
}
.band {
background: #ffffff;
border: 1px solid #d9e0e4;
border-radius: 8px;
margin-top: 14px;
padding: 16px;
}
.split {
display: grid;
gap: 16px;
grid-template-columns: minmax(360px, 0.9fr) minmax(420px, 1.1fr);
}
.section-title,
.actions {
align-items: center;
display: flex;
gap: 10px;
justify-content: space-between;
margin-bottom: 12px;
}
.doctor {
align-items: center;
border: 1px solid #d9e0e4;
border-radius: 8px;
display: flex;
gap: 12px;
padding: 10px 12px;
}
.doctor.pass {
background: #eff8f2;
border-color: #b9dfc6;
}
.doctor.warn {
background: #fff7e8;
border-color: #efcf91;
}
.doctor.fail {
background: #fff0f0;
border-color: #e2a4a4;
}
table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
}
th,
td {
border-bottom: 1px solid #e5eaed;
overflow-wrap: anywhere;
padding: 9px 8px;
text-align: left;
vertical-align: middle;
}
th {
color: #5e6d77;
font-size: 12px;
font-weight: 600;
}
tr.selected {
background: #f0f5fa;
}
button {
background: #17324d;
border: 1px solid #17324d;
color: #ffffff;
cursor: pointer;
min-height: 34px;
padding: 7px 12px;
}
button.small {
min-height: 28px;
padding: 4px 8px;
}
button.danger {
background: #7b2d2d;
border-color: #7b2d2d;
}
button:disabled {
background: #c9d1d6;
border-color: #c9d1d6;
cursor: not-allowed;
}
.pill {
border-radius: 999px;
display: inline-block;
font-size: 12px;
font-weight: 600;
min-width: 76px;
padding: 3px 8px;
text-align: center;
}
.pill.active {
background: #e7f1fb;
color: #205a8c;
}
.pill.blocked {
background: #fff0cc;
color: #735100;
}
.pill.done {
background: #ddf2e3;
color: #236b38;
}
.pill.failed {
background: #f8dddd;
color: #8d2b2b;
}
.pill.neutral {
background: #e8edf0;
color: #4e5c65;
}
.detail-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.detail-grid div {
border-bottom: 1px solid #e5eaed;
min-height: 58px;
padding: 4px 0 10px;
}
.detail-grid span,
.detail-grid strong {
display: block;
}
.mono,
pre {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
pre {
background: #11191f;
border-radius: 6px;
color: #d9e8ef;
min-height: 160px;
overflow: auto;
padding: 12px;
white-space: pre-wrap;
}
.approval-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.error {
color: #8d2b2b;
font-weight: 600;
}
form {
display: grid;
gap: 14px;
}
label {
color: #4c5b64;
display: grid;
gap: 6px;
font-weight: 600;
}
input,
textarea {
border: 1px solid #cbd5da;
border-radius: 6px;
color: #17232b;
min-width: 0;
padding: 9px 10px;
}
textarea {
resize: vertical;
}
.form-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
#form-status {
color: #8d2b2b;
}
@media (max-width: 900px) {
header,
.split,
.detail-grid,
.form-grid {
grid-template-columns: 1fr;
}
header {
align-items: flex-start;
display: grid;
gap: 12px;
}
nav {
flex-wrap: wrap;
}
}

View File

@@ -1,46 +0,0 @@
import { describe, expect, it } from "vitest";
import {
approvalDecisionActions,
compactPath,
highestDoctorStatus,
normalizeApiBase,
screenFromHash,
stateTone,
} from "./view-model.js";
describe("web view model", () => {
it("normalizes navigation and API base values", () => {
expect(screenFromHash("#/templates")).toBe("templates");
expect(screenFromHash("#/run-detail")).toBe("run-detail");
expect(screenFromHash("#/approvals")).toBe("approvals");
expect(screenFromHash("#/sessions")).toBe("sessions");
expect(screenFromHash("#missing")).toBe("dashboard");
expect(normalizeApiBase("http://127.0.0.1:3000///")).toBe("http://127.0.0.1:3000");
expect(normalizeApiBase(undefined)).toBe("");
});
it("maps operational state tones", () => {
expect(stateTone("executing")).toBe("active");
expect(stateTone("awaiting_approval")).toBe("blocked");
expect(stateTone("completed")).toBe("done");
expect(stateTone("failed")).toBe("failed");
expect(stateTone("created")).toBe("neutral");
});
it("summarizes doctor severity and long paths", () => {
expect(
highestDoctorStatus([
{ name: "config", status: "pass", detail: "", remediation: "" },
{ name: "backend.codex", status: "warn", detail: "", remediation: "" },
]),
).toBe("warn");
expect(compactPath("/a/very/long/path/that/should/be/shortened/for/the/dashboard", 24)).toBe(
"/a/very/lon…e/dashboard",
);
});
it("exposes every approval decision action required by the API contract", () => {
expect([...approvalDecisionActions]).toEqual(["approve", "request_changes", "reject", "abort"]);
});
});

View File

@@ -1,108 +0,0 @@
export const screens = [
"dashboard",
"run-detail",
"approvals",
"sessions",
"templates",
"personas",
"new-run",
] as const;
export type Screen = (typeof screens)[number];
export const approvalDecisionActions = ["approve", "request_changes", "reject", "abort"] as const;
export type ApprovalDecisionAction = (typeof approvalDecisionActions)[number];
export interface RunSummary {
id: string;
state: string;
repoPath: string;
baseBranch: string;
currentPhaseId: string | null;
createdAt: string;
startedAt: string | null;
endedAt: string | null;
}
export interface DoctorCheck {
name: string;
status: "pass" | "warn" | "fail";
detail: string;
remediation: string;
}
export function screenFromHash(hash: string): Screen {
const candidate = hash.replace(/^#\/?/, "");
return isScreen(candidate) ? candidate : "dashboard";
}
export function normalizeApiBase(value: string | undefined): string {
if (value === undefined || value.trim().length === 0) {
return "";
}
return value.replace(/\/+$/, "");
}
export function stateTone(state: string): "active" | "blocked" | "done" | "failed" | "neutral" {
if (state === "completed") {
return "done";
}
if (state === "failed" || state === "aborted" || state === "FAILED_NEEDS_HUMAN") {
return "failed";
}
if (
state === "paused" ||
state === "awaiting_approval" ||
state === "WAITING_FOR_APPROVAL" ||
state === "ARTIFACT_TIMEOUT" ||
state === "HUNG" ||
state === "CRASHED"
) {
return "blocked";
}
if (
state === "executing" ||
state === "planning" ||
state === "bound" ||
state === "READY" ||
state === "BUSY" ||
state === "BOOTSTRAPPING" ||
state === "RESUMING" ||
state === "REBOOTSTRAPPED"
) {
return "active";
}
return "neutral";
}
export function compactPath(path: string, maxLength = 54): string {
if (path.length <= maxLength) {
return path;
}
const keep = Math.max(8, Math.floor((maxLength - 1) / 2));
return `${path.slice(0, keep)}${path.slice(-keep)}`;
}
export function formatDateTime(value: string | null): string {
if (value === null) {
return "";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}
export function highestDoctorStatus(checks: readonly DoctorCheck[]): DoctorCheck["status"] {
if (checks.some((check) => check.status === "fail")) {
return "fail";
}
if (checks.some((check) => check.status === "warn")) {
return "warn";
}
return "pass";
}
function isScreen(value: string): value is Screen {
return screens.includes(value as Screen);
}

View File

@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"emitDeclarationOnly": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"outDir": "dist",
"types": ["vite/client", "vitest"]
},
"include": ["src/**/*.ts", "vite.config.ts"]
}

View File

@@ -1,10 +0,0 @@
import { defineConfig } from "vite";
export default defineConfig({
server: {
proxy: {
"/api": "http://127.0.0.1:3000",
"/sse": "http://127.0.0.1:3000",
},
},
});

View File

@@ -1,19 +0,0 @@
{
"name": "@devflow/worker",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsup src/index.ts --format esm --clean --external @temporalio/worker --external @temporalio/client --external @temporalio/workflow",
"typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit",
"test": "cd ../.. && vitest run --project apps/worker"
},
"dependencies": {
"@devflow/core": "workspace:*",
"@devflow/db": "workspace:*",
"@devflow/session": "workspace:*",
"@devflow/workflows": "workspace:*",
"@temporalio/client": "^1.17.1",
"@temporalio/worker": "^1.17.1"
}
}

View File

@@ -1,331 +0,0 @@
import { randomUUID } from "node:crypto";
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DevflowError } from "@devflow/core";
import {
type DbClient,
createDbClient,
runEvents,
runs,
tuiSessions,
workflowTemplates,
} from "@devflow/db";
import { FakeSessionAdapter, type SessionAdapter, type SessionHandle } from "@devflow/session";
import { eq, inArray } from "drizzle-orm";
import { afterEach, describe, expect, it } from "vitest";
import { startWorker } from "./index.js";
const databaseUrl =
process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow";
class ResumeTrackingAdapter extends FakeSessionAdapter {
resumeAttempts = 0;
override async resume(handle: SessionHandle): Promise<SessionHandle> {
this.resumeAttempts += 1;
return super.resume(handle);
}
}
describe("startWorker", () => {
let client: DbClient | undefined;
const runIds: string[] = [];
const templateIds: string[] = [];
const tempRoots: string[] = [];
afterEach(async () => {
if (client !== undefined) {
if (runIds.length > 0) {
await client.db.delete(runs).where(inArray(runs.id, [...runIds]));
}
if (templateIds.length > 0) {
await client.db
.delete(workflowTemplates)
.where(inArray(workflowTemplates.id, [...templateIds]));
}
await client.close();
client = undefined;
}
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
runIds.length = 0;
templateIds.length = 0;
});
it("initializes SessionManager recovery before accepting Temporal work", async () => {
client = createDbClient(databaseUrl);
const templateId = randomUUID();
const runId = randomUUID();
const sessionId = randomUUID();
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-repo-")));
const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-worktree-")));
tempRoots.push(repoPath, worktreeRoot);
templateIds.push(templateId);
runIds.push(runId);
await client.db.insert(workflowTemplates).values({
id: templateId,
name: `worker-recovery-${templateId}`,
version: 1,
hash: "f".repeat(64),
definition: { name: "worker-recovery", version: 1, roles: [], phases: [] },
});
await client.db.insert(runs).values({
id: runId,
templateId,
templateHash: "f".repeat(64),
state: "executing",
repoPath,
baseBranch: "main",
worktreeRoot,
});
await client.db.insert(tuiSessions).values({
id: sessionId,
runId,
roleId: "spec_writer",
backend: "fake",
cwd: worktreeRoot,
state: "BOOTSTRAPPING",
});
const adapter = new ResumeTrackingAdapter({
sessionIdFactory: () => sessionId,
writeDelayMs: 0,
});
await adapter.start({
runId,
roleId: "spec_writer",
backend: "fake",
cwd: worktreeRoot,
});
const worker = await startWorkerWhenLockFree({
config: {
DATABASE_URL: databaseUrl,
LOG_LEVEL: "info",
TEMPORAL_ADDRESS: "localhost:7233",
WORKSPACE_ROOT: worktreeRoot,
MAX_CONCURRENT_RUNS: 4,
SESSION_MAX_HUNG_MS: 20 * 60 * 1000,
backends: [{ id: "fake", enabled: true }],
},
dbClient: client,
recoveryRunIds: [runId],
sessionAdapter: adapter,
connectionFactory: async () => fakeConnection(),
workerFactory: async () => fakeWorker(),
});
try {
expect(worker.recovery).toEqual({ failedSessionIds: [], recoveredSessionIds: [sessionId] });
expect(adapter.resumeAttempts).toBe(1);
const [session] = await client.db
.select({ state: tuiSessions.state })
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionId));
expect(session).toEqual({ state: "READY" });
const events = await client.db
.select({ type: runEvents.type })
.from(runEvents)
.where(eq(runEvents.runId, runId))
.orderBy(runEvents.seq);
expect(events.map((event) => event.type)).toEqual(["session.created", "session.ready"]);
} finally {
await worker.shutdown();
}
});
it("releases acquired resources when SessionManager startup fails", async () => {
client = createDbClient(databaseUrl);
const adapter: SessionAdapter = new FakeSessionAdapter();
const first = await startWorkerWhenLockFree({
config: {
DATABASE_URL: databaseUrl,
LOG_LEVEL: "info",
TEMPORAL_ADDRESS: "localhost:7233",
WORKSPACE_ROOT: realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-workspace-"))),
MAX_CONCURRENT_RUNS: 4,
SESSION_MAX_HUNG_MS: 20 * 60 * 1000,
backends: [{ id: "fake", enabled: true }],
},
dbClient: client,
recoveryRunIds: [],
sessionAdapter: adapter,
connectionFactory: async () => fakeConnection(),
workerFactory: async () => fakeWorker(),
});
try {
await expect(
startWorker({
config: {
DATABASE_URL: databaseUrl,
LOG_LEVEL: "info",
TEMPORAL_ADDRESS: "localhost:7233",
WORKSPACE_ROOT: realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-workspace-"))),
MAX_CONCURRENT_RUNS: 4,
SESSION_MAX_HUNG_MS: 20 * 60 * 1000,
backends: [{ id: "fake", enabled: true }],
},
dbClient: client,
recoveryRunIds: [],
connectionFactory: async () => fakeConnection(),
workerFactory: async () => fakeWorker(),
}),
).rejects.toMatchObject({ code: "session_manager_already_running" });
} finally {
await first.shutdown();
}
});
it("drains SessionManager resources when the Temporal worker run loop stops", async () => {
client = createDbClient(databaseUrl);
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-run-")));
tempRoots.push(workspaceRoot);
const connection = countingConnection();
const runtime = countingWorker();
const worker = await startWorkerWhenLockFree({
config: {
DATABASE_URL: databaseUrl,
LOG_LEVEL: "info",
TEMPORAL_ADDRESS: "localhost:7233",
WORKSPACE_ROOT: workspaceRoot,
MAX_CONCURRENT_RUNS: 4,
SESSION_MAX_HUNG_MS: 20 * 60 * 1000,
backends: [{ id: "fake", enabled: true }],
},
dbClient: client,
recoveryRunIds: [],
connectionFactory: async () => connection,
workerFactory: async () => runtime,
});
await worker.run();
expect(runtime.runs).toBe(1);
expect(runtime.shutdowns).toBe(1);
expect(connection.closes).toBe(1);
const next = await startWorkerWhenLockFree({
config: {
DATABASE_URL: databaseUrl,
LOG_LEVEL: "info",
TEMPORAL_ADDRESS: "localhost:7233",
WORKSPACE_ROOT: workspaceRoot,
MAX_CONCURRENT_RUNS: 4,
SESSION_MAX_HUNG_MS: 20 * 60 * 1000,
backends: [{ id: "fake", enabled: true }],
},
dbClient: client,
recoveryRunIds: [],
connectionFactory: async () => fakeConnection(),
workerFactory: async () => fakeWorker(),
});
await next.shutdown();
});
it("drains SessionManager resources when Temporal worker shutdown fails", async () => {
client = createDbClient(databaseUrl);
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-shutdown-")));
tempRoots.push(workspaceRoot);
const connection = countingConnection();
const worker = await startWorkerWhenLockFree({
config: {
DATABASE_URL: databaseUrl,
LOG_LEVEL: "info",
TEMPORAL_ADDRESS: "localhost:7233",
WORKSPACE_ROOT: workspaceRoot,
MAX_CONCURRENT_RUNS: 4,
SESSION_MAX_HUNG_MS: 20 * 60 * 1000,
backends: [{ id: "fake", enabled: true }],
},
dbClient: client,
recoveryRunIds: [],
connectionFactory: async () => connection,
workerFactory: async () => failingShutdownWorker(),
});
await expect(worker.shutdown()).rejects.toThrow("worker shutdown failed");
expect(connection.closes).toBe(1);
const next = await startWorkerWhenLockFree({
config: {
DATABASE_URL: databaseUrl,
LOG_LEVEL: "info",
TEMPORAL_ADDRESS: "localhost:7233",
WORKSPACE_ROOT: workspaceRoot,
MAX_CONCURRENT_RUNS: 4,
SESSION_MAX_HUNG_MS: 20 * 60 * 1000,
backends: [{ id: "fake", enabled: true }],
},
dbClient: client,
recoveryRunIds: [],
connectionFactory: async () => fakeConnection(),
workerFactory: async () => fakeWorker(),
});
await next.shutdown();
});
});
function fakeConnection() {
return {
close: async () => undefined,
};
}
function fakeWorker() {
return {
run: async () => undefined,
shutdown: () => undefined,
};
}
function countingConnection() {
return {
closes: 0,
async close() {
this.closes += 1;
},
};
}
function countingWorker() {
return {
runs: 0,
shutdowns: 0,
async run() {
this.runs += 1;
},
shutdown() {
this.shutdowns += 1;
},
};
}
function failingShutdownWorker() {
return {
run: async () => undefined,
shutdown() {
throw new Error("worker shutdown failed");
},
};
}
async function startWorkerWhenLockFree(options: Parameters<typeof startWorker>[0]) {
const deadline = Date.now() + 6_000;
let lastError: unknown;
while (Date.now() < deadline) {
try {
return await startWorker(options);
} catch (error) {
lastError = error;
if (!(error instanceof DevflowError) || error.code !== "session_manager_already_running") {
throw error;
}
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
}
}
throw lastError;
}

View File

@@ -1,140 +0,0 @@
import { fileURLToPath } from "node:url";
import { type Config, DevflowError, getConfig } from "@devflow/core";
import { type DbClient, createDbClient } from "@devflow/db";
import { FakeSessionAdapter, type SessionAdapter, SessionManager } from "@devflow/session";
import { NativeConnection, Worker } from "@temporalio/worker";
import { createDevflowActivities, temporalTaskQueue } from "@devflow/workflows";
interface WorkerConnection {
close(): Promise<void>;
}
interface WorkerRuntime {
run(): Promise<void>;
shutdown(): void | Promise<void>;
}
export interface StartWorkerOptions {
config?: Config;
dbClient?: DbClient;
sessionAdapter?: SessionAdapter;
recoveryRunIds?: readonly string[];
temporalAddress?: string;
taskQueue?: string;
connectionFactory?: (options: { address: string }) => Promise<WorkerConnection>;
workerFactory?: (options: Parameters<typeof Worker.create>[0]) => Promise<WorkerRuntime>;
}
export async function startWorker(options: StartWorkerOptions = {}) {
const config = options.config ?? getConfig();
const ownedClient = options.dbClient === undefined;
const dbClient = options.dbClient ?? createDbClient(config.DATABASE_URL);
const sessionManager = new SessionManager({
dbClient,
adapter: options.sessionAdapter ?? new FakeSessionAdapter(),
...(options.recoveryRunIds === undefined ? {} : { recoveryRunIds: options.recoveryRunIds }),
});
let connection: WorkerConnection | undefined;
let worker: WorkerRuntime | undefined;
try {
const recovery = await sessionManager.initialize();
connection = await (options.connectionFactory ?? NativeConnection.connect)({
address: options.temporalAddress ?? config.TEMPORAL_ADDRESS,
});
worker = await (options.workerFactory ?? Worker.create)({
activities: createDevflowActivities({
db: dbClient.db,
sessions: sessionManager,
workspaceRoot: config.WORKSPACE_ROOT,
availableBackends: config.backends,
maxConcurrentRuns: config.MAX_CONCURRENT_RUNS,
recovery: { maxHungMs: config.SESSION_MAX_HUNG_MS },
}),
connection: connection as NativeConnection,
namespace: "devflow",
taskQueue: options.taskQueue ?? temporalTaskQueue,
workflowsPath: fileURLToPath(
new URL("../../../packages/workflows/src/workflow.ts", import.meta.url),
),
});
const startedWorker = worker;
const startedConnection = connection;
if (startedWorker === undefined || startedConnection === undefined) {
throw new DevflowError("Temporal worker failed to initialize", {
class: "fatal",
code: "internal_state_corruption",
});
}
let shutdownPromise: Promise<void> | undefined;
const shutdown = () => {
shutdownPromise ??= (async () => {
let workerShutdownError: unknown;
try {
await Promise.resolve(startedWorker.shutdown());
} catch (error) {
workerShutdownError = error;
} finally {
try {
await sessionManager.shutdown();
} finally {
await startedConnection.close();
if (ownedClient) {
await dbClient.close();
}
}
}
if (workerShutdownError !== undefined) {
throw workerShutdownError;
}
})();
return shutdownPromise;
};
return {
recovery,
async run() {
try {
await startedWorker.run();
} finally {
await shutdown();
}
},
shutdown,
};
} catch (error) {
if (worker !== undefined) {
await Promise.resolve(worker.shutdown()).catch(() => undefined);
}
if (connection !== undefined) {
await connection.close().catch(() => undefined);
}
await sessionManager.shutdown().catch(() => undefined);
if (ownedClient) {
await dbClient.close().catch(() => undefined);
}
throw error;
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
startWorker()
.then(async (worker) => {
const requestShutdown = () => {
void worker.shutdown().catch((error: unknown) => {
console.error(error);
process.exitCode = 2;
});
};
process.once("SIGINT", requestShutdown);
process.once("SIGTERM", requestShutdown);
await worker.run();
})
.catch((error: unknown) => {
console.error(error);
process.exitCode =
error instanceof DevflowError && error.code === "session_manager_already_running" ? 3 : 2;
});
}

View File

@@ -1,15 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node", "vitest"]
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../../packages/core" },
{ "path": "../../packages/db" },
{ "path": "../../packages/session" },
{ "path": "../../packages/workflows" }
]
}

View File

@@ -1,42 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"ignore": ["node_modules", "dist", "coverage", "data"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "all"
}
},
"json": {
"formatter": {
"trailingCommas": "none"
}
}
}

View File

@@ -1,11 +0,0 @@
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./packages/db/src/schema/index.ts",
out: "./packages/db/src/migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow",
},
});

View File

@@ -1,12 +0,0 @@
pre-commit:
parallel: false
commands:
biome:
glob: "*.{ts,tsx,js,jsx,json,jsonc,md,yml,yaml}"
run: npx pnpm@9.15.9 biome check --write {staged_files}
stage_fixed: true
typecheck:
run: npx pnpm@9.15.9 typecheck
test:
glob: "*.{ts,tsx,js,jsx}"
run: npx pnpm@9.15.9 vitest related --run --passWithNoTests {staged_files}

View File

@@ -1,44 +0,0 @@
{
"name": "devflow",
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "pnpm@9.15.9",
"engines": {
"node": ">=22.0.0 <23",
"pnpm": ">=9.0.0 <10"
},
"scripts": {
"build": "tsc -b && pnpm -r --if-present build",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx scripts/migrate.ts",
"db:seed": "tsx scripts/seed.ts",
"devflow": "tsx apps/cli/src/index.ts",
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"lint": "biome check .",
"format": "biome check --write ."
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@types/node": "22.10.2",
"@types/pg": "8.20.0",
"@vitest/coverage-v8": "2.1.8",
"drizzle-kit": "0.31.10",
"lefthook": "2.1.6",
"tsup": "8.3.5",
"tsx": "4.19.2",
"typescript": "5.6.3",
"vite": "6.0.3",
"vitest": "2.1.8"
},
"dependencies": {
"commander": "12.1.0",
"dotenv": "17.4.2",
"drizzle-orm": "0.45.2",
"pg": "8.20.0",
"zod": "3.24.1"
}
}

View File

@@ -1 +0,0 @@

View File

@@ -1,20 +0,0 @@
{
"name": "@devflow/core",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --clean && tsc -p tsconfig.build.json",
"typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit",
"test": "cd ../.. && vitest run --project packages/core"
},
"dependencies": {
"ajv": "8.17.1",
"dotenv": "17.4.2",
"yaml": "2.6.1",
"zod": "3.24.1"
}
}

View File

@@ -1,366 +0,0 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import {
clearArtifactSchemaCacheForTests,
loadSchema,
validateArtifact,
} from "./artifact-schema.js";
import { DevflowError } from "./errors.js";
const artifactRoot = resolve(
dirname(fileURLToPath(import.meta.url)),
"../../../docs/schemas/artifacts",
);
const repoRoot = resolve(artifactRoot, "../../..");
const hash64 = "a".repeat(64);
const runId = "00000000-0000-4000-8000-000000000001";
const originalCwd = process.cwd();
describe("artifact schema registry", () => {
afterEach(() => {
process.chdir(originalCwd);
clearArtifactSchemaCacheForTests();
});
it("loads the first locked artifact schemas from docs/schemas/artifacts", () => {
clearArtifactSchemaCacheForTests();
expect(loadSchema("dev/spec@1", { root: artifactRoot })).toMatchObject({
$id: "dev/spec@1",
});
expect(loadSchema("dev/phase-plan@1", { root: artifactRoot })).toMatchObject({
$id: "dev/phase-plan@1",
});
expect(loadSchema("common/final-report@1", { root: artifactRoot })).toMatchObject({
$id: "common/final-report@1",
});
expect(Object.isFrozen(loadSchema("dev/spec@1", { root: artifactRoot }))).toBe(true);
});
it("finds the default schema root from package subdirectories", () => {
process.chdir(resolve(repoRoot, "packages/core"));
expect(loadSchema("dev/spec@1")).toMatchObject({ $id: "dev/spec@1" });
});
it("validates dev/spec@1 artifacts and returns compact validation errors", () => {
expect(
validateArtifact(
"dev/spec@1",
{
summary: "Add a binding algorithm",
requirements: [{ id: "REQ-1", description: "Bind every role" }],
acceptanceCriteria: ["All roles have bindings"],
risks: [],
},
{ root: artifactRoot },
),
).toEqual({ ok: true });
const result = validateArtifact(
"dev/spec@1",
{
summary: "Missing requirements",
requirements: [],
acceptanceCriteria: [],
risks: [],
},
{ root: artifactRoot },
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors.map((error) => error.keyword)).toContain("minItems");
expect(result.errors[0]).toMatchObject({
instancePath: expect.any(String),
schemaPath: expect.any(String),
params: expect.any(Object),
});
}
});
it("validates dev/phase-plan@1 artifacts", () => {
expect(
validateArtifact(
"dev/phase-plan@1",
{
phases: [
{
key: "implement",
title: "Implement",
objective: "Implement the requested behavior",
roles: ["implementer"],
expectedArtifact: {
path: "artifacts/spec.json",
schema: "dev/spec@1",
},
tasks: [
{
id: "task-1",
title: "Edit code",
role: "implementer",
writeSet: ["packages/core/src/**", "**/*.ts"],
},
],
},
],
},
{ root: artifactRoot },
),
).toEqual({ ok: true });
const invalidSchemaId = validateArtifact(
"dev/phase-plan@1",
{
phases: [
{
key: "implement",
title: "Implement",
objective: "Implement the requested behavior",
roles: ["implementer"],
expectedArtifact: {
path: "artifacts/spec.json",
schema: "../secret@1",
},
},
],
},
{ root: artifactRoot },
);
expect(invalidSchemaId.ok).toBe(false);
if (!invalidSchemaId.ok) {
expect(invalidSchemaId.errors.map((error) => error.keyword)).toContain("pattern");
}
for (const path of [
"../../secrets.json",
"/etc/passwd",
"artifacts/../outside.json",
"C:/outside.json",
"ok\n/../../outside.json",
"ok\r/../../outside.json",
]) {
const invalidPath = validateArtifact(
"dev/phase-plan@1",
{
phases: [
{
key: "implement",
title: "Implement",
objective: "Implement the requested behavior",
roles: ["implementer"],
expectedArtifact: {
path,
schema: "dev/spec@1",
},
},
],
},
{ root: artifactRoot },
);
expect(invalidPath.ok).toBe(false);
if (!invalidPath.ok) {
expect(invalidPath.errors.map((error) => error.keyword)).toContain("pattern");
}
}
const missingWriteSet = validateArtifact(
"dev/phase-plan@1",
{
phases: [
{
key: "implement",
title: "Implement",
objective: "Implement the requested behavior",
roles: ["implementer"],
tasks: [
{
id: "task-1",
title: "Edit code",
role: "implementer",
},
],
},
],
},
{ root: artifactRoot },
);
expect(missingWriteSet.ok).toBe(false);
if (!missingWriteSet.ok) {
expect(missingWriteSet.errors.map((error) => error.keyword)).toContain("required");
}
for (const writeSet of [
"../../**",
"/etc/**",
"src/../secrets/**",
"C:/outside/**",
"src\n/../../outside",
"src\r/../../outside",
"..\\outside\\**",
"!/etc/**",
"!../*",
"{../*,packages/core/src/**}",
"{..,packages}/**",
"@(../*)",
"!(../*)",
"src/[ab]/**",
]) {
const invalidWriteSet = validateArtifact(
"dev/phase-plan@1",
{
phases: [
{
key: "implement",
title: "Implement",
objective: "Implement the requested behavior",
roles: ["implementer"],
tasks: [
{
id: "task-1",
title: "Edit code",
role: "implementer",
writeSet: [writeSet],
},
],
},
],
},
{ root: artifactRoot },
);
expect(invalidWriteSet.ok).toBe(false);
if (!invalidWriteSet.ok) {
expect(invalidWriteSet.errors.map((error) => error.keyword)).toContain("pattern");
}
}
});
it("validates common/final-report@1 minimum fields", () => {
expect(
validateArtifact(
"common/final-report@1",
{
runId,
templateHash: hash64,
bindings: [{ roleId: "implementer", personaHash: hash64, backend: "fake" }],
inputs: {},
phases: [],
approvals: [],
findings: [],
commands: [{ kind: "test", argv: ["pnpm", "test"], exit_code: 0 }],
artifacts: [],
events: { tail: [] },
unresolved: [],
endedAt: "2026-05-09T00:00:00.000Z",
status: "completed",
},
{ root: artifactRoot },
),
).toEqual({ ok: true });
const result = validateArtifact(
"common/final-report@1",
{
runId: "not-a-uuid",
templateHash: hash64,
bindings: [{ roleId: "implementer", personaHash: hash64, backend: "fake" }],
inputs: {},
phases: [],
approvals: [],
findings: [],
commands: [],
artifacts: [],
events: { tail: [] },
unresolved: [],
endedAt: "2026-99-99T99:99:99Z",
status: "executing",
},
{ root: artifactRoot },
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors.map((error) => error.keyword)).toEqual(
expect.arrayContaining(["format", "enum"]),
);
}
});
it("fails fatally for unknown or malformed schema ids", () => {
expect(() => loadSchema("dev/unknown@1", { root: artifactRoot })).toThrow(DevflowError);
expect(() => loadSchema("../secret@1", { root: artifactRoot })).toThrow(
/artifact_schema_unknown/,
);
});
it("fails fatally when schema files are malformed", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-artifact-schemas-"));
const devDir = join(root, "dev");
mkdirSync(devDir, { recursive: true });
writeFileSync(
join(devDir, "bad@1.json"),
JSON.stringify({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "dev/wrong@1",
type: "object",
}),
);
expect(() => loadSchema("dev/bad@1", { root })).toThrow(/artifact_schema_load_failed/);
});
it("wraps registry root, JSON parse, and path layout load failures", () => {
expect(() =>
loadSchema("dev/spec@1", { root: join(tmpdir(), "missing-artifact-root") }),
).toThrow(/artifact_schema_load_failed/);
const badJsonRoot = mkdtempSync(join(tmpdir(), "devflow-artifact-schemas-"));
mkdirSync(join(badJsonRoot, "dev"), { recursive: true });
writeFileSync(join(badJsonRoot, "dev", "bad@1.json"), "{");
expect(() => loadSchema("dev/bad@1", { root: badJsonRoot })).toThrow(
/artifact_schema_load_failed/,
);
const badPathRoot = mkdtempSync(join(tmpdir(), "devflow-artifact-schemas-"));
mkdirSync(join(badPathRoot, "bad"), { recursive: true });
writeFileSync(
join(badPathRoot, "bad", "schema.json"),
JSON.stringify({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "bad/schema",
type: "object",
}),
);
expect(() => loadSchema("dev/missing@1", { root: badPathRoot })).toThrow(
/artifact_schema_load_failed/,
);
});
it("does not load schemas from a target-controlled cwd shadow root", () => {
const shadowRoot = mkdtempSync(join(tmpdir(), "devflow-shadow-schemas-"));
const shadowSchemaDir = join(shadowRoot, "docs", "schemas", "artifacts", "dev");
mkdirSync(shadowSchemaDir, { recursive: true });
writeFileSync(join(shadowRoot, "package.json"), JSON.stringify({ name: "devflow" }));
writeFileSync(
join(shadowSchemaDir, "spec@1.json"),
JSON.stringify({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "dev/spec@1",
title: "SHADOW",
type: "object",
}),
);
process.chdir(shadowRoot);
expect(loadSchema("dev/spec@1")).toMatchObject({ title: "Devflow Development Specification" });
});
});

View File

@@ -1,388 +0,0 @@
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import {
Ajv2020,
type ErrorObject,
type FormatDefinition,
type ValidateFunction,
} from "ajv/dist/2020.js";
import { DevflowError } from "./errors.js";
import type { JsonObject, JsonValue } from "./persona.js";
export type JsonSchema = JsonObject;
export interface ValidationError {
instancePath: string;
schemaPath: string;
keyword: string;
message?: string;
params: JsonObject;
}
export interface ArtifactSchemaOptions {
root?: string;
}
interface CompiledArtifactSchema {
id: string;
schema: JsonSchema;
validate: ValidateFunction;
path: string;
}
const schemaIdPattern = /^[a-z][a-z0-9_-]*\/[a-z][a-z0-9_-]*@[1-9]\d*$/;
const schemaRootSegments = ["docs", "schemas", "artifacts"] as const;
const registries = new Map<string, Map<string, CompiledArtifactSchema>>();
export function loadSchema(id: string, options: ArtifactSchemaOptions = {}): JsonSchema {
assertSchemaId(id);
const schema = registryFor(options).get(id);
if (!schema) {
throw artifactSchemaUnknown(id);
}
return schema.schema;
}
export function validateArtifact(
id: string,
data: unknown,
options: ArtifactSchemaOptions = {},
): { ok: true } | { ok: false; errors: ValidationError[] } {
assertSchemaId(id);
const schema = registryFor(options).get(id);
if (!schema) {
throw artifactSchemaUnknown(id);
}
if (schema.validate(data)) {
return { ok: true };
}
return {
ok: false,
errors: (schema.validate.errors ?? []).map(toValidationError),
};
}
export function clearArtifactSchemaCacheForTests(): void {
registries.clear();
}
function registryFor(options: ArtifactSchemaOptions): Map<string, CompiledArtifactSchema> {
const root = resolveRegistryRoot(options.root ?? findDefaultSchemaRoot());
const cached = registries.get(root);
if (cached) {
return cached;
}
const registry = loadRegistry(root);
registries.set(root, registry);
return registry;
}
function resolveRegistryRoot(root: string): string {
try {
return realpathSync(resolve(root));
} catch (error) {
throw artifactSchemaLoadFailed(root, error);
}
}
function findDefaultSchemaRoot(): string {
const moduleDirectory = currentModuleDirectory();
if (moduleDirectory === undefined) {
throw artifactSchemaLoadFailed(
"default",
new Error("Could not resolve current module directory for artifact schemas"),
);
}
const packageRoot = findCorePackageRoot(moduleDirectory);
if (packageRoot === undefined) {
throw artifactSchemaLoadFailed(
"default",
new Error("Could not find @devflow/core package root for artifact schemas"),
);
}
return resolve(packageRoot, "../..", ...schemaRootSegments);
}
function currentModuleDirectory(): string | undefined {
const stack = new Error().stack?.split("\n").slice(1) ?? [];
for (const line of stack) {
const match = line.match(/\(?((?:file:\/\/)?\/[^):]+\.(?:cjs|mjs|js|ts)):\d+:\d+\)?/);
if (!match?.[1]) {
continue;
}
const path = match[1].startsWith("file://") ? fileURLToPath(match[1]) : match[1];
return dirname(path);
}
return undefined;
}
function findCorePackageRoot(startDirectory: string): string | undefined {
let current = resolve(startDirectory);
while (true) {
if (isCorePackageRoot(current)) {
return current;
}
const parent = dirname(current);
if (parent === current) {
return undefined;
}
current = parent;
}
}
function isCorePackageRoot(directory: string): boolean {
const packageJsonPath = join(directory, "package.json");
if (!existsSync(packageJsonPath)) {
return false;
}
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown };
return packageJson.name === "@devflow/core";
} catch {
return false;
}
}
function addArtifactFormats(ajv: Ajv2020): void {
ajv.addFormat("uuid", uuidFormat);
ajv.addFormat("utc-date-time", utcDateTimeFormat);
}
const uuidFormat: FormatDefinition<string> = {
type: "string",
validate: (value: string) =>
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(value),
};
const utcDateTimeFormat: FormatDefinition<string> = {
type: "string",
validate: isUtcDateTime,
};
function isUtcDateTime(value: string): boolean {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?Z$/);
if (!match) {
return false;
}
const [, yearText, monthText, dayText, hourText, minuteText, secondText, millisecondText] = match;
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
const hour = Number(hourText);
const minute = Number(minuteText);
const second = Number(secondText);
const millisecond = millisecondText === undefined ? 0 : Number(millisecondText);
const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millisecond));
return (
date.getUTCFullYear() === year &&
date.getUTCMonth() === month - 1 &&
date.getUTCDate() === day &&
date.getUTCHours() === hour &&
date.getUTCMinutes() === minute &&
date.getUTCSeconds() === second &&
date.getUTCMilliseconds() === millisecond
);
}
function loadRegistry(root: string): Map<string, CompiledArtifactSchema> {
const ajv = new Ajv2020({ allErrors: true, strict: true });
addArtifactFormats(ajv);
const schemas = new Map<string, JsonSchemaFile>();
for (const file of readSchemaFiles(root, root)) {
if (schemas.has(file.id)) {
throw artifactSchemaLoadFailed(file.id, new Error(`Duplicate artifact schema id ${file.id}`));
}
schemas.set(file.id, file);
}
const registry = new Map<string, CompiledArtifactSchema>();
for (const file of schemas.values()) {
try {
registry.set(file.id, {
id: file.id,
schema: deepFreeze(file.schema),
validate: ajv.compile(file.schema),
path: file.path,
});
} catch (error) {
throw artifactSchemaLoadFailed(file.id, error);
}
}
return registry;
}
interface JsonSchemaFile {
id: string;
schema: JsonSchema;
path: string;
}
function readSchemaFiles(root: string, directory: string): JsonSchemaFile[] {
const files: JsonSchemaFile[] = [];
let entryNames: string[];
try {
entryNames = readdirSync(directory).sort();
} catch (error) {
throw artifactSchemaLoadFailed(directory, error);
}
for (const entryName of entryNames) {
const path = join(directory, entryName);
let stat: ReturnType<typeof lstatSync>;
try {
stat = lstatSync(path);
} catch (error) {
throw artifactSchemaLoadFailed(path, error);
}
if (stat.isSymbolicLink()) {
throw artifactSchemaLoadFailed(
entryName,
new Error("Artifact schema path must not be a symlink"),
);
}
if (stat.isDirectory()) {
files.push(...readSchemaFiles(root, path));
continue;
}
if (!entryName.endsWith(".json")) {
continue;
}
const canonicalPath = resolveSchemaFilePath(path);
const id = schemaIdFromPath(root, canonicalPath);
const parsed = parseSchemaFile(id, canonicalPath);
if (!isJsonObject(parsed)) {
throw artifactSchemaLoadFailed(id, new Error("Artifact schema must be a JSON object"));
}
if (parsed.$id !== id) {
throw artifactSchemaLoadFailed(id, new Error(`Artifact schema $id must equal ${id}`));
}
files.push({ id, schema: parsed, path: canonicalPath });
}
return files;
}
function schemaIdFromPath(root: string, path: string) {
const relativePath = relative(root, path).split(sep).join("/");
const id = relativePath.replace(/\.json$/, "");
if (!schemaIdPattern.test(id)) {
throw artifactSchemaLoadFailed(id, new Error(`Invalid artifact schema path ${relativePath}`));
}
return id;
}
function assertSchemaId(id: string) {
if (!schemaIdPattern.test(id)) {
throw artifactSchemaUnknown(id);
}
}
function toValidationError(error: ErrorObject): ValidationError {
return {
instancePath: error.instancePath,
schemaPath: error.schemaPath,
keyword: error.keyword,
...(error.message === undefined ? {} : { message: error.message }),
params: toJsonObject(error.params),
};
}
function toJsonObject(value: Record<string, unknown>): JsonObject {
return Object.fromEntries(
Object.entries(value).map(([key, childValue]) => [key, toJsonValue(childValue)]),
);
}
function toJsonValue(value: unknown): JsonValue {
if (value === null || typeof value === "string" || typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : String(value);
}
if (Array.isArray(value)) {
return value.map(toJsonValue);
}
if (isJsonObject(value)) {
return toJsonObject(value);
}
return String(value);
}
function isJsonObject(value: unknown): value is JsonObject {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function resolveSchemaFilePath(path: string): string {
try {
return realpathSync(path);
} catch (error) {
throw artifactSchemaLoadFailed(path, error);
}
}
function parseSchemaFile(id: string, path: string): unknown {
try {
return JSON.parse(readFileSync(path, "utf8")) as unknown;
} catch (error) {
throw artifactSchemaLoadFailed(id, error);
}
}
function deepFreeze<T extends JsonValue>(value: T): T {
if (value !== null && typeof value === "object") {
Object.freeze(value);
for (const child of Object.values(value)) {
deepFreeze(child);
}
}
return value;
}
function artifactSchemaUnknown(id: string) {
return new DevflowError(`artifact_schema_unknown:${id}`, {
class: "fatal",
code: "artifact_schema_unknown",
recoveryHint: `Add docs/schemas/artifacts/${id}.json or update the template schema id.`,
});
}
function artifactSchemaLoadFailed(id: string, cause: unknown) {
return new DevflowError(`artifact_schema_load_failed:${id}`, {
class: "fatal",
code: "artifact_schema_load_failed",
cause,
recoveryHint: "Fix the artifact schema JSON document.",
});
}

View File

@@ -1,429 +0,0 @@
import { describe, expect, it } from "vitest";
import type { z } from "zod";
import { type BindingOverride, bindTemplatePersonas } from "./binding.js";
import type { BackendConfig } from "./config.js";
import { DevflowError } from "./errors.js";
import { hash } from "./hash.js";
import { Persona } from "./persona.js";
import { personaHash } from "./persona.js";
import { Template } from "./template.js";
const enabledBackends: BackendConfig[] = [
{ id: "fake", enabled: true },
{ id: "claude", enabled: true, binaryPath: process.execPath },
{ id: "codex", enabled: true, binaryPath: process.execPath },
];
describe("binding algorithm", () => {
it("auto-selects deterministically by preferred backend, version, name, then hash", () => {
const result = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "implementer",
requiredCapabilities: ["code_edit"],
preferredBackends: ["claude", "fake"],
},
],
}),
personas: [
persona({ name: "fake_v9", version: 9, backend: "fake", capabilities: ["code_edit"] }),
persona({ name: "claude_v1", version: 1, backend: "claude", capabilities: ["code_edit"] }),
persona({
name: "claude_v2_b",
version: 2,
backend: "claude",
capabilities: ["code_edit"],
}),
persona({
name: "claude_v2_a",
version: 2,
backend: "claude",
capabilities: ["code_edit"],
}),
],
templateHash: "template-hash",
availableBackends: enabledBackends,
});
expect(result.bindings).toHaveLength(1);
expect(result.bindings[0]?.roleId).toBe("implementer");
expect(result.bindings[0]?.persona.name).toBe("claude_v2_a");
});
it("falls back to non-preferred personas only when preferred personas fail eligibility", () => {
const result = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "implementer",
requiredCapabilities: ["code_edit"],
preferredBackends: ["claude"],
},
],
}),
personas: [
persona({ name: "claude_reader", backend: "claude", capabilities: ["code_review"] }),
persona({ name: "fake_implementer", backend: "fake", capabilities: ["code_edit"] }),
],
templateHash: "template-hash",
availableBackends: enabledBackends,
});
expect(result.bindings[0]?.persona.name).toBe("fake_implementer");
});
it("does not fall back when preferred personas only fail allowed role checks", () => {
expect(() =>
bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "implementer",
requiredCapabilities: ["code_edit"],
preferredBackends: ["claude"],
},
],
}),
personas: [
persona({
name: "claude_restricted",
backend: "claude",
capabilities: ["code_edit"],
allowedRoles: ["reviewer"],
}),
persona({ name: "fake_implementer", backend: "fake", capabilities: ["code_edit"] }),
],
templateHash: "template-hash",
availableBackends: enabledBackends,
}),
).toThrow(/no_eligible_persona/);
});
it("fails instead of falling back when the selected preferred backend is unavailable", () => {
expect(() =>
bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "implementer",
requiredCapabilities: ["code_edit"],
preferredBackends: ["codex"],
},
],
}),
personas: [
persona({ name: "codex_implementer", backend: "codex", capabilities: ["code_edit"] }),
persona({ name: "fake_implementer", backend: "fake", capabilities: ["code_edit"] }),
],
templateHash: "template-hash",
availableBackends: [{ id: "fake", enabled: true }],
}),
).toThrow(/backend_unavailable/);
});
it("classifies binding failures as human-required DevflowError instances", () => {
try {
bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "implementer",
requiredCapabilities: ["code_edit"],
preferredBackends: ["codex"],
},
],
}),
personas: [persona({ name: "codex_implementer", backend: "codex" })],
templateHash: "template-hash",
availableBackends: [{ id: "codex", enabled: true }],
});
} catch (error) {
expect(error).toBeInstanceOf(DevflowError);
expect((error as DevflowError).class).toBe("human_required");
expect((error as DevflowError).code).toBe("backend_unavailable");
return;
}
throw new Error("expected binding to fail");
});
it("treats absolute backend paths as process-start resolved registry entries", () => {
const result = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "implementer",
requiredCapabilities: ["code_edit"],
preferredBackends: ["codex"],
},
],
}),
personas: [persona({ name: "codex_implementer", backend: "codex" })],
templateHash: "template-hash",
availableBackends: [{ id: "codex", enabled: true, binaryPath: "/process/start/codex" }],
});
expect(result.bindings[0]?.backend).toBe("codex");
});
it("supports persona overrides and backend-constraining overrides", () => {
const result = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [{ id: "reviewer", requiredCapabilities: ["code_review"] }],
}),
personas: [
persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }),
persona({ name: "codex_reviewer", backend: "codex", capabilities: ["code_review"] }),
],
overrides: { roles: { reviewer: { backend: "codex" } } },
templateHash: "template-hash",
availableBackends: enabledBackends,
});
expect(result.bindings[0]?.persona.name).toBe("codex_reviewer");
const swappedPersona = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [{ id: "reviewer", requiredCapabilities: ["code_review"] }],
}),
personas: [
persona({ name: "alpha_reviewer", backend: "fake", capabilities: ["code_review"] }),
persona({ name: "beta_reviewer", backend: "fake", capabilities: ["code_review"] }),
],
overrides: { roles: { reviewer: { persona: "beta_reviewer" } } },
templateHash: "template-hash",
availableBackends: enabledBackends,
});
expect(swappedPersona.bindings[0]?.persona.name).toBe("beta_reviewer");
});
it("rejects typo override keys and normalizes explicit undefined override fields", () => {
const base = {
runId: "run-1",
template: template(),
personas: [persona({ name: "fake_implementer", backend: "fake" })],
templateHash: "template-hash",
availableBackends: enabledBackends,
};
expect(() =>
bindTemplatePersonas({
...base,
overrides: {
roles: {
implementer: { backned: "codex" } as unknown as BindingOverride,
},
},
}),
).toThrow(/Unrecognized key/);
expect(
bindTemplatePersonas({
...base,
overrides: {
roles: {
implementer: {
persona: undefined,
backend: undefined,
} as unknown as BindingOverride,
},
},
}).bindings[0]?.persona.name,
).toBe("fake_implementer");
expect(() =>
bindTemplatePersonas({
...base,
overrides: {
roles: {
implmenter: { backend: "fake" },
},
},
}),
).toThrow(/unknown override role/);
});
it("computes binding hashes from the locked hash subject", () => {
const override = { backend: "fake" as const };
const result = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "reviewer",
requiredCapabilities: ["code_review"],
count: 2,
},
],
}),
personas: [
persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }),
],
overrides: { roles: { "reviewer#1": override } },
templateHash: "template-hash",
availableBackends: enabledBackends,
});
const binding = result.bindings.find((candidate) => candidate.roleId === "reviewer#1");
const selectedPersonaHash = personaHash(binding?.persona);
expect(binding?.bindingHash).toBe(
hash({
runId: "run-1",
roleId: "reviewer#1",
templateHash: "template-hash",
personaHash: selectedPersonaHash,
backend: "fake",
override,
}),
);
});
it("expands counted roles and enforces backend diversity after overrides", () => {
const result = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "reviewer",
requiredCapabilities: ["code_review"],
count: 2,
diversity: { requireDifferentBackends: true },
},
],
}),
personas: [
persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }),
persona({ name: "codex_reviewer", backend: "codex", capabilities: ["code_review"] }),
],
templateHash: "template-hash",
availableBackends: enabledBackends,
});
expect(result.bindings.map((binding) => binding.roleId)).toEqual(["reviewer#0", "reviewer#1"]);
expect(new Set(result.bindings.map((binding) => binding.backend)).size).toBe(2);
const constrainedSecondInstance = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "reviewer",
requiredCapabilities: ["code_review"],
count: 2,
diversity: { requireDifferentBackends: true },
},
],
}),
personas: [
persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }),
persona({ name: "codex_reviewer", backend: "codex", capabilities: ["code_review"] }),
],
overrides: { roles: { "reviewer#1": { backend: "fake" } } },
templateHash: "template-hash",
availableBackends: enabledBackends,
});
expect(constrainedSecondInstance.bindings.map((binding) => binding.backend)).toEqual([
"codex",
"fake",
]);
});
it("rejects unavailable backends, missing capabilities, role restrictions, and risk overflow", () => {
const base = {
runId: "run-1",
templateHash: "template-hash",
availableBackends: [{ id: "fake", enabled: true }] satisfies BackendConfig[],
};
expect(() =>
bindTemplatePersonas({
...base,
template: template({ roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }] }),
personas: [persona({ name: "disabled", backend: "codex", capabilities: ["code_edit"] })],
}),
).toThrow(/backend_unavailable/);
expect(() =>
bindTemplatePersonas({
...base,
template: template({ roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }] }),
personas: [persona({ name: "reviewer", backend: "fake", capabilities: ["code_review"] })],
}),
).toThrow(/no_eligible_persona/);
expect(() =>
bindTemplatePersonas({
...base,
template: template({ roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }] }),
personas: [
persona({
name: "restricted",
backend: "fake",
capabilities: ["code_edit"],
allowedRoles: ["reviewer"],
}),
],
}),
).toThrow(/no_eligible_persona/);
expect(() =>
bindTemplatePersonas({
...base,
template: template({
roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }],
phases: [{ key: "danger", title: "Danger", risk: "high", roles: ["implementer"] }],
}),
personas: [
persona({
name: "medium_only",
backend: "fake",
capabilities: ["code_edit"],
maxRiskLevel: "medium",
}),
],
}),
).toThrow(/no_eligible_persona/);
});
});
function template(input: Partial<z.input<typeof Template>> = {}): Template {
const roles = input.roles ?? [{ id: "implementer", requiredCapabilities: ["code_edit"] }];
const phases = input.phases ?? [
{ key: "spec", title: "Spec", risk: "low", roles: [roles[0]?.id ?? "implementer"] },
];
return Template.parse({
name: input.name ?? "development",
version: input.version ?? 1,
roles,
phases,
defaultGates: input.defaultGates ?? [],
});
}
function persona(
input: Partial<z.input<typeof Persona>> & Pick<z.input<typeof Persona>, "name">,
): Persona {
return Persona.parse({
name: input.name,
version: input.version ?? 1,
backend: input.backend ?? "fake",
capabilities: input.capabilities ?? ["code_edit"],
maxRiskLevel: input.maxRiskLevel ?? "high",
promptConfig: input.promptConfig ?? {},
modelConfig: input.modelConfig ?? {},
...(input.allowedRoles === undefined ? {} : { allowedRoles: input.allowedRoles }),
});
}

View File

@@ -1,384 +0,0 @@
import { isAbsolute } from "node:path";
import { z } from "zod";
import type { BackendConfig } from "./config.js";
import { Backend } from "./enums.js";
import { DevflowError } from "./errors.js";
import { hash } from "./hash.js";
import { type Persona, personaHash } from "./persona.js";
import type { Template, TemplateRole } from "./template.js";
const riskRank = { low: 0, medium: 1, high: 2 } as const;
export const BindingOverride = z
.object({
persona: z.string().optional(),
backend: Backend.optional(),
})
.strict();
export const BindingOverrides = z
.object({
roles: z.record(BindingOverride).default({}),
})
.strict();
export type BindingOverride = z.infer<typeof BindingOverride>;
export type BindingOverrides = z.infer<typeof BindingOverrides>;
export interface BindTemplatePersonasInput {
runId: string;
template: Template;
personas: Persona[];
templateHash: string;
availableBackends: readonly BackendConfig[];
overrides?: Partial<BindingOverrides>;
}
export interface RoleBinding {
roleId: string;
templateRoleId: string;
persona: Persona;
personaHash: string;
backend: Backend;
bindingHash: string;
}
export interface BindingResult {
bindings: RoleBinding[];
}
export function bindTemplatePersonas(input: BindTemplatePersonasInput): BindingResult {
const overrides = BindingOverrides.parse(input.overrides ?? {});
assertOverrideRoleKeys(input.template, overrides);
const bindings: RoleBinding[] = [];
for (const role of input.template.roles) {
const assignments = selectRoleAssignments(input, role, overrides);
for (const assignment of assignments) {
const { roleId, override, candidate } = assignment;
const personaHashValue = personaHash(candidate);
bindings.push({
roleId,
templateRoleId: role.id,
persona: candidate,
personaHash: personaHashValue,
backend: candidate.backend,
bindingHash: hash({
runId: input.runId,
roleId,
templateHash: input.templateHash,
personaHash: personaHashValue,
backend: candidate.backend,
override,
}),
});
}
}
return { bindings };
}
interface RoleAssignment {
roleId: string;
override: BindingOverride;
candidate: Persona;
}
function selectRoleAssignments(
input: BindTemplatePersonasInput,
role: TemplateRole,
overrides: BindingOverrides,
): RoleAssignment[] {
const instances = roleInstances(role).map((roleId) => ({
roleId,
override: normalizeOverride(overrides.roles[roleId] ?? overrides.roles[role.id]),
}));
const candidateLists = instances.map((instance) => ({
...instance,
candidates: candidatesForRoleInstance(input, role, instance.override),
}));
for (const list of candidateLists) {
if (list.candidates.length === 0) {
throw noEligiblePersona(list.roleId);
}
}
const assignments = assignCandidates(
candidateLists,
role.diversity?.requireDifferentBackends === true,
);
if (assignments === undefined) {
throw noEligiblePersona(role.id, "diversity failed");
}
return instances.map((instance) => {
const candidate = assignments.get(instance.roleId);
if (candidate === undefined) {
throw noEligiblePersona(instance.roleId);
}
return { ...instance, candidate };
});
}
function roleInstances(role: TemplateRole): string[] {
if (role.count === 1) {
return [role.id];
}
return Array.from({ length: role.count }, (_, index) => `${role.id}#${index}`);
}
function assertOverrideRoleKeys(template: Template, overrides: BindingOverrides) {
const validRoleIds = new Set<string>();
for (const role of template.roles) {
validRoleIds.add(role.id);
if (role.count > 1) {
for (let index = 0; index < role.count; index += 1) {
validRoleIds.add(`${role.id}#${index}`);
}
}
}
for (const roleId of Object.keys(overrides.roles)) {
if (!validRoleIds.has(roleId)) {
throw noEligiblePersona(roleId, "unknown override role");
}
}
}
interface CandidateList {
roleId: string;
override?: BindingOverride | undefined;
candidates: Persona[];
}
function candidatesForRoleInstance(
input: BindTemplatePersonasInput,
role: TemplateRole,
override: BindingOverride | undefined,
): Persona[] {
const normalizedOverride = normalizeOverride(override);
const candidates: Persona[] = [];
const sortedCandidates = sortCandidates(input.personas, role)
.filter((persona) =>
normalizedOverride.persona === undefined ? true : persona.name === normalizedOverride.persona,
)
.filter((persona) =>
normalizedOverride.backend === undefined
? true
: persona.backend === normalizedOverride.backend,
);
const selectableCandidates = hasOverrideConstraint(normalizedOverride)
? sortedCandidates
: applyPreferredFallbackRule(sortedCandidates, role, input.template);
for (const candidate of selectableCandidates) {
if (!isEligible(candidate, role, input.template)) {
continue;
}
if (!isBackendAvailable(candidate.backend, input.availableBackends)) {
if (candidates.length === 0) {
throw backendUnavailable(candidate.backend);
}
continue;
}
candidates.push(candidate);
}
return candidates;
}
function hasOverrideConstraint(override: BindingOverride) {
return override.persona !== undefined || override.backend !== undefined;
}
function applyPreferredFallbackRule(
candidates: Persona[],
role: TemplateRole,
template: Template,
): Persona[] {
if (role.preferredBackends.length === 0) {
return candidates;
}
const preferredCandidates = candidates.filter((candidate) =>
role.preferredBackends.includes(candidate.backend),
);
if (preferredCandidates.length === 0) {
return candidates;
}
const allPreferredFailCapabilityOrRisk = preferredCandidates.every(
(candidate) => !capabilitiesCovered(candidate, role) || !riskCovered(candidate, role, template),
);
return allPreferredFailCapabilityOrRisk ? candidates : preferredCandidates;
}
function assignCandidates(
candidateLists: CandidateList[],
requireDifferentBackends: boolean,
): Map<string, Persona> | undefined {
return assignCandidatesAt(candidateLists, requireDifferentBackends, 0, new Map(), new Set());
}
function assignCandidatesAt(
candidateLists: CandidateList[],
requireDifferentBackends: boolean,
index: number,
assignments: Map<string, Persona>,
selectedBackends: Set<Backend>,
): Map<string, Persona> | undefined {
if (index >= candidateLists.length) {
return assignments;
}
const candidateList = candidateLists[index];
if (candidateList === undefined) {
return assignments;
}
for (const candidate of candidateList.candidates) {
if (requireDifferentBackends && selectedBackends.has(candidate.backend)) {
continue;
}
const nextAssignments = new Map(assignments);
const nextBackends = new Set(selectedBackends);
nextAssignments.set(candidateList.roleId, candidate);
nextBackends.add(candidate.backend);
const result = assignCandidatesAt(
candidateLists,
requireDifferentBackends,
index + 1,
nextAssignments,
nextBackends,
);
if (result !== undefined) {
return result;
}
}
return undefined;
}
function normalizeOverride(override: BindingOverride | undefined): BindingOverride {
const parsed = BindingOverride.parse(override ?? {});
const normalized: BindingOverride = {};
if (parsed.persona !== undefined) {
normalized.persona = parsed.persona;
}
if (parsed.backend !== undefined) {
normalized.backend = parsed.backend;
}
return normalized;
}
function sortCandidates(personas: Persona[], role: TemplateRole) {
return [...personas].sort((left, right) => {
const leftPreferredRank = preferredBackendRank(left.backend, role);
const rightPreferredRank = preferredBackendRank(right.backend, role);
return (
leftPreferredRank - rightPreferredRank ||
right.version - left.version ||
compareCodeUnits(left.name, right.name) ||
compareCodeUnits(personaHash(left), personaHash(right))
);
});
}
function preferredBackendRank(backend: Backend, role: TemplateRole) {
const rank = role.preferredBackends.indexOf(backend);
if (rank >= 0) {
return rank;
}
return role.preferredBackends.length;
}
function isEligible(persona: Persona, role: TemplateRole, template: Template) {
return (
roleAllowed(persona, role) &&
capabilitiesCovered(persona, role) &&
riskCovered(persona, role, template)
);
}
function roleAllowed(persona: Persona, role: TemplateRole) {
return persona.allowedRoles === undefined || persona.allowedRoles.includes(role.id);
}
function capabilitiesCovered(persona: Persona, role: TemplateRole) {
const capabilities = new Set(persona.capabilities);
return role.requiredCapabilities.every((capability) => capabilities.has(capability));
}
function riskCovered(persona: Persona, role: TemplateRole, template: Template) {
const phaseRisk = template.phases
.filter((phase) => phase.roles.includes(role.id))
.reduce<number>((maxRisk, phase) => Math.max(maxRisk, riskRank[phase.risk]), riskRank.low);
return phaseRisk <= riskRank[persona.maxRiskLevel];
}
function isBackendAvailable(backend: Backend, availableBackends: readonly BackendConfig[]) {
if (backend === "fake") {
return true;
}
return availableBackends.some(
(backendConfig) =>
backendConfig.id === backend &&
backendConfig.enabled &&
isResolvedBinaryPath(backendConfig.binaryPath),
);
}
function isResolvedBinaryPath(path: string | undefined) {
return typeof path === "string" && path.length > 0 && isAbsolute(path);
}
function backendUnavailable(backend: string) {
return new DevflowError(`human_required:backend_unavailable:${backend}`, {
class: "human_required",
code: "backend_unavailable",
recoveryHint: `Enable ${backend} and ensure its binary resolves at process start.`,
});
}
function noEligiblePersona(roleId: string, reason?: string) {
return new DevflowError(
`human_required:no_eligible_persona:${roleId}${reason === undefined ? "" : `:${reason}`}`,
{
class: "human_required",
code: "no_eligible_persona",
recoveryHint: `Add or override a persona eligible for role ${roleId}.`,
},
);
}
function compareCodeUnits(left: string, right: string) {
if (left < right) {
return -1;
}
if (left > right) {
return 1;
}
return 0;
}

View File

@@ -1,210 +0,0 @@
import { chmodSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { loadConfigFromSources } from "./config.js";
import { DevflowError } from "./errors.js";
describe("config loader", () => {
it("loads .env, .env.local, then process env in descending precedence", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
mkdirSync(workspace);
writeFileSync(
join(root, ".env"),
[
"DATABASE_URL=postgres://env:env@localhost:5432/env",
"WORKSPACE_ROOT=workspace",
"LOG_LEVEL=warn",
"TEMPORAL_ADDRESS=localhost:7233",
].join("\n"),
);
writeFileSync(join(root, ".env.local"), "LOG_LEVEL=debug\n");
const config = loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://process:process@localhost:5432/process",
},
});
expect(config.DATABASE_URL).toBe("postgres://process:process@localhost:5432/process");
expect(config.LOG_LEVEL).toBe("debug");
expect(config.WORKSPACE_ROOT).toBe(realpathSync(workspace));
});
it("always exposes the fake backend as enabled", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
mkdirSync(workspace);
const config = loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow",
WORKSPACE_ROOT: workspace,
LOG_LEVEL: "info",
TEMPORAL_ADDRESS: "localhost:7233",
},
});
expect(config.backends).toContainEqual({ id: "fake", enabled: true });
expect(config.SESSION_MAX_HUNG_MS).toBe(20 * 60 * 1000);
});
it("loads configurable session hung timeout", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
mkdirSync(workspace);
const config = loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow",
WORKSPACE_ROOT: workspace,
LOG_LEVEL: "info",
TEMPORAL_ADDRESS: "localhost:7233",
SESSION_MAX_HUNG_MS: "2500",
},
});
expect(config.SESSION_MAX_HUNG_MS).toBe(2500);
});
it("resolves backend binaries from PATH during config load", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
const binDir = join(root, "bin");
const codexBin = join(binDir, "codex");
mkdirSync(workspace);
mkdirSync(binDir);
writeFileSync(codexBin, "#!/bin/sh\nexit 0\n");
chmodSync(codexBin, 0o755);
const config = loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow",
WORKSPACE_ROOT: workspace,
LOG_LEVEL: "info",
TEMPORAL_ADDRESS: "localhost:7233",
PATH: binDir,
DEVFLOW_BACKENDS_JSON: JSON.stringify([{ id: "codex", enabled: true }]),
},
});
expect(config.backends).toEqual([
{ id: "fake", enabled: true },
{ id: "codex", enabled: true, binaryPath: realpathSync(codexBin) },
]);
});
it("keeps enabled real backends unavailable when their binary cannot be resolved", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
const emptyBin = join(root, "empty-bin");
mkdirSync(workspace);
mkdirSync(emptyBin);
const config = loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow",
WORKSPACE_ROOT: workspace,
LOG_LEVEL: "info",
TEMPORAL_ADDRESS: "localhost:7233",
PATH: emptyBin,
DEVFLOW_BACKENDS_JSON: JSON.stringify([{ id: "codex", enabled: true }]),
},
});
expect(config.backends).toEqual([
{ id: "fake", enabled: true },
{ id: "codex", enabled: true },
]);
});
it("requires LOG_LEVEL and classifies invalid config as fatal", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
mkdirSync(workspace);
let caught: unknown;
try {
loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow",
WORKSPACE_ROOT: workspace,
},
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(DevflowError);
expect((caught as DevflowError).class).toBe("fatal");
expect((caught as DevflowError).code).toBe("config_invalid");
expect((caught as DevflowError).cause).toBeDefined();
});
it("requires TEMPORAL_ADDRESS at M5", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
mkdirSync(workspace);
expect(() =>
loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow",
WORKSPACE_ROOT: workspace,
LOG_LEVEL: "info",
},
}),
).toThrow(DevflowError);
});
it("classifies malformed backend JSON as invalid config", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
mkdirSync(workspace);
expect(() =>
loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow",
WORKSPACE_ROOT: workspace,
LOG_LEVEL: "info",
TEMPORAL_ADDRESS: "localhost:7233",
DEVFLOW_BACKENDS_JSON: "{",
},
}),
).toThrow(DevflowError);
});
it("freezes config and backend registrations", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
mkdirSync(workspace);
const config = loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow",
WORKSPACE_ROOT: workspace,
LOG_LEVEL: "info",
TEMPORAL_ADDRESS: "localhost:7233",
},
});
expect(Object.isFrozen(config)).toBe(true);
expect(Object.isFrozen(config.backends)).toBe(true);
expect(Object.isFrozen(config.backends[0])).toBe(true);
expect(() => {
(config.backends[0] as { enabled: boolean }).enabled = false;
}).toThrow(TypeError);
});
});

View File

@@ -1,207 +0,0 @@
import { constants, accessSync, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
import { delimiter, isAbsolute, resolve } from "node:path";
import { parse } from "dotenv";
import { z } from "zod";
import { Backend } from "./enums.js";
import { DevflowError } from "./errors.js";
const LogLevel = z.enum(["trace", "debug", "info", "warn", "error"]);
export const BackendConfig = z.object({
id: Backend,
enabled: z.boolean(),
binaryPath: z.string().optional(),
});
export type BackendConfig = z.infer<typeof BackendConfig>;
const RawConfigSchema = z.object({
DATABASE_URL: z.string().min(1),
WORKSPACE_ROOT: z.string().min(1),
LOG_LEVEL: LogLevel,
TEMPORAL_ADDRESS: z.string().min(1),
MAX_CONCURRENT_RUNS: z.coerce.number().int().positive().default(4),
SESSION_MAX_HUNG_MS: z.coerce
.number()
.int()
.positive()
.default(20 * 60 * 1000),
backends: z.array(BackendConfig).default([{ id: "fake", enabled: true }]),
});
type RawConfig = z.infer<typeof RawConfigSchema>;
export type Config = Omit<RawConfig, "WORKSPACE_ROOT" | "backends"> & {
readonly WORKSPACE_ROOT: string;
readonly backends: readonly BackendConfig[];
};
export const ConfigSchema = RawConfigSchema.transform(
(value): Config =>
finalizeConfig(value, {
cwd: process.cwd(),
pathEnv: process.env.PATH,
}),
);
export interface LoadConfigOptions {
cwd?: string;
env?: Record<string, string | undefined>;
}
function readEnvFile(cwd: string, fileName: string): Record<string, string> {
const path = resolve(cwd, fileName);
if (!existsSync(path)) {
return {};
}
return parse(readFileSync(path));
}
export function loadConfigFromSources(options: LoadConfigOptions = {}): Config {
try {
const cwd = options.cwd ?? process.cwd();
const env = options.env ?? process.env;
const raw = {
...readEnvFile(cwd, ".env"),
...readEnvFile(cwd, ".env.local"),
...env,
};
const normalizedRaw = normalizeRawConfig(raw);
if (typeof normalizedRaw.WORKSPACE_ROOT === "string") {
normalizedRaw.WORKSPACE_ROOT = resolve(cwd, normalizedRaw.WORKSPACE_ROOT);
}
return finalizeConfig(RawConfigSchema.parse(normalizedRaw), {
cwd,
pathEnv: env.PATH ?? process.env.PATH,
});
} catch (error) {
if (error instanceof DevflowError) {
throw error;
}
throw configInvalid(error);
}
}
function normalizeRawConfig(raw: Record<string, string | undefined>): Record<string, unknown> {
const backendsJson = raw.DEVFLOW_BACKENDS_JSON;
if (!backendsJson) {
return raw;
}
return {
...raw,
backends: JSON.parse(backendsJson) as unknown,
};
}
let cachedConfig: Config | undefined;
export function getConfig(): Config {
cachedConfig ??= loadConfigFromSources();
return cachedConfig;
}
function finalizeConfig(
value: RawConfig,
options: { cwd: string; pathEnv: string | undefined },
): Config {
const canonicalWorkspaceRoot = realpathSync(resolve(value.WORKSPACE_ROOT));
return Object.freeze({
...value,
WORKSPACE_ROOT: canonicalWorkspaceRoot,
backends: Object.freeze(normalizeBackends(value.backends, options)),
});
}
function normalizeBackends(
backends: BackendConfig[],
options: { cwd: string; pathEnv: string | undefined },
): BackendConfig[] {
const normalized = backends.map((backend) => normalizeBackend(backend, options));
const hasFakeBackend = normalized.some((backend) => backend.id === "fake");
if (hasFakeBackend) {
return normalized;
}
return [freezeBackend({ id: "fake", enabled: true }), ...normalized];
}
function normalizeBackend(
backend: BackendConfig,
options: { cwd: string; pathEnv: string | undefined },
): BackendConfig {
if (backend.id === "fake") {
return freezeBackend({ id: "fake", enabled: true });
}
if (!backend.enabled) {
return freezeBackend({ id: backend.id, enabled: false });
}
const resolvedPath = resolveBinaryPath(backend.binaryPath ?? backend.id, options);
if (resolvedPath === undefined) {
return freezeBackend({ id: backend.id, enabled: true });
}
return freezeBackend({ id: backend.id, enabled: true, binaryPath: resolvedPath });
}
function resolveBinaryPath(
binaryPath: string,
options: { cwd: string; pathEnv: string | undefined },
): string | undefined {
if (binaryPath.includes("/")) {
const candidate = isAbsolute(binaryPath) ? binaryPath : resolve(options.cwd, binaryPath);
return executableRealpath(candidate);
}
for (const pathEntry of (options.pathEnv ?? "").split(delimiter)) {
if (pathEntry.length === 0) {
continue;
}
const candidate = resolve(pathEntry, binaryPath);
const resolved = executableRealpath(candidate);
if (resolved !== undefined) {
return resolved;
}
}
return undefined;
}
function executableRealpath(path: string): string | undefined {
try {
const resolved = realpathSync(path);
if (!statSync(resolved).isFile()) {
return undefined;
}
accessSync(resolved, constants.X_OK);
return resolved;
} catch {
return undefined;
}
}
function freezeBackend(backend: BackendConfig): BackendConfig {
return Object.freeze(backend);
}
function configInvalid(cause: unknown) {
return new DevflowError("config_invalid", {
class: "fatal",
code: "config_invalid",
cause,
recoveryHint: "Fix .env, .env.local, environment variables, and backend registrations.",
});
}

View File

@@ -1,22 +0,0 @@
import { describe, expect, it } from "vitest";
import {
ApprovalDecisionActionValues,
BackendValues,
CapabilityValues,
RiskLevelValues,
} from "./enums.js";
describe("core enums", () => {
it("keeps approval decisions separate from run pause controls", () => {
expect(ApprovalDecisionActionValues).toEqual(["approve", "reject", "request_changes", "abort"]);
expect(ApprovalDecisionActionValues).not.toContain("pause");
});
it("exports the locked backend, risk, and capability sets", () => {
expect(BackendValues).toEqual(["codex", "claude", "fake"]);
expect(RiskLevelValues).toEqual(["low", "medium", "high"]);
expect(CapabilityValues).toContain("test_first_development");
expect(CapabilityValues).toContain("backtest_run");
});
});

View File

@@ -1,90 +0,0 @@
import { z } from "zod";
export const BackendValues = ["codex", "claude", "fake"] as const;
export const Backend = z.enum(BackendValues);
export type Backend = z.infer<typeof Backend>;
export const CapabilityValues = [
"spec_write",
"phase_planning",
"task_dag_planning",
"code_edit",
"test_first_development",
"code_review",
"evidence_check",
"command_execute",
"backtest_run",
"metric_extract",
"failure_mining",
"objective_eval",
"final_report_compose",
] as const;
export const Capability = z.enum(CapabilityValues);
export type Capability = z.infer<typeof Capability>;
export const RiskLevelValues = ["low", "medium", "high"] as const;
export const RiskLevel = z.enum(RiskLevelValues);
export type RiskLevel = z.infer<typeof RiskLevel>;
export const ApprovalDecisionActionValues = [
"approve",
"reject",
"request_changes",
"abort",
] as const;
export const ApprovalDecisionAction = z.enum(ApprovalDecisionActionValues);
export type ApprovalDecisionAction = z.infer<typeof ApprovalDecisionAction>;
export const ApprovalStateValues = [
"pending",
"approved",
"rejected",
"changes_requested",
"aborted",
"paused",
] as const;
export const ApprovalState = z.enum(ApprovalStateValues);
export type ApprovalState = z.infer<typeof ApprovalState>;
export const RunStateValues = [
"created",
"bound",
"planning",
"awaiting_approval",
"executing",
"paused",
"completed",
"failed",
"aborted",
] as const;
export const RunState = z.enum(RunStateValues);
export type RunState = z.infer<typeof RunState>;
export const RunPhaseStateValues = [
"pending",
"running",
"awaiting_artifact",
"validating",
"awaiting_approval",
"completed",
"failed",
"skipped",
] as const;
export const RunPhaseState = z.enum(RunPhaseStateValues);
export type RunPhaseState = z.infer<typeof RunPhaseState>;
export const SessionStateValues = [
"CREATED",
"BOOTSTRAPPING",
"READY",
"BUSY",
"WAITING_FOR_APPROVAL",
"ARTIFACT_TIMEOUT",
"HUNG",
"CRASHED",
"RESUMING",
"REBOOTSTRAPPED",
"FAILED_NEEDS_HUMAN",
] as const;
export const SessionState = z.enum(SessionStateValues);
export type SessionState = z.infer<typeof SessionState>;

View File

@@ -1,17 +0,0 @@
import { describe, expect, it } from "vitest";
import { DevflowError } from "./errors.js";
describe("DevflowError", () => {
it("carries stable classification metadata", () => {
const error = new DevflowError("blocked", {
class: "human_required",
code: "destructive_command_blocked",
recoveryHint: "Ask for approval before running rm -rf",
});
expect(error.class).toBe("human_required");
expect(error.code).toBe("destructive_command_blocked");
expect(error.recoveryHint).toContain("approval");
});
});

View File

@@ -1,30 +0,0 @@
export type ErrorClass = "recoverable" | "human_required" | "fatal";
export interface DevflowErrorOptions {
class: ErrorClass;
code: string;
runId?: string;
phaseId?: string;
recoveryHint?: string;
cause?: unknown;
}
export class DevflowError extends Error {
readonly class: ErrorClass;
readonly code: string;
readonly runId: string | undefined;
readonly phaseId: string | undefined;
readonly recoveryHint: string | undefined;
override readonly cause: unknown;
constructor(message: string, options: DevflowErrorOptions) {
super(message, { cause: options.cause });
this.name = "DevflowError";
this.class = options.class;
this.code = options.code;
this.runId = options.runId;
this.phaseId = options.phaseId;
this.recoveryHint = options.recoveryHint;
this.cause = options.cause;
}
}

View File

@@ -1,63 +0,0 @@
import { describe, expect, it } from "vitest";
import { canonicalize, hash } from "./hash.js";
describe("content hashing", () => {
it("canonicalizes object keys lexicographically while preserving array order", () => {
expect(canonicalize({ z: 1, a: [{ b: true, a: null }] })).toBe(
'{"a":[{"a":null,"b":true}],"z":1}',
);
});
it("hashes equivalent object key orders to the same sha256 hex", () => {
const left = hash({ z: 1, a: 2 });
const right = hash({ a: 2, z: 1 });
expect(left).toBe(right);
expect(left).toMatch(/^[a-f0-9]{64}$/);
});
it("preserves own __proto__ keys while canonicalizing objects", () => {
const withProtoKey = JSON.parse('{"__proto__":{"x":1},"a":2}') as unknown;
expect(canonicalize(withProtoKey)).toBe('{"__proto__":{"x":1},"a":2}');
expect(hash(withProtoKey)).not.toBe(hash({ a: 2 }));
});
it("rejects hidden object keys that would be ignored by JSON rendering", () => {
const withSymbol = { a: 1, [Symbol("x")]: 2 };
const withHidden = { a: 1 };
Object.defineProperty(withHidden, "hidden", { value: 2, enumerable: false });
expect(() => canonicalize(withSymbol)).toThrow(/non-enumerable or symbol/);
expect(() => canonicalize(withHidden)).toThrow(/non-enumerable or symbol/);
});
it("rejects non-index array object keys that would be ignored by JSON rendering", () => {
const withStringKey = [1] as number[] & { extra?: number };
withStringKey.extra = 2;
const withSymbol = [1] as unknown[];
Object.defineProperty(withSymbol, Symbol("x"), { value: 2, enumerable: true });
expect(() => canonicalize(withStringKey)).toThrow(/non-index array/);
expect(() => canonicalize(withSymbol)).toThrow(/non-index array/);
});
it("renders the shortest round-trippable number literals without plus signs", () => {
expect(canonicalize([100, 1000, 11000, 123000, 1e20, 1e21, 0.000001, 0.0000001])).toBe(
"[100,1e3,11e3,123e3,1e20,1e21,1e-6,1e-7]",
);
});
it("rejects values that are not JSON-safe", () => {
const sparse = Array<number>(3);
sparse[0] = 1;
sparse[2] = 3;
expect(() => canonicalize({ date: new Date("2026-05-09T00:00:00Z") })).toThrow(
/non-plain object/,
);
expect(() => canonicalize({ missing: undefined })).toThrow(/undefined/);
expect(() => canonicalize(sparse)).toThrow(/sparse array/);
});
});

View File

@@ -1,265 +0,0 @@
import { createHash } from "node:crypto";
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
export function canonicalize(value: unknown): string {
return renderCanonical(assertJsonValue(value));
}
export function hash(value: unknown): string {
return createHash("sha256").update(canonicalize(value)).digest("hex");
}
function renderCanonical(value: JsonValue): string {
if (value === null || typeof value === "boolean" || typeof value === "string") {
return JSON.stringify(value);
}
if (typeof value === "number") {
return renderCanonicalNumber(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => renderCanonical(item)).join(",")}]`;
}
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${renderCanonical(value[key] as JsonValue)}`)
.join(",")}}`;
}
function assertJsonValue(value: unknown): JsonValue {
if (
value === null ||
typeof value === "boolean" ||
typeof value === "string" ||
Array.isArray(value)
) {
if (Array.isArray(value)) {
assertOnlyArrayIndexKeys(value);
const arrayValue: JsonValue[] = [];
for (let index = 0; index < value.length; index += 1) {
if (!(index in value)) {
throw new TypeError(`Cannot canonicalize sparse array at index ${index}`);
}
arrayValue.push(assertJsonValue(value[index]));
}
return arrayValue;
}
return value;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
throw new TypeError("Cannot canonicalize non-finite numbers");
}
return value;
}
if (typeof value === "object") {
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError("Cannot canonicalize non-plain object");
}
assertOnlyEnumerableStringKeys(value);
const objectValue: Record<string, JsonValue> = Object.create(null) as Record<string, JsonValue>;
for (const [key, childValue] of Object.entries(value as Record<string, unknown>)) {
if (childValue === undefined) {
throw new TypeError(`Cannot canonicalize undefined at key ${key}`);
}
objectValue[key] = assertJsonValue(childValue);
}
return objectValue;
}
throw new TypeError(`Cannot canonicalize ${typeof value}`);
}
function assertOnlyEnumerableStringKeys(value: object) {
const enumerableStringKeys = Object.keys(value);
const ownKeys = Reflect.ownKeys(value);
const hasOnlyEnumerableStringKeys =
ownKeys.length === enumerableStringKeys.length &&
ownKeys.every(
(key) => typeof key === "string" && Object.prototype.propertyIsEnumerable.call(value, key),
);
if (!hasOnlyEnumerableStringKeys) {
throw new TypeError("Cannot canonicalize non-enumerable or symbol object keys");
}
}
function assertOnlyArrayIndexKeys(value: unknown[]) {
for (const key of Reflect.ownKeys(value)) {
if (key === "length") {
continue;
}
if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) {
throw new TypeError("Cannot canonicalize non-index array object keys");
}
const index = Number(key);
if (!Number.isInteger(index) || index < 0 || index >= value.length || String(index) !== key) {
throw new TypeError("Cannot canonicalize non-index array object keys");
}
}
}
function renderCanonicalNumber(value: number): string {
if (Object.is(value, -0) || value === 0) {
return "0";
}
const candidates = new Set<string>();
addNumberCandidate(candidates, value, value.toString());
const jsonCandidate = JSON.stringify(value);
if (jsonCandidate !== undefined) {
addNumberCandidate(candidates, value, jsonCandidate);
}
for (let precision = 1; precision <= 17; precision += 1) {
addNumberCandidate(candidates, value, value.toPrecision(precision));
addNumberCandidate(candidates, value, value.toExponential(precision - 1));
}
const [best] = [...candidates].sort(
(left, right) => left.length - right.length || compareCodeUnits(left, right),
);
if (!best) {
throw new TypeError(`Cannot canonicalize number ${value}`);
}
return best;
}
function addNumberCandidate(candidates: Set<string>, value: number, raw: string) {
const candidate = normalizeNumberLiteral(raw);
for (const equivalent of expandNumberLiteral(candidate)) {
if (Number(equivalent) === value) {
candidates.add(equivalent);
}
}
}
function compareCodeUnits(left: string, right: string) {
if (left < right) {
return -1;
}
if (left > right) {
return 1;
}
return 0;
}
function normalizeNumberLiteral(raw: string): string {
const [mantissaText, exponentText] = raw.toLowerCase().split("e");
const mantissa = normalizeDecimal(mantissaText ?? "");
if (exponentText === undefined) {
return mantissa;
}
const exponent = normalizeExponent(exponentText);
if (exponent === "0") {
return mantissa;
}
return `${mantissa}e${exponent}`;
}
function normalizeDecimal(raw: string): string {
if (!raw.includes(".")) {
return raw;
}
const trimmed = raw.replace(/0+$/, "").replace(/\.$/, "");
if (trimmed === "-0") {
return "0";
}
return trimmed;
}
function normalizeExponent(raw: string): string {
const sign = raw.startsWith("-") ? "-" : "";
const unsigned = raw.replace(/^[+-]/, "").replace(/^0+/, "");
if (unsigned === "") {
return "0";
}
return `${sign}${unsigned}`;
}
function expandNumberLiteral(raw: string): string[] {
const parsed = parseNumberLiteral(raw);
if (!parsed) {
return [raw];
}
const plain = renderPlainDecimal(parsed);
const candidates = new Set([plain]);
for (let integerDigits = 1; integerDigits <= parsed.digits.length; integerDigits += 1) {
const exponent = parsed.power + parsed.digits.length - integerDigits;
if (exponent === 0) {
continue;
}
const mantissa =
integerDigits === parsed.digits.length
? parsed.digits
: `${parsed.digits.slice(0, integerDigits)}.${parsed.digits.slice(integerDigits)}`;
candidates.add(`${parsed.sign}${mantissa}e${exponent}`);
}
return [...candidates];
}
function parseNumberLiteral(raw: string) {
const match = raw.match(/^(-?)(\d+)(?:\.(\d+))?(?:e(-?\d+))?$/);
if (!match) {
return undefined;
}
const [, sign, integerPart, fractionalPart = "", exponentPart] = match;
let digits = `${integerPart}${fractionalPart}`;
let power = (exponentPart === undefined ? 0 : Number(exponentPart)) - fractionalPart.length;
digits = digits.replace(/^0+/, "");
if (digits === "") {
return { sign: "", digits: "0", power: 0 };
}
const lengthBeforeTrailingTrim = digits.length;
digits = digits.replace(/0+$/, "");
power += lengthBeforeTrailingTrim - digits.length;
return { sign: sign ?? "", digits, power };
}
function renderPlainDecimal(parsed: { sign: string; digits: string; power: number }) {
if (parsed.digits === "0") {
return "0";
}
if (parsed.power >= 0) {
return `${parsed.sign}${parsed.digits}${"0".repeat(parsed.power)}`;
}
const pointIndex = parsed.digits.length + parsed.power;
if (pointIndex > 0) {
return `${parsed.sign}${parsed.digits.slice(0, pointIndex)}.${parsed.digits.slice(pointIndex)}`;
}
return `${parsed.sign}0.${"0".repeat(-pointIndex)}${parsed.digits}`;
}

View File

@@ -1,12 +0,0 @@
export * from "./artifact-schema.js";
export * from "./config.js";
export * from "./binding.js";
export * from "./enums.js";
export * from "./errors.js";
export * from "./hash.js";
export * from "./persona.js";
export * from "./prompt-envelope.js";
export * from "./registry-loader.js";
export * from "./run-event.js";
export * from "./template.js";
export * from "./version.js";

View File

@@ -1,147 +0,0 @@
import { z } from "zod";
import { Backend, Capability, RiskLevel } from "./enums.js";
import { hash } from "./hash.js";
import { DbIntVersion } from "./version.js";
export type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonObject = { [key: string]: JsonValue };
export const JsonObject: z.ZodType<JsonObject, z.ZodTypeDef, unknown> = z.lazy(() =>
z
.custom<Record<string, unknown>>(isPlainJsonRecordInput, {
message: "expected plain JSON object",
})
.superRefine((value, context) => {
for (const key of Reflect.ownKeys(value)) {
if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "expected plain JSON object",
});
continue;
}
if (!isSafeJsonObjectKey(key)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "reserved object key",
path: [key],
});
}
}
})
.pipe(z.record(z.string(), JsonValue)),
);
export const JsonValue: z.ZodType<JsonValue, z.ZodTypeDef, unknown> = z.lazy(() =>
z.union([z.null(), z.boolean(), z.number().finite(), z.string(), JsonArray, JsonObject]),
);
export const JsonArray: z.ZodType<JsonValue[], z.ZodTypeDef, unknown> = z.lazy(() =>
z
.custom<unknown[]>(Array.isArray, { message: "expected JSON array" })
.superRefine((value, context) => {
for (const key of Reflect.ownKeys(value)) {
if (key === "length") {
continue;
}
if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "expected JSON array",
});
continue;
}
const index = Number(key);
if (
!Number.isInteger(index) ||
index < 0 ||
index >= value.length ||
String(index) !== key
) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "expected JSON array",
});
}
}
})
.pipe(z.array(JsonValue)),
);
export const Persona = z
.object({
name: z.string().min(1),
version: DbIntVersion,
backend: Backend,
capabilities: z.array(Capability),
maxRiskLevel: RiskLevel,
allowedRoles: z.array(z.string().min(1)).optional(),
promptConfig: z
.object({
systemPrompt: z.string().optional(),
instructionsPrelude: z.string().optional(),
})
.strict()
.default({})
.transform((value) => {
const promptConfig: { systemPrompt?: string; instructionsPrelude?: string } = {};
if (value.systemPrompt !== undefined) {
promptConfig.systemPrompt = value.systemPrompt;
}
if (value.instructionsPrelude !== undefined) {
promptConfig.instructionsPrelude = value.instructionsPrelude;
}
return promptConfig;
}),
modelConfig: JsonObject.default({}),
})
.strict();
export type Persona = z.infer<typeof Persona>;
export function personaHash(persona: Persona | undefined): string {
if (!persona) {
throw new TypeError("persona is required");
}
const hashSubject = {
name: persona.name,
version: persona.version,
capabilities: persona.capabilities,
backend: persona.backend,
maxRiskLevel: persona.maxRiskLevel,
promptConfig: persona.promptConfig,
modelConfig: persona.modelConfig,
};
return hash(
persona.allowedRoles === undefined
? hashSubject
: { ...hashSubject, allowedRoles: persona.allowedRoles },
);
}
function isSafeJsonObjectKey(key: string) {
return key !== "__proto__" && key !== "constructor" && key !== "prototype";
}
function isPlainJsonRecordInput(value: unknown) {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}

View File

@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { PromptEnvelope, renderPromptEnvelope } from "./prompt-envelope.js";
describe("prompt envelope", () => {
it("validates and renders the locked wire markers", () => {
const envelope = PromptEnvelope.parse({
uuid: "00000000-0000-4000-8000-000000000000",
runId: "11111111-1111-4111-8111-111111111111",
roleId: "planner",
phaseKey: "plan",
attempt: 0,
expectedArtifact: "/tmp/devflow/spec.json",
expectedSchema: "dev/spec@1",
dedupKey: "a".repeat(64),
instructions: "Write the spec.",
});
expect(renderPromptEnvelope(envelope)).toContain(
"DEVFLOW_PROMPT_BEGIN 00000000-0000-4000-8000-000000000000",
);
expect(renderPromptEnvelope(envelope)).toContain("Phase: plan");
expect(renderPromptEnvelope(envelope)).toContain("DEVFLOW_PROMPT_END");
});
});

View File

@@ -1,31 +0,0 @@
import { z } from "zod";
export const PromptEnvelope = z.object({
uuid: z.string().uuid(),
runId: z.string().uuid(),
roleId: z.string().min(1),
phaseKey: z.string().min(1),
attempt: z.number().int().nonnegative(),
expectedArtifact: z.string().min(1),
expectedSchema: z.string().min(1),
dedupKey: z.string().regex(/^[a-f0-9]{64}$/),
instructions: z.string(),
});
export type PromptEnvelope = z.infer<typeof PromptEnvelope>;
export function renderPromptEnvelope(envelope: PromptEnvelope): string {
return [
`DEVFLOW_PROMPT_BEGIN ${envelope.uuid}`,
`Run: ${envelope.runId}`,
`Role: ${envelope.roleId}`,
`Phase: ${envelope.phaseKey}`,
`Attempt: ${envelope.attempt}`,
`Expected artifact: ${envelope.expectedArtifact}`,
`Expected schema: ${envelope.expectedSchema}`,
`Dedup-Key: ${envelope.dedupKey}`,
"Instructions:",
envelope.instructions,
`DEVFLOW_PROMPT_END ${envelope.uuid}`,
].join("\n");
}

View File

@@ -1,297 +0,0 @@
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { Persona } from "./persona.js";
import {
assertNoReferencedRegistryDeletions,
buildRegistrySeedPlan,
loadPersonaFiles,
loadTemplateFiles,
personaHash,
templateHash,
} from "./registry-loader.js";
function makeRoot() {
return mkdtempSync(join(tmpdir(), "devflow-registry-"));
}
describe("registry loader", () => {
it("loads versioned persona YAML files and computes stable hashes", () => {
const root = makeRoot();
const dir = join(root, "personas");
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "fake_developer@1.yaml"),
[
"name: fake_developer",
"version: 1",
"backend: fake",
"capabilities:",
" - code_edit",
"maxRiskLevel: medium",
].join("\n"),
);
const [entry] = loadPersonaFiles(dir);
expect(entry?.name).toBe("fake_developer");
expect(entry?.version).toBe(1);
expect(entry?.path).toBe(realpathSync(join(dir, "fake_developer@1.yaml")));
expect(entry?.hash).toMatch(/^[a-f0-9]{64}$/);
expect(entry?.hash).toBe(personaHash(entry?.definition));
});
it("rejects non-canonical template filenames and filename identity mismatches", () => {
const root = makeRoot();
const dir = join(root, "templates");
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "development@1.yaml"),
[
"name: development",
"version: 1",
"roles:",
" - id: implementer",
" requiredCapabilities:",
" - code_edit",
"phases:",
" - key: spec",
" title: Spec",
" risk: low",
" roles:",
" - implementer",
].join("\n"),
);
writeFileSync(
join(dir, "development@01.yaml"),
["name: development", "version: 1", "roles: []", "phases: []"].join("\n"),
);
writeFileSync(
join(dir, "actual_name@2.yml"),
["name: actual_name", "version: 2", "roles: []", "phases: []"].join("\n"),
);
expect(() => loadTemplateFiles(dir)).toThrow(/registry filename/);
const validDir = join(root, "valid-templates");
mkdirSync(validDir);
writeFileSync(join(validDir, "development@1.yaml"), readDevelopmentTemplate());
const [entry] = loadTemplateFiles(validDir);
expect(entry?.hash).toBe(templateHash(entry?.definition));
const mismatchDir = join(root, "mismatched-templates");
mkdirSync(mismatchDir);
writeFileSync(join(mismatchDir, "wrong@1.yaml"), readDevelopmentTemplate());
expect(() => loadTemplateFiles(mismatchDir)).toThrow(/identity mismatch/);
});
it("rejects registry versions outside the database integer range", () => {
const root = makeRoot();
const personaDir = join(root, "personas");
mkdirSync(personaDir);
writeFileSync(
join(personaDir, "fake@2147483648.yaml"),
[
"name: fake",
"version: 2147483648",
"backend: fake",
"capabilities: []",
"maxRiskLevel: low",
].join("\n"),
);
expect(() => loadPersonaFiles(personaDir)).toThrow(/less than or equal/);
});
it("rejects unknown template and persona keys instead of silently stripping them", () => {
const root = makeRoot();
const personaDir = join(root, "personas");
const templateDir = join(root, "templates");
mkdirSync(personaDir);
mkdirSync(templateDir);
writeFileSync(
join(personaDir, "fake@1.yaml"),
[
"name: fake",
"version: 1",
"backend: fake",
"capabilities: []",
"maxRiskLevel: low",
"typo: accepted",
].join("\n"),
);
writeFileSync(
join(templateDir, "development@1.yaml"),
["name: development", "version: 1", "roles: []", "phases: []", "typo: accepted"].join("\n"),
);
expect(() => loadPersonaFiles(personaDir)).toThrow(/Unrecognized key/);
expect(() => loadTemplateFiles(templateDir)).toThrow(/Unrecognized key/);
});
it("rejects persona model config values that cannot be content-hashed as JSON", () => {
const root = makeRoot();
const personaDir = join(root, "personas");
mkdirSync(personaDir);
writeFileSync(
join(personaDir, "fake@1.yaml"),
[
"name: fake",
"version: 1",
"backend: fake",
"capabilities: []",
"maxRiskLevel: low",
"modelConfig:",
" temperature: .nan",
].join("\n"),
);
expect(() => loadPersonaFiles(personaDir)).toThrow(/finite|number|expected plain JSON object/);
});
it("rejects persona model config keys that would mutate object prototypes", () => {
const root = makeRoot();
const personaDir = join(root, "personas");
mkdirSync(personaDir);
writeFileSync(
join(personaDir, "fake@1.yaml"),
[
"name: fake",
"version: 1",
"backend: fake",
"capabilities: []",
"maxRiskLevel: low",
"modelConfig:",
' "__proto__":',
" x: 1",
].join("\n"),
);
expect(() => loadPersonaFiles(personaDir)).toThrow(/reserved object key/);
});
it("rejects non-plain programmatic model config objects", () => {
class ModelConfig {
readonly inherited = true;
}
expect(() =>
Persona.parse({
name: "fake",
version: 1,
backend: "fake",
capabilities: [],
maxRiskLevel: "low",
modelConfig: new ModelConfig(),
}),
).toThrow(/expected plain JSON object/);
});
it("rejects programmatic model config arrays with non-index keys", () => {
const array = [1] as unknown[] & { extra?: number };
array.extra = 2;
expect(() =>
Persona.parse({
name: "fake",
version: 1,
backend: "fake",
capabilities: [],
maxRiskLevel: "low",
modelConfig: { array },
}),
).toThrow(/expected JSON array/);
});
it("rejects symlinked registry files", () => {
const root = makeRoot();
const dir = join(root, "personas");
mkdirSync(dir);
const target = join(root, "target.yaml");
writeFileSync(
target,
["name: fake", "version: 1", "backend: fake", "capabilities: []", "maxRiskLevel: low"].join(
"\n",
),
);
symlinkSync(target, join(dir, "fake@1.yaml"));
expect(() => loadPersonaFiles(dir)).toThrow(/not a symlink/);
});
it("builds seed actions and fails on published hash mismatch", () => {
const root = makeRoot();
const personaDir = join(root, "personas");
mkdirSync(personaDir);
writeFileSync(
join(personaDir, "fake@1.yaml"),
["name: fake", "version: 1", "backend: fake", "capabilities: []", "maxRiskLevel: low"].join(
"\n",
),
);
const [entry] = loadPersonaFiles(personaDir);
if (!entry) {
throw new Error("expected persona registry entry");
}
expect(buildRegistrySeedPlan([entry], [])).toEqual({
unchanged: [],
inserts: [entry],
missingReferenced: [],
missingUnreferenced: [],
});
expect(() =>
buildRegistrySeedPlan(
[entry],
[{ name: "fake", version: 1, hash: "different", referencedByRun: false }],
),
).toThrow(/published registry entry was modified/);
});
it("reports published registry rows that no longer have YAML files", () => {
const plan = buildRegistrySeedPlan(
[],
[
{ name: "unused", version: 1, hash: "abc", referencedByRun: false },
{ name: "referenced", version: 1, hash: "def", referencedByRun: true },
],
);
expect(plan).toEqual({
inserts: [],
missingReferenced: [{ name: "referenced", version: 1, hash: "def", referencedByRun: true }],
missingUnreferenced: [{ name: "unused", version: 1, hash: "abc", referencedByRun: false }],
unchanged: [],
});
});
it("rejects referenced published registry deletions", () => {
const plan = buildRegistrySeedPlan(
[],
[{ name: "referenced", version: 1, hash: "def", referencedByRun: true }],
);
expect(() => assertNoReferencedRegistryDeletions("persona", plan)).toThrow(/referenced@1/);
});
});
function readDevelopmentTemplate() {
return [
"name: development",
"version: 1",
"roles:",
" - id: implementer",
" requiredCapabilities:",
" - code_edit",
"phases:",
" - key: spec",
" title: Spec",
" risk: low",
" roles:",
" - implementer",
].join("\n");
}

View File

@@ -1,152 +0,0 @@
import { lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
import { basename, join } from "node:path";
import { parse } from "yaml";
import { Persona, personaHash } from "./persona.js";
import { Template, templateHash } from "./template.js";
export interface RegistryEntry<TDefinition> {
name: string;
version: number;
hash: string;
definition: TDefinition;
path: string;
}
export interface PublishedRegistryRow {
name: string;
version: number;
hash: string;
referencedByRun: boolean;
}
export interface RegistrySeedPlan<TDefinition> {
inserts: RegistryEntry<TDefinition>[];
missingReferenced: PublishedRegistryRow[];
missingUnreferenced: PublishedRegistryRow[];
unchanged: RegistryEntry<TDefinition>[];
}
export type RegistryKind = "persona" | "template";
export function loadPersonaFiles(directory: string): RegistryEntry<Persona>[] {
return loadVersionedYamlFiles(directory, Persona, personaHash);
}
export function loadTemplateFiles(directory: string): RegistryEntry<Template>[] {
return loadVersionedYamlFiles(directory, Template, templateHash);
}
export function buildRegistrySeedPlan<TDefinition>(
entries: RegistryEntry<TDefinition>[],
publishedRows: PublishedRegistryRow[],
): RegistrySeedPlan<TDefinition> {
const publishedByIdentity = new Map(
publishedRows.map((row) => [identityKey(row.name, row.version), row]),
);
const entriesByIdentity = new Map(
entries.map((entry) => [identityKey(entry.name, entry.version), entry]),
);
const inserts: RegistryEntry<TDefinition>[] = [];
const missingReferenced: PublishedRegistryRow[] = [];
const missingUnreferenced: PublishedRegistryRow[] = [];
const unchanged: RegistryEntry<TDefinition>[] = [];
for (const entry of entries) {
const published = publishedByIdentity.get(identityKey(entry.name, entry.version));
if (!published) {
inserts.push(entry);
continue;
}
if (published.hash !== entry.hash) {
throw new Error(
`A published registry entry was modified in place: ${entry.name}@${entry.version}`,
);
}
unchanged.push(entry);
}
for (const published of publishedRows) {
if (entriesByIdentity.has(identityKey(published.name, published.version))) {
continue;
}
if (published.referencedByRun !== false) {
missingReferenced.push(published);
} else {
missingUnreferenced.push(published);
}
}
return { inserts, missingReferenced, missingUnreferenced, unchanged };
}
export function assertNoReferencedRegistryDeletions<TDefinition>(
kind: RegistryKind,
plan: RegistrySeedPlan<TDefinition>,
) {
if (plan.missingReferenced.length === 0) {
return;
}
const identities = plan.missingReferenced.map((entry) => `${entry.name}@${entry.version}`);
throw new Error(
`Cannot delete published ${kind} registry files referenced by runs: ${identities.join(", ")}`,
);
}
export { personaHash, templateHash };
function loadVersionedYamlFiles<TDefinition extends { name: string; version: number }>(
directory: string,
schema: { parse(value: unknown): TDefinition },
hashDefinition: (definition: TDefinition) => string,
): RegistryEntry<TDefinition>[] {
return readdirSync(directory)
.filter((fileName) => {
if (fileName.endsWith(".yml")) {
throw new Error(`Invalid registry filename ${fileName}; expected <name>@<version>.yaml`);
}
return fileName.endsWith(".yaml");
})
.sort()
.map((fileName) => {
const path = join(directory, fileName);
if (lstatSync(path).isSymbolicLink()) {
throw new Error(`Registry filename ${fileName} must be a regular YAML file, not a symlink`);
}
const canonicalPath = realpathSync(path);
const definition = schema.parse(parse(readFileSync(path, "utf8")));
assertFilenameIdentity(fileName, definition);
return {
name: definition.name,
version: definition.version,
hash: hashDefinition(definition),
definition,
path: canonicalPath,
};
});
}
function assertFilenameIdentity(fileName: string, definition: { name: string; version: number }) {
const match = basename(fileName).match(/^(.+)@([1-9]\d*)\.yaml$/);
if (!match) {
throw new Error(`Invalid registry filename ${fileName}; expected <name>@<version>.yaml`);
}
const [, name, versionText] = match;
if (name !== definition.name || versionText !== String(definition.version)) {
throw new Error(
`Registry filename identity mismatch for ${fileName}: expected ${definition.name}@${definition.version}`,
);
}
}
function identityKey(name: string, version: number) {
return `${name}@${version}`;
}

View File

@@ -1,93 +0,0 @@
import { describe, expect, it } from "vitest";
import { RunEvent, RunEventPayloadSchemas, RunEventTypeValues } from "./run-event.js";
describe("run events", () => {
it("keeps a payload schema for every closed run event type", () => {
expect(Object.keys(RunEventPayloadSchemas).sort()).toEqual([...RunEventTypeValues].sort());
});
it("rejects malformed payloads for structured event families", () => {
expect(
RunEventPayloadSchemas["prompt.sent"].safeParse({
roleId: "implementer",
}).success,
).toBe(false);
expect(
RunEventPayloadSchemas["artifact.validated"].safeParse({
artifactId: "not-a-uuid",
hash: "not-a-sha",
path: "/tmp/spec.json",
schemaId: "dev/spec@1",
}).success,
).toBe(false);
expect(
RunEventPayloadSchemas["run.paused"].safeParse({
cause: "human_required:artifact_repair_failed",
}).success,
).toBe(false);
expect(RunEventPayloadSchemas["run.resumed"].safeParse({}).success).toBe(false);
expect(
RunEventPayloadSchemas["approval.resolved"].safeParse({
action: "pause",
approvalRequestId: "00000000-0000-4000-8000-000000000000",
}).success,
).toBe(false);
expect(
RunEventPayloadSchemas["approval.resolved"].safeParse({
action: "approve",
approvalRequestId: "00000000-0000-4000-8000-000000000000",
}).success,
).toBe(true);
expect(
RunEventPayloadSchemas["session.ready"].safeParse({
roleId: "implementer",
sessionId: "00000000-0000-4000-8000-000000000000",
}).success,
).toBe(false);
expect(
RunEventPayloadSchemas["session.failed"].safeParse({
sessionId: "00000000-0000-4000-8000-000000000000",
}).success,
).toBe(false);
expect(
RunEventPayloadSchemas["phase.started"].safeParse({
attempt: 0,
phaseKey: "implement",
}).success,
).toBe(false);
expect(
RunEventPayloadSchemas["artifact.expected"].safeParse({
attempt: 0,
path: "/tmp/spec.json",
schemaId: "dev/spec@1",
}).success,
).toBe(false);
expect(RunEventPayloadSchemas["phase.skipped"].safeParse({}).success).toBe(false);
expect(
RunEventPayloadSchemas["review.batch_recorded"].safeParse({
attempt: 0,
reviewerRole: "reviewer",
}).success,
).toBe(false);
});
it("binds exported RunEvent validation to each event type payload schema", () => {
expect(
RunEvent.safeParse({
type: "session.ready",
payload: {},
}).success,
).toBe(false);
expect(
RunEvent.safeParse({
type: "session.ready",
payload: {
recoveryAttempts: 0,
roleId: "implementer",
sessionId: "00000000-0000-4000-8000-000000000000",
},
}).success,
).toBe(true);
});
});

View File

@@ -1,187 +0,0 @@
import { z } from "zod";
import { ApprovalDecisionAction } from "./enums.js";
export const RunEventTypeValues = [
"run.created",
"run.started",
"run.paused",
"run.resumed",
"run.completed",
"run.failed",
"run.aborted",
"phase.started",
"phase.completed",
"phase.failed",
"phase.skipped",
"prompt.sent",
"prompt.repaired",
"artifact.expected",
"artifact.validated",
"artifact.invalid",
"artifact.timeout",
"approval.requested",
"approval.resolved",
"session.created",
"session.ready",
"session.busy",
"session.idle",
"session.crashed",
"session.recovered",
"session.failed",
"command.started",
"command.completed",
"command.failed",
"review.batch_recorded",
"finding.verifier_resolved",
"backtest.iteration_started",
"backtest.iteration_completed",
"backtest.objective_evaluated",
] as const;
export const RunEventType = z.enum(RunEventTypeValues);
export type RunEventType = z.infer<typeof RunEventType>;
const uuid = z.string().uuid();
const sha256 = z.string().regex(/^[a-f0-9]{64}$/);
const nonEmptyString = z.string().min(1);
const phaseAttempt = z.number().int().positive();
const looseObject = z.object({}).passthrough();
const phasePayload = z
.object({
phaseKey: nonEmptyString,
attempt: phaseAttempt,
})
.passthrough();
const promptPayload = z
.object({
roleId: nonEmptyString,
dedupKey: sha256,
})
.passthrough();
const artifactWaitPayload = z
.object({
path: nonEmptyString,
schemaId: nonEmptyString,
attempt: phaseAttempt,
})
.passthrough();
const artifactValidationPayload = z
.object({
artifactId: uuid,
hash: sha256,
path: nonEmptyString,
schemaId: nonEmptyString,
})
.passthrough();
const sessionBasePayload = z
.object({
sessionId: uuid,
roleId: nonEmptyString,
})
.passthrough();
const sessionPromptPayload = sessionBasePayload.extend({
dedupKey: sha256,
});
const sessionRecoveryPayload = sessionBasePayload.extend({
recoveryAttempts: z.number().int().nonnegative(),
});
const approvalRequestedPayload = z
.object({
approvalRequestId: uuid,
approvalIdempotencyKey: nonEmptyString,
gateKey: nonEmptyString,
})
.passthrough();
const approvalResolvedPayload = z
.object({
action: ApprovalDecisionAction,
approvalRequestId: uuid,
})
.passthrough();
const commandPayload = z
.object({
commandId: uuid,
})
.passthrough();
const findingVerifierResolvedPayload = z
.object({
findingId: uuid,
})
.passthrough();
const backtestIterationPayload = z
.object({
iterationId: uuid,
})
.passthrough();
const reviewBatchRecordedPayload = z
.object({
reviewerRole: nonEmptyString,
attempt: phaseAttempt,
})
.passthrough();
const runPausedPayload = z
.object({
cause: nonEmptyString,
pausedFromState: nonEmptyString,
})
.passthrough();
export const RunEventPayloadSchemas = Object.freeze({
"run.created": looseObject,
"run.started": looseObject,
"run.paused": runPausedPayload,
"run.resumed": runPausedPayload.pick({ cause: true }).passthrough(),
"run.completed": looseObject,
"run.failed": looseObject,
"run.aborted": looseObject,
"phase.started": phasePayload,
"phase.completed": phasePayload,
"phase.failed": phasePayload.extend({ reason: nonEmptyString.optional() }),
"phase.skipped": phasePayload,
"prompt.sent": promptPayload,
"prompt.repaired": promptPayload,
"artifact.expected": artifactWaitPayload,
"artifact.validated": artifactValidationPayload,
"artifact.invalid": artifactValidationPayload.extend({ errors: z.array(z.unknown()) }),
"artifact.timeout": artifactWaitPayload,
"approval.requested": approvalRequestedPayload,
"approval.resolved": approvalResolvedPayload,
"session.created": sessionBasePayload.extend({ backend: nonEmptyString }),
"session.ready": sessionRecoveryPayload,
"session.busy": sessionPromptPayload,
"session.idle": sessionPromptPayload,
"session.crashed": sessionRecoveryPayload,
"session.recovered": sessionRecoveryPayload,
"session.failed": sessionBasePayload,
"command.started": commandPayload,
"command.completed": commandPayload,
"command.failed": commandPayload,
"review.batch_recorded": reviewBatchRecordedPayload,
"finding.verifier_resolved": findingVerifierResolvedPayload,
"backtest.iteration_started": backtestIterationPayload,
"backtest.iteration_completed": backtestIterationPayload,
"backtest.objective_evaluated": backtestIterationPayload,
} satisfies Record<RunEventType, z.ZodTypeAny>) as Readonly<Record<RunEventType, z.ZodTypeAny>>;
export const RunEvent = z
.object({
type: RunEventType,
payload: z.unknown(),
})
.superRefine((event, ctx) => {
const payload = RunEventPayloadSchemas[event.type].safeParse(event.payload);
if (payload.success) {
return;
}
for (const issue of payload.error.issues) {
ctx.addIssue({
...issue,
path: ["payload", ...issue.path],
});
}
});
export type RunEvent = z.infer<typeof RunEvent>;

View File

@@ -1,82 +0,0 @@
import { describe, expect, it } from "vitest";
import { Template, templateHash } from "./template.js";
describe("template schema", () => {
it("rejects duplicate role ids", () => {
expect(() =>
Template.parse({
name: "development",
version: 1,
roles: [
{ id: "implementer", requiredCapabilities: ["code_edit"] },
{ id: "implementer", requiredCapabilities: ["code_review"] },
],
phases: [],
}),
).toThrow(/Duplicate role id/);
});
it("rejects duplicate phase keys and unknown phase roles", () => {
expect(() =>
Template.parse({
name: "development",
version: 1,
roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }],
phases: [
{ key: "spec", title: "Spec", risk: "low", roles: ["implementer"] },
{ key: "spec", title: "Spec again", risk: "low", roles: ["missing"] },
],
}),
).toThrow(/Duplicate phase key|Unknown phase role/);
});
it("hashes schema-accepted templates with explicit undefined optional fields", () => {
const parsed = Template.parse({
name: "development",
version: 1,
roles: [
{
id: "implementer",
requiredCapabilities: ["code_edit"],
diversity: undefined,
},
],
phases: [
{
key: "spec",
title: "Spec",
risk: "low",
roles: ["implementer"],
expectedArtifact: undefined,
timeoutMs: undefined,
},
],
});
expect(templateHash(parsed)).toMatch(/^[a-f0-9]{64}$/);
});
it("treats explicit false diversity as the same hash as omitted diversity", () => {
const withoutDiversity = Template.parse({
name: "development",
version: 1,
roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }],
phases: [{ key: "spec", title: "Spec", risk: "low", roles: ["implementer"] }],
});
const withFalseDiversity = Template.parse({
name: "development",
version: 1,
roles: [
{
id: "implementer",
requiredCapabilities: ["code_edit"],
diversity: { requireDifferentBackends: false },
},
],
phases: [{ key: "spec", title: "Spec", risk: "low", roles: ["implementer"] }],
});
expect(templateHash(withFalseDiversity)).toBe(templateHash(withoutDiversity));
});
});

View File

@@ -1,119 +0,0 @@
import { z } from "zod";
import { Backend, Capability, RiskLevel } from "./enums.js";
import { hash } from "./hash.js";
import { DbIntVersion } from "./version.js";
export const TemplatePhase = z
.object({
key: z.string().min(1),
title: z.string().min(1),
risk: RiskLevel,
roles: z.array(z.string().min(1)),
expectedArtifact: z
.object({
path: z.string().min(1),
schema: z.string().min(1),
})
.strict()
.optional(),
gates: z.array(z.string().min(1)).default([]),
timeoutMs: z.number().int().positive().optional(),
})
.strict();
export const TemplateRole = z
.object({
id: z.string().min(1),
requiredCapabilities: z.array(Capability),
preferredBackends: z.array(Backend).default([]),
count: z.number().int().min(1).default(1),
diversity: z
.object({
requireDifferentBackends: z.boolean().default(false),
})
.strict()
.optional(),
})
.strict();
export const Template = z
.object({
name: z.string().min(1),
version: DbIntVersion,
roles: z.array(TemplateRole),
phases: z.array(TemplatePhase),
defaultGates: z.array(z.string().min(1)).default([]),
})
.strict()
.superRefine((template, context) => {
const roleIds = new Set<string>();
for (const [index, role] of template.roles.entries()) {
if (roleIds.has(role.id)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate role id ${role.id}`,
path: ["roles", index, "id"],
});
}
roleIds.add(role.id);
}
const phaseKeys = new Set<string>();
for (const [index, phase] of template.phases.entries()) {
if (phaseKeys.has(phase.key)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate phase key ${phase.key}`,
path: ["phases", index, "key"],
});
}
phaseKeys.add(phase.key);
for (const [roleIndex, roleId] of phase.roles.entries()) {
if (!roleIds.has(roleId)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: `Unknown phase role ${roleId}`,
path: ["phases", index, "roles", roleIndex],
});
}
}
}
});
export type TemplatePhase = z.infer<typeof TemplatePhase>;
export type TemplateRole = z.infer<typeof TemplateRole>;
export type Template = z.infer<typeof Template>;
export function templateHash(template: Template | undefined): string {
if (!template) {
throw new TypeError("template is required");
}
return hash({
name: template.name,
version: template.version,
roles: template.roles.map((role) => ({
id: role.id,
requiredCapabilities: role.requiredCapabilities,
preferredBackends: role.preferredBackends,
count: role.count,
...(role.diversity?.requireDifferentBackends === true ? { diversity: role.diversity } : {}),
})),
phases: template.phases.map((phase) => ({
key: phase.key,
title: phase.title,
risk: phase.risk,
roles: phase.roles,
gates: phase.gates,
...(phase.expectedArtifact === undefined ? {} : { expectedArtifact: phase.expectedArtifact }),
...(phase.timeoutMs === undefined ? {} : { timeoutMs: phase.timeoutMs }),
})),
gates: template.defaultGates,
capabilitiesRequired: template.roles.map((role) => ({
roleId: role.id,
requiredCapabilities: role.requiredCapabilities,
})),
});
}

View File

@@ -1,3 +0,0 @@
import { z } from "zod";
export const DbIntVersion = z.number().int().positive().max(2_147_483_647);

View File

@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": true,
"noEmit": false
},
"exclude": ["src/**/*.test.ts"]
}

View File

@@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node", "vitest"]
},
"include": ["src/**/*.ts"]
}

View File

@@ -1,19 +0,0 @@
{
"name": "@devflow/db",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --clean && tsc -p tsconfig.build.json",
"typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit",
"test": "cd ../.. && vitest run --project packages/db"
},
"dependencies": {
"@devflow/core": "workspace:*",
"drizzle-orm": "0.45.2",
"pg": "8.20.0"
}
}

View File

@@ -1,18 +0,0 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema/index.js";
export type DbClient = ReturnType<typeof createDbClient>;
export function createDbClient(databaseUrl: string) {
const pool = new Pool({ connectionString: databaseUrl });
return {
db: drizzle(pool, { schema }),
pool,
async close() {
await pool.end();
},
};
}

View File

@@ -1,4 +0,0 @@
export { createDbClient, type DbClient } from "./client.js";
export * from "./repositories/run-event.js";
export * from "./repositories/transcript.js";
export * from "./schema/index.js";

View File

@@ -1,223 +0,0 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
--> statement-breakpoint
CREATE TABLE "agent_personas" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"version" integer NOT NULL,
"hash" text NOT NULL,
"definition" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "approval_decisions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"approval_request_id" uuid NOT NULL,
"action" text NOT NULL,
"comment" text,
"decided_at" timestamp with time zone DEFAULT now() NOT NULL,
"idempotency_key" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "approval_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"run_id" uuid NOT NULL,
"phase_id" uuid,
"gate_key" text NOT NULL,
"state" text NOT NULL,
"idempotency_key" text NOT NULL,
"payload" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"resolved_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "artifacts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"run_id" uuid NOT NULL,
"phase_id" uuid,
"path" text NOT NULL,
"schema_id" text NOT NULL,
"hash" text NOT NULL,
"valid" boolean NOT NULL,
"validation_error" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "backtest_iterations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"run_id" uuid NOT NULL,
"payload" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "backtest_metrics" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"run_id" uuid NOT NULL,
"payload" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "commands" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"run_id" uuid NOT NULL,
"phase_id" uuid,
"kind" text NOT NULL,
"argv" text[] NOT NULL,
"cwd" text NOT NULL,
"exit_code" integer,
"stdout_path" text,
"stderr_path" text,
"started_at" timestamp with time zone,
"ended_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "review_findings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"run_id" uuid NOT NULL,
"phase_id" uuid,
"reviewer_role" text NOT NULL,
"severity" text NOT NULL,
"category" text NOT NULL,
"file_path" text,
"line" integer,
"summary" text NOT NULL,
"evidence" text,
"verifier_status" text DEFAULT 'unverified' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "run_bindings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"run_id" uuid NOT NULL,
"role_id" text NOT NULL,
"persona_id" uuid NOT NULL,
"persona_hash" text NOT NULL,
"backend" text NOT NULL,
"binding_hash" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "run_events" (
"id" bigserial PRIMARY KEY NOT NULL,
"run_id" uuid NOT NULL,
"phase_id" uuid,
"seq" bigint NOT NULL,
"type" text NOT NULL,
"payload" jsonb NOT NULL,
"idempotency_key" text NOT NULL,
"ts" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "run_inputs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"run_id" uuid NOT NULL,
"requirements_md" text NOT NULL,
"objective" jsonb,
"extra" jsonb,
"input_hash" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "run_phases" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"run_id" uuid NOT NULL,
"phase_key" text NOT NULL,
"seq" integer NOT NULL,
"state" text NOT NULL,
"attempts" integer DEFAULT 0 NOT NULL,
"started_at" timestamp with time zone,
"ended_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"template_id" uuid NOT NULL,
"template_hash" text NOT NULL,
"state" text NOT NULL,
"repo_path" text NOT NULL,
"base_branch" text NOT NULL,
"worktree_root" text NOT NULL,
"current_phase_id" uuid,
"started_at" timestamp with time zone,
"ended_at" timestamp with time zone,
"final_report_path" text,
"paused_from_state" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "tui_sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"run_id" uuid NOT NULL,
"role_id" text NOT NULL,
"backend" text NOT NULL,
"cwd" text NOT NULL,
"expected_artifact_path" text,
"expected_schema" text,
"last_prompt_hash" text,
"last_prompt_at" timestamp with time zone,
"last_capture_seq" bigint DEFAULT 0 NOT NULL,
"last_known_pane_pid" integer,
"tmux_session" text,
"tmux_window" text,
"state" text NOT NULL,
"recovery_attempts" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tui_transcript_chunks" (
"id" bigserial PRIMARY KEY NOT NULL,
"session_id" uuid NOT NULL,
"seq" bigint NOT NULL,
"content" text NOT NULL,
"captured_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "workflow_templates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"version" integer NOT NULL,
"hash" text NOT NULL,
"definition" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "approval_decisions" ADD CONSTRAINT "approval_decisions_approval_request_id_approval_requests_id_fk" FOREIGN KEY ("approval_request_id") REFERENCES "public"."approval_requests"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "approval_requests" ADD CONSTRAINT "approval_requests_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "approval_requests" ADD CONSTRAINT "approval_requests_phase_id_run_phases_id_fk" FOREIGN KEY ("phase_id") REFERENCES "public"."run_phases"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "artifacts" ADD CONSTRAINT "artifacts_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "artifacts" ADD CONSTRAINT "artifacts_phase_id_run_phases_id_fk" FOREIGN KEY ("phase_id") REFERENCES "public"."run_phases"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "backtest_iterations" ADD CONSTRAINT "backtest_iterations_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "backtest_metrics" ADD CONSTRAINT "backtest_metrics_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "commands" ADD CONSTRAINT "commands_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "commands" ADD CONSTRAINT "commands_phase_id_run_phases_id_fk" FOREIGN KEY ("phase_id") REFERENCES "public"."run_phases"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "review_findings" ADD CONSTRAINT "review_findings_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "review_findings" ADD CONSTRAINT "review_findings_phase_id_run_phases_id_fk" FOREIGN KEY ("phase_id") REFERENCES "public"."run_phases"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "run_bindings" ADD CONSTRAINT "run_bindings_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "run_bindings" ADD CONSTRAINT "run_bindings_persona_id_agent_personas_id_fk" FOREIGN KEY ("persona_id") REFERENCES "public"."agent_personas"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "run_events" ADD CONSTRAINT "run_events_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "run_events" ADD CONSTRAINT "run_events_phase_id_run_phases_id_fk" FOREIGN KEY ("phase_id") REFERENCES "public"."run_phases"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "run_inputs" ADD CONSTRAINT "run_inputs_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "run_phases" ADD CONSTRAINT "run_phases_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "runs" ADD CONSTRAINT "runs_template_id_workflow_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."workflow_templates"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "runs" ADD CONSTRAINT "runs_current_phase_id_run_phases_id_fk" FOREIGN KEY ("current_phase_id") REFERENCES "public"."run_phases"("id") ON DELETE no action ON UPDATE no action DEFERRABLE INITIALLY DEFERRED;--> statement-breakpoint
ALTER TABLE "tui_sessions" ADD CONSTRAINT "tui_sessions_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tui_transcript_chunks" ADD CONSTRAINT "tui_transcript_chunks_session_id_tui_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."tui_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "agent_personas_hash_unique" ON "agent_personas" USING btree ("hash");--> statement-breakpoint
CREATE UNIQUE INDEX "agent_personas_name_version_unique" ON "agent_personas" USING btree ("name","version");--> statement-breakpoint
CREATE UNIQUE INDEX "approval_decisions_idempotency_key_unique" ON "approval_decisions" USING btree ("idempotency_key");--> statement-breakpoint
CREATE UNIQUE INDEX "approval_requests_idempotency_key_unique" ON "approval_requests" USING btree ("idempotency_key");--> statement-breakpoint
CREATE UNIQUE INDEX "artifacts_run_id_path_hash_unique" ON "artifacts" USING btree ("run_id","path","hash");--> statement-breakpoint
CREATE UNIQUE INDEX "run_bindings_run_id_role_id_unique" ON "run_bindings" USING btree ("run_id","role_id");--> statement-breakpoint
CREATE UNIQUE INDEX "run_events_run_id_seq_unique" ON "run_events" USING btree ("run_id","seq");--> statement-breakpoint
CREATE UNIQUE INDEX "run_events_run_id_idempotency_key_unique" ON "run_events" USING btree ("run_id","idempotency_key");--> statement-breakpoint
CREATE INDEX "run_events_run_id_ts_idx" ON "run_events" USING btree ("run_id","ts");--> statement-breakpoint
CREATE UNIQUE INDEX "run_inputs_run_id_unique" ON "run_inputs" USING btree ("run_id");--> statement-breakpoint
CREATE UNIQUE INDEX "run_phases_run_id_phase_key_unique" ON "run_phases" USING btree ("run_id","phase_key");--> statement-breakpoint
CREATE UNIQUE INDEX "ux_active_run_repo_base" ON "runs" USING btree ("repo_path","base_branch") WHERE "runs"."state" NOT IN ('completed', 'failed', 'aborted');--> statement-breakpoint
CREATE UNIQUE INDEX "tui_sessions_run_id_role_id_unique" ON "tui_sessions" USING btree ("run_id","role_id");--> statement-breakpoint
CREATE UNIQUE INDEX "tui_transcript_chunks_session_id_seq_unique" ON "tui_transcript_chunks" USING btree ("session_id","seq");--> statement-breakpoint
CREATE UNIQUE INDEX "workflow_templates_hash_unique" ON "workflow_templates" USING btree ("hash");--> statement-breakpoint
CREATE UNIQUE INDEX "workflow_templates_name_version_unique" ON "workflow_templates" USING btree ("name","version");

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1778333151192,
"tag": "0000_robust_the_phantom",
"breakpoints": true
}
]
}

View File

@@ -1,34 +0,0 @@
import { readFileSync, readdirSync } from "node:fs";
import { describe, expect, it } from "vitest";
function readInitialMigration(): string {
const migrationDir = new URL(".", import.meta.url);
const [migrationFile] = readdirSync(migrationDir)
.filter((fileName) => fileName.endsWith(".sql"))
.sort();
if (!migrationFile) {
throw new Error("No SQL migration file found");
}
return readFileSync(new URL(migrationFile, migrationDir), "utf8");
}
describe("initial migration", () => {
it("contains the locked M1 database prelude and active-run index", () => {
const sql = readInitialMigration();
expect(sql).toContain("CREATE EXTENSION IF NOT EXISTS pgcrypto;");
expect(sql).toContain('CREATE UNIQUE INDEX "ux_active_run_repo_base"');
expect(sql).toContain("WHERE \"runs\".\"state\" NOT IN ('completed', 'failed', 'aborted')");
});
it("links runs.current_phase_id to run_phases with a deferrable foreign key", () => {
const sql = readInitialMigration();
expect(sql).toContain(
'ALTER TABLE "runs" ADD CONSTRAINT "runs_current_phase_id_run_phases_id_fk"',
);
expect(sql).toContain("DEFERRABLE INITIALLY DEFERRED");
});
});

View File

@@ -1,278 +0,0 @@
import {
DevflowError,
RunEventPayloadSchemas,
RunEventType,
type RunEventType as RunEventTypeName,
canonicalize,
} from "@devflow/core";
import { and, desc, eq, sql } from "drizzle-orm";
import type { DbClient } from "../client.js";
import { runEvents, runPhases } from "../schema/index.js";
type Database = DbClient["db"];
type TransactionDatabase = Parameters<Parameters<Database["transaction"]>[0]>[0];
export interface AppendRunEventInput {
runId: string;
phaseId?: string;
type: RunEventTypeName;
payload: Record<string, unknown>;
idempotencyKey: string;
}
export interface RunEventRow {
id: bigint;
runId: string;
phaseId: string | null;
seq: bigint;
type: string;
payload: unknown;
idempotencyKey: string;
ts: Date;
}
export class RunEventRepository {
constructor(private readonly db: Database) {}
async append(input: AppendRunEventInput): Promise<RunEventRow> {
return this.db.transaction(async (tx) => this.appendInTransaction(tx, input));
}
async appendInTransaction(
tx: TransactionDatabase,
input: AppendRunEventInput,
): Promise<RunEventRow> {
const type = RunEventType.parse(input.type);
const payload = RunEventPayloadSchemas[type].parse(input.payload) as Record<string, unknown>;
if (isPhaseScopedEvent(type) && input.phaseId === undefined) {
throw new DevflowError("Run event phase id is required for phase-scoped event", {
class: "fatal",
code: "internal_state_corruption",
runId: input.runId,
});
}
if (input.idempotencyKey.length === 0) {
throw new DevflowError("Run event idempotency key is required", {
class: "fatal",
code: "internal_state_corruption",
runId: input.runId,
...(input.phaseId === undefined ? {} : { phaseId: input.phaseId }),
});
}
const expectedIdempotencyKey = expectedRunEventIdempotencyKey(input, type, payload);
if (expectedIdempotencyKey !== undefined && input.idempotencyKey !== expectedIdempotencyKey) {
throw new DevflowError("Run event idempotency key does not match event contract", {
class: "fatal",
code: "internal_state_corruption",
runId: input.runId,
...(input.phaseId === undefined ? {} : { phaseId: input.phaseId }),
});
}
await tx.execute(
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`devflow:run-events:${input.runId}`}, 0))`,
);
if (input.phaseId !== undefined) {
const [phase] = await tx
.select({ id: runPhases.id })
.from(runPhases)
.where(and(eq(runPhases.id, input.phaseId), eq(runPhases.runId, input.runId)))
.limit(1);
if (phase === undefined) {
throw new DevflowError("Run event phase does not belong to run", {
class: "fatal",
code: "internal_state_corruption",
runId: input.runId,
phaseId: input.phaseId,
});
}
}
const existing = await tx
.select()
.from(runEvents)
.where(
and(eq(runEvents.runId, input.runId), eq(runEvents.idempotencyKey, input.idempotencyKey)),
)
.limit(1);
if (existing[0] !== undefined) {
assertIdempotentReplayMatches(input, type, payload, existing[0]);
return existing[0];
}
const latest = await tx
.select({ seq: runEvents.seq })
.from(runEvents)
.where(eq(runEvents.runId, input.runId))
.orderBy(desc(runEvents.seq))
.limit(1);
const seq = (latest[0]?.seq ?? 0n) + 1n;
const inserted = await tx
.insert(runEvents)
.values({
runId: input.runId,
phaseId: input.phaseId,
seq,
type,
payload,
idempotencyKey: input.idempotencyKey,
})
.returning();
const event = inserted[0];
if (event === undefined) {
throw new DevflowError("Run event insert returned no row", {
class: "fatal",
code: "internal_state_corruption",
runId: input.runId,
...(input.phaseId === undefined ? {} : { phaseId: input.phaseId }),
});
}
return event;
}
}
function isPhaseScopedEvent(type: RunEventTypeName): boolean {
return (
type.startsWith("phase.") || type.startsWith("artifact.") || type === "review.batch_recorded"
);
}
function assertIdempotentReplayMatches(
input: AppendRunEventInput,
type: RunEventTypeName,
payload: Record<string, unknown>,
existing: RunEventRow,
) {
const sameType = existing.type === type;
const samePhase = !isPhaseScopedEvent(type) || existing.phaseId === (input.phaseId ?? null);
const samePayload = canonicalize(normalizeJson(existing.payload)) === canonicalize(payload);
if (sameType && samePhase && samePayload) {
return;
}
throw new DevflowError("Run event idempotency key replay does not match existing event", {
class: "fatal",
code: "internal_state_corruption",
runId: input.runId,
...(input.phaseId === undefined ? {} : { phaseId: input.phaseId }),
});
}
function expectedRunEventIdempotencyKey(
input: AppendRunEventInput,
type: RunEventTypeName,
payload: Record<string, unknown>,
): string | undefined {
switch (type) {
case "run.created":
case "run.started":
case "run.completed":
case "run.failed":
case "run.aborted":
return `${type}:${input.runId}`;
case "run.paused":
case "run.resumed":
return `${type}:${input.runId}:${stringPayload(payload, "cause")}`;
case "phase.started":
case "phase.completed":
case "phase.failed":
case "phase.skipped":
return `${type}:${requiredPhaseId(input)}:${numberPayload(payload, "attempt")}`;
case "prompt.sent":
case "prompt.repaired":
return `${type}:${stringPayload(payload, "dedupKey")}`;
case "artifact.expected":
case "artifact.timeout":
return `${type}:${requiredPhaseId(input)}:${numberPayload(payload, "attempt")}:${stringPayload(payload, "path")}`;
case "artifact.validated":
case "artifact.invalid":
return `${type}:${requiredPhaseId(input)}:${stringPayload(payload, "path")}:${stringPayload(payload, "hash")}`;
case "approval.requested":
return `approval.requested:${stringPayload(payload, "approvalIdempotencyKey")}`;
case "approval.resolved":
return `approval.resolved:${stringPayload(payload, "approvalRequestId")}:${stringPayload(payload, "action")}`;
case "session.created":
case "session.failed":
return `${type}:${stringPayload(payload, "sessionId")}`;
case "session.busy":
case "session.idle":
return `${type}:${stringPayload(payload, "sessionId")}:${stringPayload(payload, "dedupKey")}`;
case "session.ready":
case "session.crashed":
case "session.recovered":
return `${type}:${stringPayload(payload, "sessionId")}:${numberPayload(payload, "recoveryAttempts")}`;
case "command.started":
case "command.completed":
case "command.failed":
return `${type}:${stringPayload(payload, "commandId")}`;
case "review.batch_recorded":
return `review.batch_recorded:${requiredPhaseId(input)}:${stringPayload(payload, "reviewerRole")}:${numberPayload(payload, "attempt")}`;
case "finding.verifier_resolved":
return `finding.verifier_resolved:${stringPayload(payload, "findingId")}`;
case "backtest.iteration_started":
case "backtest.iteration_completed":
case "backtest.objective_evaluated":
return `${type}:${stringPayload(payload, "iterationId")}`;
default:
return undefined;
}
}
function requiredPhaseId(input: AppendRunEventInput): string {
if (input.phaseId === undefined) {
throw new DevflowError("Run event phase id is required for idempotency key", {
class: "fatal",
code: "internal_state_corruption",
runId: input.runId,
});
}
return input.phaseId;
}
function stringPayload(payload: Record<string, unknown>, key: string): string {
const value = payload[key];
if (typeof value !== "string" || value.length === 0) {
throw new DevflowError(`Run event payload is missing string field ${key}`, {
class: "fatal",
code: "internal_state_corruption",
});
}
return value;
}
function numberPayload(payload: Record<string, unknown>, key: string): number {
const value = payload[key];
if (typeof value !== "number" || !Number.isInteger(value)) {
throw new DevflowError(`Run event payload is missing integer field ${key}`, {
class: "fatal",
code: "internal_state_corruption",
});
}
return value;
}
function normalizeJson(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => normalizeJson(item));
}
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, child]) => [
key,
normalizeJson(child),
]),
);
}
return value;
}

View File

@@ -1,166 +0,0 @@
import { randomUUID } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import { afterEach, describe, expect, it } from "vitest";
import { type DbClient, createDbClient } from "../client.js";
import { runs, tuiSessions, tuiTranscriptChunks, workflowTemplates } from "../schema/index.js";
import { TuiTranscriptRepository } from "./transcript.js";
const testDatabaseUrl =
process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow";
describe("TuiTranscriptRepository", () => {
let client: DbClient | undefined;
const runIds: string[] = [];
const templateIds: string[] = [];
afterEach(async () => {
if (client === undefined) {
return;
}
if (runIds.length > 0) {
await client.db.delete(runs).where(inArray(runs.id, [...runIds]));
}
if (templateIds.length > 0) {
await client.db
.delete(workflowTemplates)
.where(inArray(workflowTemplates.id, [...templateIds]));
}
runIds.length = 0;
templateIds.length = 0;
await client.close();
client = undefined;
});
async function createSession() {
client = createDbClient(testDatabaseUrl);
const templateId = randomUUID();
const runId = randomUUID();
const sessionId = randomUUID();
templateIds.push(templateId);
runIds.push(runId);
await client.db.insert(workflowTemplates).values({
id: templateId,
name: `template-${templateId}`,
version: 1,
hash: `hash-${templateId}`,
definition: {},
});
await client.db.insert(runs).values({
id: runId,
templateId,
templateHash: `hash-${templateId}`,
state: "executing",
repoPath: `/tmp/devflow-${runId}`,
baseBranch: "main",
worktreeRoot: `/tmp/devflow-${runId}/main`,
});
await client.db.insert(tuiSessions).values({
id: sessionId,
runId,
roleId: "implementer",
backend: "fake",
cwd: `/tmp/devflow-${runId}/main`,
state: "READY",
});
return { db: client.db, sessionId };
}
it("appends transcript chunks idempotently and advances last_capture_seq", async () => {
const { db, sessionId } = await createSession();
const repository = new TuiTranscriptRepository(db);
const firstAt = new Date("2026-05-09T00:00:00.000Z");
const secondAt = new Date("2026-05-09T00:00:01.000Z");
await repository.append(sessionId, [
{ seq: 1n, content: "first", capturedAt: firstAt },
{ seq: 2n, content: "second", capturedAt: secondAt },
]);
await repository.append(sessionId, [
{ seq: 2n, content: "second", capturedAt: secondAt },
{ seq: 3n, content: "third", capturedAt: new Date("2026-05-09T00:00:02.000Z") },
]);
const rows = await db
.select({
seq: tuiTranscriptChunks.seq,
content: tuiTranscriptChunks.content,
})
.from(tuiTranscriptChunks)
.where(eq(tuiTranscriptChunks.sessionId, sessionId))
.orderBy(tuiTranscriptChunks.seq);
expect(rows).toEqual([
{ seq: 1n, content: "first" },
{ seq: 2n, content: "second" },
{ seq: 3n, content: "third" },
]);
const [session] = await db
.select({ lastCaptureSeq: tuiSessions.lastCaptureSeq })
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionId));
expect(session?.lastCaptureSeq).toBe(3n);
});
it("rejects conflicting content for an existing transcript sequence", async () => {
const { db, sessionId } = await createSession();
const repository = new TuiTranscriptRepository(db);
const capturedAt = new Date("2026-05-09T00:00:00.000Z");
await repository.append(sessionId, [{ seq: 1n, content: "first", capturedAt }]);
await expect(
repository.append(sessionId, [{ seq: 1n, content: "different", capturedAt }]),
).rejects.toMatchObject({ code: "transcript_seq_conflict" });
const rows = await db
.select()
.from(tuiTranscriptChunks)
.where(and(eq(tuiTranscriptChunks.sessionId, sessionId), eq(tuiTranscriptChunks.seq, 1n)));
expect(rows).toHaveLength(1);
expect(rows?.[0]?.content).toBe("first");
});
it("rejects sequence gaps before advancing last_capture_seq", async () => {
const { db, sessionId } = await createSession();
const repository = new TuiTranscriptRepository(db);
await expect(
repository.append(sessionId, [
{ seq: 2n, content: "second", capturedAt: new Date("2026-05-09T00:00:01.000Z") },
]),
).rejects.toMatchObject({ code: "transcript_sequence_gap" });
const [session] = await db
.select({ lastCaptureSeq: tuiSessions.lastCaptureSeq })
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionId));
expect(session?.lastCaptureSeq).toBe(0n);
});
it("surfaces concurrent conflicting content for the same transcript sequence", async () => {
const { db, sessionId } = await createSession();
const firstRepository = new TuiTranscriptRepository(db);
const secondRepository = new TuiTranscriptRepository(db);
const capturedAt = new Date("2026-05-09T00:00:00.000Z");
const results = await Promise.allSettled([
firstRepository.append(sessionId, [{ seq: 1n, content: "first", capturedAt }]),
secondRepository.append(sessionId, [{ seq: 1n, content: "different", capturedAt }]),
]);
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
const rejected = results.find((result) => result.status === "rejected");
expect(rejected).toMatchObject({
reason: expect.objectContaining({ code: "transcript_seq_conflict" }),
});
const rows = await db
.select()
.from(tuiTranscriptChunks)
.where(eq(tuiTranscriptChunks.sessionId, sessionId));
expect(rows).toHaveLength(1);
});
});

View File

@@ -1,139 +0,0 @@
import { DevflowError } from "@devflow/core";
import { and, eq, inArray, sql } from "drizzle-orm";
import type { DbClient } from "../client.js";
import { tuiSessions, tuiTranscriptChunks } from "../schema/index.js";
export interface TranscriptChunkInput {
seq: bigint;
content: string;
capturedAt: Date;
}
export interface AppendTranscriptResult {
received: number;
inserted: number;
lastSeq: bigint | undefined;
}
type Database = DbClient["db"];
export class TuiTranscriptRepository {
constructor(private readonly db: Database) {}
async append(
sessionId: string,
chunks: readonly TranscriptChunkInput[],
): Promise<AppendTranscriptResult> {
if (chunks.length === 0) {
return { received: 0, inserted: 0, lastSeq: undefined };
}
const normalized = normalizeChunks(chunks);
return this.db.transaction(async (tx) => {
await tx.execute(
sql`SELECT pg_advisory_xact_lock(hashtext(${`devflow:tui-transcript:${sessionId}`}))`,
);
const [session] = await tx
.select({ lastCaptureSeq: tuiSessions.lastCaptureSeq })
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionId));
if (session === undefined) {
throw new DevflowError("TUI session does not exist", {
class: "fatal",
code: "session_not_found",
});
}
const insertedRows = await tx
.insert(tuiTranscriptChunks)
.values(
normalized.map((chunk) => ({
sessionId,
seq: chunk.seq,
content: chunk.content,
capturedAt: chunk.capturedAt,
})),
)
.onConflictDoNothing({
target: [tuiTranscriptChunks.sessionId, tuiTranscriptChunks.seq],
})
.returning({ seq: tuiTranscriptChunks.seq });
const seqs = normalized.map((chunk) => chunk.seq);
const persistedRows = await tx
.select({
seq: tuiTranscriptChunks.seq,
content: tuiTranscriptChunks.content,
})
.from(tuiTranscriptChunks)
.where(
and(eq(tuiTranscriptChunks.sessionId, sessionId), inArray(tuiTranscriptChunks.seq, seqs)),
);
const persistedBySeq = new Map(persistedRows.map((row) => [row.seq, row.content]));
for (const chunk of normalized) {
const persisted = persistedBySeq.get(chunk.seq);
if (persisted !== chunk.content) {
throw new DevflowError("Transcript sequence already exists with different content", {
class: "fatal",
code: "transcript_seq_conflict",
});
}
}
const lastSeq = normalized.at(-1)?.seq;
const nextCaptureSeq = advanceContiguousCursor(session.lastCaptureSeq, normalized);
await tx
.update(tuiSessions)
.set({
lastCaptureSeq: nextCaptureSeq,
})
.where(eq(tuiSessions.id, sessionId));
return { received: chunks.length, inserted: insertedRows.length, lastSeq };
});
}
}
function normalizeChunks(chunks: readonly TranscriptChunkInput[]): TranscriptChunkInput[] {
const bySeq = new Map<bigint, TranscriptChunkInput>();
for (const chunk of chunks) {
if (chunk.seq <= 0n) {
throw new DevflowError("Transcript sequence must be positive", {
class: "fatal",
code: "transcript_sequence_invalid",
});
}
const existing = bySeq.get(chunk.seq);
if (existing !== undefined && existing.content !== chunk.content) {
throw new DevflowError("Duplicate transcript sequence has conflicting content", {
class: "fatal",
code: "transcript_seq_conflict",
});
}
bySeq.set(chunk.seq, chunk);
}
return [...bySeq.values()].sort((left, right) => Number(left.seq - right.seq));
}
function advanceContiguousCursor(current: bigint, chunks: readonly TranscriptChunkInput[]): bigint {
let cursor = current;
for (const chunk of chunks) {
if (chunk.seq <= cursor) {
continue;
}
if (chunk.seq !== cursor + 1n) {
throw new DevflowError("Transcript sequence cannot skip the capture cursor", {
class: "fatal",
code: "transcript_sequence_gap",
});
}
cursor = chunk.seq;
}
return cursor;
}

View File

@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { schemaTableNames } from "./index.js";
describe("database schema", () => {
it("exports every M1 table from the implementation plan", () => {
expect(schemaTableNames).toEqual([
"agent_personas",
"approval_decisions",
"approval_requests",
"artifacts",
"backtest_iterations",
"backtest_metrics",
"commands",
"review_findings",
"run_bindings",
"run_events",
"run_inputs",
"run_phases",
"runs",
"tui_sessions",
"tui_transcript_chunks",
"workflow_templates",
]);
});
});

View File

@@ -1,321 +0,0 @@
import { sql } from "drizzle-orm";
import {
bigint,
bigserial,
boolean,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
function createdAtColumn() {
return timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
}
function updatedAtColumn() {
return timestamp("updated_at", { withTimezone: true });
}
export const workflowTemplates = pgTable(
"workflow_templates",
{
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
version: integer("version").notNull(),
hash: text("hash").notNull(),
definition: jsonb("definition").notNull(),
createdAt: createdAtColumn(),
},
(table) => [
uniqueIndex("workflow_templates_hash_unique").on(table.hash),
uniqueIndex("workflow_templates_name_version_unique").on(table.name, table.version),
],
);
export const agentPersonas = pgTable(
"agent_personas",
{
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
version: integer("version").notNull(),
hash: text("hash").notNull(),
definition: jsonb("definition").notNull(),
createdAt: createdAtColumn(),
},
(table) => [
uniqueIndex("agent_personas_hash_unique").on(table.hash),
uniqueIndex("agent_personas_name_version_unique").on(table.name, table.version),
],
);
export const runs = pgTable(
"runs",
{
id: uuid("id").primaryKey().defaultRandom(),
templateId: uuid("template_id")
.notNull()
.references(() => workflowTemplates.id),
templateHash: text("template_hash").notNull(),
state: text("state").notNull(),
repoPath: text("repo_path").notNull(),
baseBranch: text("base_branch").notNull(),
worktreeRoot: text("worktree_root").notNull(),
currentPhaseId: uuid("current_phase_id"),
startedAt: timestamp("started_at", { withTimezone: true }),
endedAt: timestamp("ended_at", { withTimezone: true }),
finalReportPath: text("final_report_path"),
pausedFromState: text("paused_from_state"),
createdAt: createdAtColumn(),
updatedAt: updatedAtColumn(),
},
(table) => [
uniqueIndex("ux_active_run_repo_base")
.on(table.repoPath, table.baseBranch)
.where(sql`${table.state} NOT IN ('completed', 'failed', 'aborted')`),
],
);
export const runInputs = pgTable(
"run_inputs",
{
id: uuid("id").primaryKey().defaultRandom(),
runId: uuid("run_id")
.notNull()
.references(() => runs.id, { onDelete: "cascade" }),
requirementsMd: text("requirements_md").notNull(),
objective: jsonb("objective"),
extra: jsonb("extra"),
inputHash: text("input_hash").notNull(),
createdAt: createdAtColumn(),
},
(table) => [uniqueIndex("run_inputs_run_id_unique").on(table.runId)],
);
export const runBindings = pgTable(
"run_bindings",
{
id: uuid("id").primaryKey().defaultRandom(),
runId: uuid("run_id")
.notNull()
.references(() => runs.id, { onDelete: "cascade" }),
roleId: text("role_id").notNull(),
personaId: uuid("persona_id")
.notNull()
.references(() => agentPersonas.id),
personaHash: text("persona_hash").notNull(),
backend: text("backend").notNull(),
bindingHash: text("binding_hash").notNull(),
createdAt: createdAtColumn(),
},
(table) => [uniqueIndex("run_bindings_run_id_role_id_unique").on(table.runId, table.roleId)],
);
export const runPhases = pgTable(
"run_phases",
{
id: uuid("id").primaryKey().defaultRandom(),
runId: uuid("run_id")
.notNull()
.references(() => runs.id, { onDelete: "cascade" }),
phaseKey: text("phase_key").notNull(),
seq: integer("seq").notNull(),
state: text("state").notNull(),
attempts: integer("attempts").notNull().default(0),
startedAt: timestamp("started_at", { withTimezone: true }),
endedAt: timestamp("ended_at", { withTimezone: true }),
createdAt: createdAtColumn(),
},
(table) => [uniqueIndex("run_phases_run_id_phase_key_unique").on(table.runId, table.phaseKey)],
);
export const runEvents = pgTable(
"run_events",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
runId: uuid("run_id")
.notNull()
.references(() => runs.id, { onDelete: "cascade" }),
phaseId: uuid("phase_id").references(() => runPhases.id),
seq: bigint("seq", { mode: "bigint" }).notNull(),
type: text("type").notNull(),
payload: jsonb("payload").notNull(),
idempotencyKey: text("idempotency_key").notNull(),
ts: timestamp("ts", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex("run_events_run_id_seq_unique").on(table.runId, table.seq),
uniqueIndex("run_events_run_id_idempotency_key_unique").on(table.runId, table.idempotencyKey),
index("run_events_run_id_ts_idx").on(table.runId, table.ts),
],
);
export const approvalRequests = pgTable(
"approval_requests",
{
id: uuid("id").primaryKey().defaultRandom(),
runId: uuid("run_id")
.notNull()
.references(() => runs.id),
phaseId: uuid("phase_id").references(() => runPhases.id),
gateKey: text("gate_key").notNull(),
state: text("state").notNull(),
idempotencyKey: text("idempotency_key").notNull(),
payload: jsonb("payload").notNull(),
createdAt: createdAtColumn(),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
},
(table) => [uniqueIndex("approval_requests_idempotency_key_unique").on(table.idempotencyKey)],
);
export const approvalDecisions = pgTable(
"approval_decisions",
{
id: uuid("id").primaryKey().defaultRandom(),
approvalRequestId: uuid("approval_request_id")
.notNull()
.references(() => approvalRequests.id),
action: text("action").notNull(),
comment: text("comment"),
decidedAt: timestamp("decided_at", { withTimezone: true }).notNull().defaultNow(),
idempotencyKey: text("idempotency_key").notNull(),
createdAt: createdAtColumn(),
},
(table) => [uniqueIndex("approval_decisions_idempotency_key_unique").on(table.idempotencyKey)],
);
export const tuiSessions = pgTable(
"tui_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
runId: uuid("run_id")
.notNull()
.references(() => runs.id, { onDelete: "cascade" }),
roleId: text("role_id").notNull(),
backend: text("backend").notNull(),
cwd: text("cwd").notNull(),
expectedArtifactPath: text("expected_artifact_path"),
expectedSchema: text("expected_schema"),
lastPromptHash: text("last_prompt_hash"),
lastPromptAt: timestamp("last_prompt_at", { withTimezone: true }),
lastCaptureSeq: bigint("last_capture_seq", { mode: "bigint" }).notNull().default(sql`0`),
lastKnownPanePid: integer("last_known_pane_pid"),
tmuxSession: text("tmux_session"),
tmuxWindow: text("tmux_window"),
state: text("state").notNull(),
recoveryAttempts: integer("recovery_attempts").notNull().default(0),
createdAt: createdAtColumn(),
},
(table) => [uniqueIndex("tui_sessions_run_id_role_id_unique").on(table.runId, table.roleId)],
);
export const tuiTranscriptChunks = pgTable(
"tui_transcript_chunks",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
sessionId: uuid("session_id")
.notNull()
.references(() => tuiSessions.id, { onDelete: "cascade" }),
seq: bigint("seq", { mode: "bigint" }).notNull(),
content: text("content").notNull(),
capturedAt: timestamp("captured_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex("tui_transcript_chunks_session_id_seq_unique").on(table.sessionId, table.seq),
],
);
export const artifacts = pgTable(
"artifacts",
{
id: uuid("id").primaryKey().defaultRandom(),
runId: uuid("run_id")
.notNull()
.references(() => runs.id, { onDelete: "cascade" }),
phaseId: uuid("phase_id").references(() => runPhases.id),
path: text("path").notNull(),
schemaId: text("schema_id").notNull(),
hash: text("hash").notNull(),
valid: boolean("valid").notNull(),
validationError: jsonb("validation_error"),
createdAt: createdAtColumn(),
},
(table) => [
uniqueIndex("artifacts_run_id_path_hash_unique").on(table.runId, table.path, table.hash),
],
);
export const commands = pgTable("commands", {
id: uuid("id").primaryKey().defaultRandom(),
runId: uuid("run_id")
.notNull()
.references(() => runs.id, { onDelete: "cascade" }),
phaseId: uuid("phase_id").references(() => runPhases.id),
kind: text("kind").notNull(),
argv: text("argv").array().notNull(),
cwd: text("cwd").notNull(),
exitCode: integer("exit_code"),
stdoutPath: text("stdout_path"),
stderrPath: text("stderr_path"),
startedAt: timestamp("started_at", { withTimezone: true }),
endedAt: timestamp("ended_at", { withTimezone: true }),
createdAt: createdAtColumn(),
});
export const reviewFindings = pgTable("review_findings", {
id: uuid("id").primaryKey().defaultRandom(),
runId: uuid("run_id")
.notNull()
.references(() => runs.id, { onDelete: "cascade" }),
phaseId: uuid("phase_id").references(() => runPhases.id),
reviewerRole: text("reviewer_role").notNull(),
severity: text("severity").notNull(),
category: text("category").notNull(),
filePath: text("file_path"),
line: integer("line"),
summary: text("summary").notNull(),
evidence: text("evidence"),
verifierStatus: text("verifier_status").notNull().default("unverified"),
createdAt: createdAtColumn(),
});
export const backtestIterations = pgTable("backtest_iterations", {
id: uuid("id").primaryKey().defaultRandom(),
runId: uuid("run_id")
.notNull()
.references(() => runs.id, { onDelete: "cascade" }),
payload: jsonb("payload").notNull(),
createdAt: createdAtColumn(),
});
export const backtestMetrics = pgTable("backtest_metrics", {
id: uuid("id").primaryKey().defaultRandom(),
runId: uuid("run_id")
.notNull()
.references(() => runs.id, { onDelete: "cascade" }),
payload: jsonb("payload").notNull(),
createdAt: createdAtColumn(),
});
export const schemaTableNames = [
"agent_personas",
"approval_decisions",
"approval_requests",
"artifacts",
"backtest_iterations",
"backtest_metrics",
"commands",
"review_findings",
"run_bindings",
"run_events",
"run_inputs",
"run_phases",
"runs",
"tui_sessions",
"tui_transcript_chunks",
"workflow_templates",
] as const;

View File

@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": true,
"noEmit": false
},
"exclude": ["src/**/*.test.ts"]
}

View File

@@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node", "vitest"]
},
"include": ["src/**/*.ts"]
}

View File

@@ -1,20 +0,0 @@
{
"name": "@devflow/run-engine",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --clean && tsc -p tsconfig.build.json",
"typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit",
"test": "cd ../.. && vitest run --project packages/run-engine"
},
"dependencies": {
"@devflow/core": "workspace:*",
"@devflow/db": "workspace:*",
"@devflow/session": "workspace:*",
"drizzle-orm": "0.45.2"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
export * from "./engine.js";
export * from "./fake-phase-harness.js";
export * from "./run-event-repository.js";

View File

@@ -1 +0,0 @@
export { RunEventRepository } from "@devflow/db";

View File

@@ -1,10 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": true,
"noEmit": false
},
"references": [],
"exclude": ["src/**/*.test.ts"]
}

View File

@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node", "vitest"]
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../core" }, { "path": "../db" }, { "path": "../session" }]
}

View File

@@ -1,18 +0,0 @@
{
"name": "@devflow/session",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --clean && tsc -p tsconfig.build.json",
"typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit",
"test": "cd ../.. && vitest run --project packages/session"
},
"dependencies": {
"@devflow/core": "workspace:*",
"@devflow/db": "workspace:*"
}
}

View File

@@ -1,52 +0,0 @@
import type { Backend, PromptEnvelope } from "@devflow/core";
export interface SessionAdapter {
start(input: StartInput): Promise<SessionHandle>;
sendPrompt(handle: SessionHandle, envelope: PromptEnvelope): Promise<{ promptId: string }>;
probe(handle: SessionHandle): Promise<ProbeResult>;
resume(handle: SessionHandle): Promise<SessionHandle>;
rebootstrap(handle: SessionHandle): Promise<SessionHandle>;
capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk>;
dispose(handle: SessionHandle): Promise<void>;
}
export interface StartInput {
sessionId?: string;
runId: string;
roleId: string;
backend: Backend;
cwd: string;
expectedArtifactPath?: string;
expectedSchema?: string;
envelopePrelude?: string;
}
export interface SessionHandle {
sessionId: string;
runId?: string;
roleId?: string;
pid?: number;
tmuxSession?: string;
tmuxWindow?: string;
envelopePrelude?: string;
requirePreludeReplay?: boolean;
transcriptBaseline?: TranscriptBaseline;
}
export interface TranscriptBaseline {
startSeq: bigint;
lines: readonly string[];
}
export interface ProbeResult {
alive: boolean;
paneActive: boolean;
lastOutputAt?: Date;
hint?: string;
}
export interface TranscriptChunk {
seq: bigint;
content: string;
capturedAt: Date;
}

View File

@@ -1,321 +0,0 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { PromptEnvelope } from "@devflow/core";
import { DevflowError } from "@devflow/core";
import { FakeSessionAdapter } from "./fake.js";
const runId = "00000000-0000-4000-8000-000000000001";
const dedupKey = "a".repeat(64);
const secondDedupKey = "b".repeat(64);
function envelope(overrides: Partial<PromptEnvelope> = {}): PromptEnvelope {
return {
uuid: "00000000-0000-4000-8000-000000000010",
runId,
roleId: "implementer",
phaseKey: "implement",
attempt: 0,
expectedArtifact: join(mkdtempSync(join(tmpdir(), "devflow-fake-artifact-")), "artifact.json"),
expectedSchema: "dev/spec@1",
dedupKey,
instructions: "Build the artifact",
...overrides,
};
}
function makeFixtureRoot(): string {
const root = mkdtempSync(join(tmpdir(), "devflow-fake-fixtures-"));
const schemaDir = join(root, "dev", "spec@1");
mkdirSync(schemaDir, { recursive: true });
writeFileSync(
join(schemaDir, "ok.json"),
JSON.stringify({
summary: "Fake spec",
requirements: [{ id: "REQ-1", description: "Write the file" }],
acceptanceCriteria: ["File is written"],
risks: [],
}),
);
return root;
}
async function waitForFile(path: string): Promise<void> {
const deadline = Date.now() + 500;
while (Date.now() < deadline) {
if (existsSync(path)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error(`Timed out waiting for ${path}`);
}
async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> {
const items: T[] = [];
for await (const item of iterable) {
items.push(item);
}
return items;
}
describe("FakeSessionAdapter", () => {
const tempRoots: string[] = [];
afterEach(() => {
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
it("writes the ok fixture for the prompt schema and records transcript chunks", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd: fixtureRoot,
});
const prompt = envelope();
tempRoots.push(join(prompt.expectedArtifact, ".."));
await expect(adapter.sendPrompt(handle, prompt)).resolves.toEqual({ promptId: dedupKey });
await waitForFile(prompt.expectedArtifact);
expect(JSON.parse(readFileSync(prompt.expectedArtifact, "utf8"))).toMatchObject({
summary: "Fake spec",
});
const chunks = await collect(adapter.capture(handle, 0n));
expect(chunks.map((chunk) => chunk.content).join("\n")).toContain(
`[fake] received prompt ${prompt.uuid}; will write ${prompt.expectedArtifact} in 0ms`,
);
expect(chunks.every((chunk, index) => chunk.seq === BigInt(index + 1))).toBe(true);
});
it("classifies unsupported backend as human-required backend_unavailable", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
await expect(
adapter.start({
runId,
roleId: "implementer",
backend: "codex",
cwd: fixtureRoot,
}),
).rejects.toMatchObject({
class: "human_required",
code: "backend_unavailable",
});
});
it("treats duplicate prompt dedup keys as idempotent success without reprocessing", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd: fixtureRoot,
});
const first = envelope();
const duplicate = envelope({
uuid: "00000000-0000-4000-8000-000000000011",
dedupKey,
});
tempRoots.push(join(first.expectedArtifact, ".."), join(duplicate.expectedArtifact, ".."));
await adapter.sendPrompt(handle, first);
await expect(adapter.sendPrompt(handle, duplicate)).resolves.toEqual({ promptId: dedupKey });
await waitForFile(first.expectedArtifact);
expect(existsSync(duplicate.expectedArtifact)).toBe(false);
});
it("records dedup history only after a fake prompt is accepted", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd: fixtureRoot,
});
const crash = envelope({
dedupKey: "c".repeat(64),
instructions: "Scenario: crash\nCrash",
});
await expect(adapter.sendPrompt(handle, crash)).rejects.toMatchObject({
code: "prompt_send_transient",
});
await expect(adapter.sendPrompt(handle, crash)).rejects.toMatchObject({
code: "prompt_send_transient",
});
const ok = envelope({ dedupKey: "d".repeat(64) });
tempRoots.push(join(ok.expectedArtifact, ".."));
await adapter.sendPrompt(handle, ok);
await waitForFile(ok.expectedArtifact);
await adapter.rebootstrap(handle);
await expect(adapter.sendPrompt(handle, ok)).resolves.toEqual({ promptId: "d".repeat(64) });
});
it("rejects prompts whose run or role do not match the session", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd: fixtureRoot,
});
await expect(
adapter.sendPrompt(
handle,
envelope({
runId: "00000000-0000-4000-8000-000000000099",
dedupKey: "e".repeat(64),
}),
),
).rejects.toMatchObject({ code: "prompt_session_mismatch" });
await expect(
adapter.sendPrompt(
handle,
envelope({
roleId: "reviewer",
dedupKey: "f".repeat(64),
}),
),
).rejects.toMatchObject({ code: "prompt_session_mismatch" });
});
it("fails sendPrompt immediately when an ok fixture is missing", async () => {
const fixtureRoot = mkdtempSync(join(tmpdir(), "devflow-empty-fake-fixtures-"));
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd: fixtureRoot,
});
const prompt = envelope();
tempRoots.push(join(prompt.expectedArtifact, ".."));
await expect(adapter.sendPrompt(handle, prompt)).rejects.toMatchObject({
class: "fatal",
code: "fake_fixture_missing",
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(existsSync(prompt.expectedArtifact)).toBe(false);
});
it("does not record dedup history when fixture resolution fails", async () => {
const fixtureRoot = mkdtempSync(join(tmpdir(), "devflow-empty-fake-fixtures-"));
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd: fixtureRoot,
});
const missing = envelope({ dedupKey: "f".repeat(64) });
await expect(adapter.sendPrompt(handle, missing)).rejects.toMatchObject({
code: "fake_fixture_missing",
});
await expect(adapter.sendPrompt(handle, missing)).rejects.toMatchObject({
code: "fake_fixture_missing",
});
});
it("supports invalid, timeout, and crash sentinel scenarios", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd: fixtureRoot,
});
const invalid = envelope({
dedupKey: secondDedupKey,
instructions: "Scenario: invalid\nBuild an invalid artifact",
});
tempRoots.push(join(invalid.expectedArtifact, ".."));
await adapter.sendPrompt(handle, invalid);
await waitForFile(invalid.expectedArtifact);
expect(JSON.parse(readFileSync(invalid.expectedArtifact, "utf8"))).toEqual({
fake: "invalid",
});
const timeout = envelope({
dedupKey: "c".repeat(64),
instructions: "Scenario: timeout\nDo not write",
});
tempRoots.push(join(timeout.expectedArtifact, ".."));
await adapter.sendPrompt(handle, timeout);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(existsSync(timeout.expectedArtifact)).toBe(false);
const crash = envelope({
dedupKey: "d".repeat(64),
instructions: "Scenario: crash\nCrash",
});
await expect(adapter.sendPrompt(handle, crash)).rejects.toBeInstanceOf(DevflowError);
await expect(
adapter.sendPrompt(handle, {
...crash,
dedupKey: "e".repeat(64),
}),
).rejects.toMatchObject({
class: "recoverable",
code: "prompt_send_transient",
});
});
it("probes, resumes, rebootstraps, captures from a sequence, and disposes sessions", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd: fixtureRoot,
envelopePrelude: "Follow the fake protocol",
});
expect(await adapter.resume(handle)).toEqual(handle);
expect(await adapter.probe(handle)).toMatchObject({ alive: true, paneActive: true });
const rebootstrapped = await adapter.rebootstrap(handle);
expect(rebootstrapped.sessionId).toBe(handle.sessionId);
expect(await collect(adapter.capture(handle, 1n))).toEqual(
expect.arrayContaining([
expect.objectContaining({
seq: 2n,
content: "[fake] rebootstrap complete",
}),
]),
);
await adapter.dispose(handle);
expect(await adapter.probe(handle)).toMatchObject({ alive: false, paneActive: false });
});
});

View File

@@ -1,300 +0,0 @@
import { randomUUID } from "node:crypto";
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { DevflowError, type PromptEnvelope } from "@devflow/core";
import type {
ProbeResult,
SessionAdapter,
SessionHandle,
StartInput,
TranscriptChunk,
} from "./adapter.js";
export interface FakeSessionAdapterOptions {
fixtureRoot?: string;
writeDelayMs?: number | ((envelope: PromptEnvelope) => number);
sessionIdFactory?: () => string;
now?: () => Date;
}
interface FakeSessionRecord {
handle: SessionHandle;
runId: string;
roleId: string;
alive: boolean;
disposed: boolean;
transcript: TranscriptChunk[];
sentDedupKeys: Set<string>;
timers: Set<NodeJS.Timeout>;
lastOutputAt?: Date;
}
export class FakeSessionAdapter implements SessionAdapter {
private readonly fixtureRoot: string;
private readonly writeDelayMs: (envelope: PromptEnvelope) => number;
private readonly sessionIdFactory: () => string;
private readonly now: () => Date;
private readonly sessions = new Map<string, FakeSessionRecord>();
constructor(options: FakeSessionAdapterOptions = {}) {
this.fixtureRoot = options.fixtureRoot ?? defaultFixtureRoot();
const writeDelayMs = options.writeDelayMs;
this.writeDelayMs =
typeof writeDelayMs === "function" ? writeDelayMs : () => writeDelayMs ?? 50;
this.sessionIdFactory = options.sessionIdFactory ?? randomUUID;
this.now = options.now ?? (() => new Date());
}
reserveSessionId(): string {
return this.sessionIdFactory();
}
async start(input: StartInput): Promise<SessionHandle> {
if (input.backend !== "fake") {
throw new DevflowError("FakeSessionAdapter only supports the fake backend", {
class: "human_required",
code: "backend_unavailable",
runId: input.runId,
});
}
const handle: SessionHandle = { sessionId: input.sessionId ?? this.sessionIdFactory() };
const record: FakeSessionRecord = {
handle,
runId: input.runId,
roleId: input.roleId,
alive: true,
disposed: false,
transcript: [],
sentDedupKeys: new Set(),
timers: new Set(),
};
this.sessions.set(handle.sessionId, record);
this.appendTranscript(record, `[fake] session started for ${input.roleId} in ${input.cwd}`);
return handle;
}
async sendPrompt(handle: SessionHandle, envelope: PromptEnvelope): Promise<{ promptId: string }> {
const record = this.requireLiveSession(handle);
if (envelope.runId !== record.runId || envelope.roleId !== record.roleId) {
throw new DevflowError("Prompt does not match fake session run or role", {
class: "fatal",
code: "prompt_session_mismatch",
runId: envelope.runId,
});
}
if (record.sentDedupKeys.has(envelope.dedupKey)) {
return { promptId: envelope.dedupKey };
}
const scenarioName = scenarioFromInstructions(envelope.instructions);
if (scenarioName === "crash") {
this.appendTranscript(record, `[fake] received prompt ${envelope.uuid}; crashing`);
throw new DevflowError("Fake session crash scenario", {
class: "recoverable",
code: "prompt_send_transient",
runId: envelope.runId,
});
}
if (scenarioName === "timeout") {
record.sentDedupKeys.add(envelope.dedupKey);
this.appendTranscript(record, `[fake] received prompt ${envelope.uuid}; timeout`);
return { promptId: envelope.dedupKey };
}
const fixturePath =
scenarioName === "invalid"
? undefined
: resolveFixturePath(
this.fixtureRoot,
envelope.expectedSchema,
scenarioName,
envelope.runId,
);
record.sentDedupKeys.add(envelope.dedupKey);
const writeDelayMs = this.writeDelayMs(envelope);
this.appendTranscript(
record,
`[fake] received prompt ${envelope.uuid}; will write ${envelope.expectedArtifact} in ${writeDelayMs}ms`,
);
const timer = setTimeout(() => {
record.timers.delete(timer);
if (!record.alive || record.disposed) {
return;
}
try {
if (scenarioName === "invalid") {
writeJsonArtifact(envelope.expectedArtifact, { fake: "invalid" });
} else {
copyFixtureArtifact(fixturePath, envelope.expectedArtifact);
}
} catch (cause) {
record.alive = false;
this.appendTranscript(
record,
`[fake] failed to write artifact ${envelope.expectedArtifact}`,
);
return;
}
this.appendTranscript(record, `[fake] wrote artifact ${envelope.expectedArtifact}`);
}, writeDelayMs);
record.timers.add(timer);
return { promptId: envelope.dedupKey };
}
async probe(handle: SessionHandle): Promise<ProbeResult> {
const record = this.sessions.get(handle.sessionId);
if (record === undefined || !record.alive || record.disposed) {
return { alive: false, paneActive: false, hint: "fake session is not active" };
}
const result: ProbeResult = { alive: true, paneActive: true };
if (record.lastOutputAt !== undefined) {
return { ...result, lastOutputAt: record.lastOutputAt };
}
return result;
}
async resume(handle: SessionHandle): Promise<SessionHandle> {
return this.requireLiveSession(handle).handle;
}
async rebootstrap(handle: SessionHandle): Promise<SessionHandle> {
const record = this.sessions.get(handle.sessionId);
if (record === undefined) {
throw new DevflowError("Cannot rebootstrap unknown fake session", {
class: "recoverable",
code: "pane_briefly_unresponsive",
});
}
for (const timer of record.timers) {
clearTimeout(timer);
}
record.timers.clear();
record.alive = true;
record.disposed = false;
this.appendTranscript(record, "[fake] rebootstrap complete");
return record.handle;
}
async *capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk> {
const record = this.sessions.get(handle.sessionId);
if (record === undefined) {
return;
}
for (const chunk of record.transcript) {
if (chunk.seq > fromSeq) {
yield chunk;
}
}
}
async dispose(handle: SessionHandle): Promise<void> {
const record = this.sessions.get(handle.sessionId);
if (record === undefined) {
return;
}
for (const timer of record.timers) {
clearTimeout(timer);
}
record.timers.clear();
record.alive = false;
record.disposed = true;
}
private requireLiveSession(handle: SessionHandle): FakeSessionRecord {
const record = this.sessions.get(handle.sessionId);
if (record === undefined || !record.alive || record.disposed) {
throw new DevflowError("Fake session is not active", {
class: "recoverable",
code: "pane_briefly_unresponsive",
});
}
return record;
}
private appendTranscript(record: FakeSessionRecord, content: string): void {
const capturedAt = this.now();
record.lastOutputAt = capturedAt;
record.transcript.push({
seq: BigInt(record.transcript.length + 1),
content,
capturedAt,
});
}
}
function scenarioFromInstructions(instructions: string): string {
const match = /^Scenario:\s*([A-Za-z0-9_-]+)\s*$/m.exec(instructions);
return match?.[1] ?? "ok";
}
function resolveFixturePath(
fixtureRoot: string,
expectedSchema: string,
scenarioName: string,
runId: string,
): string {
const fixturePath = join(fixtureRoot, expectedSchema, `${scenarioName}.json`);
if (!existsSync(fixturePath) || !statSync(fixturePath).isFile()) {
throw new DevflowError(`Missing fake artifact fixture ${fixturePath}`, {
class: "fatal",
code: "fake_fixture_missing",
runId,
});
}
return fixturePath;
}
function copyFixtureArtifact(fixturePath: string | undefined, expectedArtifact: string): void {
if (fixturePath === undefined) {
throw new DevflowError("Missing resolved fake artifact fixture path", {
class: "fatal",
code: "fake_fixture_missing",
});
}
mkdirSync(dirname(expectedArtifact), { recursive: true });
copyFileSync(fixturePath, expectedArtifact);
}
function writeJsonArtifact(path: string, value: unknown): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(value));
}
function defaultFixtureRoot(): string {
const workspaceRoot = findWorkspaceRoot(process.cwd());
return join(workspaceRoot, "tests", "fixtures", "fake-artifacts");
}
function findWorkspaceRoot(start: string): string {
let current = resolve(start);
while (true) {
const packageJsonPath = join(current, "package.json");
const workspacePath = join(current, "pnpm-workspace.yaml");
if (existsSync(packageJsonPath) && existsSync(workspacePath)) {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown };
if (packageJson.name === "devflow") {
return current;
}
}
const parent = dirname(current);
if (parent === current) {
return resolve(start);
}
current = parent;
}
}

View File

@@ -1,6 +0,0 @@
export * from "./adapter.js";
export * from "./fake.js";
export * from "./manager.js";
export * from "./recovery.js";
export * from "./transcript.js";
export * from "./tmux.js";

View File

@@ -1,334 +0,0 @@
import { randomUUID } from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import type { PromptEnvelope } from "@devflow/core";
import {
type DbClient,
approvalRequests,
createDbClient,
runEvents,
runs,
tuiSessions,
tuiTranscriptChunks,
workflowTemplates,
} from "@devflow/db";
import { eq, inArray } from "drizzle-orm";
import type {
ProbeResult,
SessionAdapter,
SessionHandle,
StartInput,
TranscriptChunk,
} from "./adapter.js";
import { SessionManager } from "./manager.js";
const testDatabaseUrl =
process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow";
class RecordingRecoveryAdapter implements SessionAdapter {
resumeCalls = 0;
sendPromptCalls = 0;
captureCalls: Array<{ sessionId: string; fromSeq: bigint }> = [];
async start(input: StartInput): Promise<SessionHandle> {
return { sessionId: input.sessionId ?? randomUUID() };
}
async sendPrompt(
_handle: SessionHandle,
envelope: PromptEnvelope,
): Promise<{ promptId: string }> {
this.sendPromptCalls += 1;
return { promptId: envelope.dedupKey };
}
async probe(): Promise<ProbeResult> {
return { alive: true, paneActive: true };
}
async resume(handle: SessionHandle): Promise<SessionHandle> {
this.resumeCalls += 1;
return handle;
}
async rebootstrap(handle: SessionHandle): Promise<SessionHandle> {
return handle;
}
capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk> {
this.captureCalls.push({ sessionId: handle.sessionId, fromSeq });
return singleTranscript(fromSeq + 1n, "shutdown transcript");
}
async dispose(): Promise<void> {
return;
}
}
describe("SessionManager recovery", () => {
let client: DbClient | undefined;
const runIds: string[] = [];
const templateIds: string[] = [];
afterEach(async () => {
if (client === undefined) {
return;
}
if (runIds.length > 0) {
await client.db.delete(approvalRequests).where(inArray(approvalRequests.runId, [...runIds]));
await client.db.delete(runs).where(inArray(runs.id, [...runIds]));
}
if (templateIds.length > 0) {
await client.db
.delete(workflowTemplates)
.where(inArray(workflowTemplates.id, [...templateIds]));
}
runIds.length = 0;
templateIds.length = 0;
await client.close();
client = undefined;
});
it("recovers BUSY sessions without prompt proof for baseline replay handling", async () => {
client = createDbClient(testDatabaseUrl);
const templateId = randomUUID();
const runId = randomUUID();
const sessionId = randomUUID();
templateIds.push(templateId);
runIds.push(runId);
await client.db.insert(workflowTemplates).values({
id: templateId,
name: `template-${templateId}`,
version: 1,
hash: `hash-${templateId}`,
definition: {},
});
await client.db.insert(runs).values({
id: runId,
templateId,
templateHash: `hash-${templateId}`,
state: "executing",
repoPath: `/tmp/devflow-${runId}`,
baseBranch: "main",
worktreeRoot: `/tmp/devflow-${runId}/main`,
});
await client.db.insert(tuiSessions).values({
id: sessionId,
runId,
roleId: "implementer",
backend: "codex",
cwd: `/tmp/devflow-${runId}/main`,
state: "BUSY",
lastPromptHash: "a".repeat(64),
lastPromptAt: new Date("2026-05-13T00:00:00.000Z"),
tmuxSession: "devflow-test-session",
tmuxWindow: "implementer",
});
const adapter = new RecordingRecoveryAdapter();
const manager = new SessionManager({
db: client.db,
adapter,
recoveryRunIds: [runId],
});
await expect(manager.recoverSessions()).resolves.toEqual({
recoveredSessionIds: [sessionId],
failedSessionIds: [],
});
expect(adapter.resumeCalls).toBe(1);
await expect(
client.db
.select({ state: tuiSessions.state, recoveryAttempts: tuiSessions.recoveryAttempts })
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionId)),
).resolves.toEqual([{ state: "BUSY", recoveryAttempts: 0 }]);
await expect(
client.db.select({ state: runs.state }).from(runs).where(eq(runs.id, runId)),
).resolves.toEqual([{ state: "executing" }]);
const events = await client.db
.select({ type: runEvents.type })
.from(runEvents)
.where(eq(runEvents.runId, runId))
.orderBy(runEvents.seq);
expect(events.map((event) => event.type)).toEqual([]);
});
it("skips prompt delivery when durable prompt proof already exists", async () => {
client = createDbClient(testDatabaseUrl);
const templateId = randomUUID();
const runId = randomUUID();
templateIds.push(templateId);
runIds.push(runId);
const dedupKey = "b".repeat(64);
await client.db.insert(workflowTemplates).values({
id: templateId,
name: `template-${templateId}`,
version: 1,
hash: `hash-${templateId}`,
definition: {},
});
await client.db.insert(runs).values({
id: runId,
templateId,
templateHash: `hash-${templateId}`,
state: "executing",
repoPath: `/tmp/devflow-${runId}`,
baseBranch: "main",
worktreeRoot: `/tmp/devflow-${runId}/main`,
});
await client.db.insert(runEvents).values({
runId,
seq: 1n,
type: "prompt.sent",
payload: { roleId: "implementer", dedupKey },
idempotencyKey: `prompt.sent:${dedupKey}`,
});
const adapter = new RecordingRecoveryAdapter();
const manager = new SessionManager({ db: client.db, adapter });
await expect(
manager.sendPrompt(
{ sessionId: randomUUID() },
{
uuid: randomUUID(),
runId,
roleId: "implementer",
phaseKey: "spec",
attempt: 1,
expectedArtifact: `/tmp/devflow-${runId}/main/spec.json`,
expectedSchema: "dev/spec@1",
dedupKey,
instructions: "already delivered",
},
),
).resolves.toEqual({ promptId: dedupKey });
expect(adapter.sendPromptCalls).toBe(0);
});
it("does not deliver a prompt if shutdown starts while checking durable prompt proof", async () => {
const adapter = new RecordingRecoveryAdapter();
const manager = new SessionManager({ adapter });
const promptProofStarted = deferred<void>();
const promptProofAllowed = deferred<boolean>();
(
manager as unknown as {
promptDeliveryAlreadyRecorded(envelope: PromptEnvelope): Promise<boolean>;
}
).promptDeliveryAlreadyRecorded = async () => {
promptProofStarted.resolve();
return promptProofAllowed.promise;
};
const runId = randomUUID();
const dedupKey = "c".repeat(64);
const send = manager.sendPrompt(
{ sessionId: randomUUID() },
{
uuid: randomUUID(),
runId,
roleId: "implementer",
phaseKey: "spec",
attempt: 1,
expectedArtifact: `/tmp/devflow-${runId}/main/spec.json`,
expectedSchema: "dev/spec@1",
dedupKey,
instructions: "shutdown race",
},
);
await promptProofStarted.promise;
const shutdown = manager.shutdown();
promptProofAllowed.resolve(false);
await expect(send).rejects.toMatchObject({ code: "session_manager_draining" });
await expect(shutdown).resolves.toBeUndefined();
expect(adapter.sendPromptCalls).toBe(0);
});
it("captures tracked transcripts before releasing the singleton lock on shutdown", async () => {
client = createDbClient(testDatabaseUrl);
const templateId = randomUUID();
const runId = randomUUID();
const sessionId = randomUUID();
templateIds.push(templateId);
runIds.push(runId);
await client.db.insert(workflowTemplates).values({
id: templateId,
name: `template-${templateId}`,
version: 1,
hash: `hash-${templateId}`,
definition: {},
});
await client.db.insert(runs).values({
id: runId,
templateId,
templateHash: `hash-${templateId}`,
state: "executing",
repoPath: `/tmp/devflow-${runId}`,
baseBranch: "main",
worktreeRoot: `/tmp/devflow-${runId}/main`,
});
await client.db.insert(tuiSessions).values({
id: sessionId,
runId,
roleId: "implementer",
backend: "codex",
cwd: `/tmp/devflow-${runId}/main`,
state: "READY",
lastCaptureSeq: 1n,
});
const adapter = new RecordingRecoveryAdapter();
const manager = new SessionManager({ db: client.db, adapter });
await manager.resume({ sessionId });
await expect(manager.shutdown()).resolves.toBeUndefined();
expect(adapter.captureCalls).toEqual([{ sessionId, fromSeq: 1n }]);
await expect(
client.db
.select({ seq: tuiTranscriptChunks.seq, content: tuiTranscriptChunks.content })
.from(tuiTranscriptChunks)
.where(eq(tuiTranscriptChunks.sessionId, sessionId)),
).resolves.toEqual([{ seq: 2n, content: "shutdown transcript" }]);
await expect(
client.db
.select({ lastCaptureSeq: tuiSessions.lastCaptureSeq })
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionId)),
).resolves.toEqual([{ lastCaptureSeq: 2n }]);
});
});
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
function singleTranscript(seq: bigint, content: string): AsyncIterable<TranscriptChunk> {
return {
[Symbol.asyncIterator]() {
let emitted = false;
return {
async next() {
if (emitted) {
return { done: true, value: undefined };
}
emitted = true;
return {
done: false,
value: { seq, content, capturedAt: new Date("2026-05-13T00:00:00.000Z") },
};
},
};
},
};
}

View File

@@ -1,602 +0,0 @@
import { DevflowError, type PromptEnvelope } from "@devflow/core";
import {
type DbClient,
RunEventRepository,
TuiTranscriptRepository,
approvalRequests,
runEvents,
runs,
tuiSessions,
tuiTranscriptChunks,
} from "@devflow/db";
import { and, desc, eq, gt, inArray, lte, notInArray, sql } from "drizzle-orm";
import type {
ProbeResult,
SessionAdapter,
SessionHandle,
StartInput,
TranscriptBaseline,
TranscriptChunk,
} from "./adapter.js";
import { assertSessionTransition, retryRecoverable } from "./recovery.js";
import { captureAndPersistTranscript } from "./transcript.js";
type Database = DbClient["db"];
interface AdvisoryLockClient {
query<T extends Record<string, unknown> = Record<string, unknown>>(
text: string,
values?: readonly unknown[],
): Promise<{ rows: T[] }>;
release(): void;
}
export interface SessionRuntime {
trackOperation<T>(operation: Promise<T>): Promise<T>;
start(input: StartInput): Promise<SessionHandle>;
sendPrompt(handle: SessionHandle, envelope: PromptEnvelope): Promise<{ promptId: string }>;
probe(handle: SessionHandle): Promise<ProbeResult>;
resume(handle: SessionHandle): Promise<SessionHandle>;
rebootstrap(handle: SessionHandle): Promise<SessionHandle>;
capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk>;
dispose(handle: SessionHandle): Promise<void>;
}
export interface SessionManagerOptions {
adapter: SessionAdapter;
db?: Database;
dbClient?: DbClient;
recoveryRunIds?: readonly string[];
shutdownDrainMs?: number;
}
export interface SessionManagerRecoveryResult {
recoveredSessionIds: string[];
failedSessionIds: string[];
}
export class SessionManager implements SessionRuntime {
private readonly adapter: SessionAdapter;
private readonly db: Database | undefined;
private readonly dbClient: DbClient | undefined;
private readonly recoveryRunIds: readonly string[] | undefined;
private readonly shutdownDrainMs: number;
private readonly handles = new Map<string, SessionHandle>();
private readonly inFlight = new Set<Promise<unknown>>();
private lockClient: AdvisoryLockClient | undefined;
private draining = false;
constructor(options: SessionManagerOptions) {
this.adapter = options.adapter;
this.db = options.dbClient?.db ?? options.db;
this.dbClient = options.dbClient;
this.recoveryRunIds = options.recoveryRunIds;
this.shutdownDrainMs = options.shutdownDrainMs ?? 30_000;
}
async initialize(): Promise<SessionManagerRecoveryResult> {
await this.acquireLock();
try {
return await this.recoverSessions();
} catch (error) {
await this.shutdown();
throw error;
}
}
async acquireLock(): Promise<void> {
if (this.dbClient === undefined) {
throw new DevflowError("SessionManager requires a DbClient for singleton startup", {
class: "fatal",
code: "internal_state_corruption",
});
}
if (this.lockClient !== undefined) {
return;
}
const client = (await this.dbClient.pool.connect()) as AdvisoryLockClient;
const result = await client.query<{ acquired: boolean }>(
"SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS acquired",
["devflow:session-manager"],
);
if (result.rows[0]?.acquired !== true) {
client.release();
throw new DevflowError("another session manager is running", {
class: "human_required",
code: "session_manager_already_running",
recoveryHint: "exit_code=3",
});
}
this.lockClient = client;
}
async shutdown(): Promise<void> {
this.draining = true;
await this.waitForInFlight();
let captureError: unknown;
try {
await this.captureTrackedTranscripts();
} catch (error) {
captureError = error;
}
const client = this.lockClient;
this.lockClient = undefined;
this.handles.clear();
if (client !== undefined) {
try {
await client.query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [
"devflow:session-manager",
]);
} finally {
client.release();
}
}
if (captureError !== undefined) {
throw captureError;
}
}
trackOperation<T>(operation: Promise<T>): Promise<T> {
return this.track(operation);
}
async start(input: StartInput): Promise<SessionHandle> {
this.assertAcceptingPrompts();
const handle = await this.track(this.adapter.start(input));
this.handles.set(handle.sessionId, handle);
return handle;
}
async sendPrompt(handle: SessionHandle, envelope: PromptEnvelope): Promise<{ promptId: string }> {
this.assertAcceptingPrompts();
return this.track(
(async () => {
if (await this.promptDeliveryAlreadyRecorded(envelope)) {
return { promptId: envelope.dedupKey };
}
this.assertAcceptingPrompts();
return this.adapter.sendPrompt(this.handleFor(handle), envelope);
})(),
);
}
async probe(handle: SessionHandle): Promise<ProbeResult> {
return this.track(this.adapter.probe(this.handleFor(handle)));
}
async resume(handle: SessionHandle): Promise<SessionHandle> {
this.assertAcceptingPrompts();
const resumed = await this.track(this.adapter.resume(this.handleFor(handle)));
this.handles.set(resumed.sessionId, resumed);
return resumed;
}
async rebootstrap(handle: SessionHandle): Promise<SessionHandle> {
this.assertAcceptingPrompts();
const rebootstrapped = await this.track(this.adapter.rebootstrap(this.handleFor(handle)));
this.handles.set(rebootstrapped.sessionId, rebootstrapped);
return rebootstrapped;
}
async *capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk> {
const finishTracking = this.beginTrackedOperation();
try {
for await (const chunk of this.adapter.capture(this.handleFor(handle), fromSeq)) {
yield chunk;
}
} finally {
finishTracking();
}
}
async dispose(handle: SessionHandle): Promise<void> {
const resolvedHandle = this.handleFor(handle);
await this.track(this.adapter.dispose(resolvedHandle));
this.handles.delete(resolvedHandle.sessionId);
}
async recoverSessions(): Promise<SessionManagerRecoveryResult> {
if (this.db === undefined) {
return { recoveredSessionIds: [], failedSessionIds: [] };
}
const sessionRows = await this.db
.select({
id: tuiSessions.id,
runId: tuiSessions.runId,
roleId: tuiSessions.roleId,
backend: tuiSessions.backend,
cwd: tuiSessions.cwd,
lastCaptureSeq: tuiSessions.lastCaptureSeq,
lastKnownPanePid: tuiSessions.lastKnownPanePid,
lastPromptHash: tuiSessions.lastPromptHash,
recoveryAttempts: tuiSessions.recoveryAttempts,
state: tuiSessions.state,
tmuxSession: tuiSessions.tmuxSession,
tmuxWindow: tuiSessions.tmuxWindow,
})
.from(tuiSessions)
.innerJoin(runs, eq(tuiSessions.runId, runs.id))
.where(
this.recoveryRunIds === undefined
? and(
notInArray(tuiSessions.state, [...nonRecoverableSessionStates]),
notInArray(runs.state, [...terminalRunStates]),
)
: and(
notInArray(tuiSessions.state, [...nonRecoverableSessionStates]),
notInArray(runs.state, [...terminalRunStates]),
inArray(tuiSessions.runId, [...this.recoveryRunIds]),
),
);
const recoveredSessionIds: string[] = [];
const failedSessionIds: string[] = [];
for (const session of sessionRows) {
const transcriptBaseline = await this.loadTranscriptBaseline(
session.id,
session.lastCaptureSeq,
);
const handle = compactHandle(
session.id,
session.runId,
session.roleId,
session.backend,
session.lastKnownPanePid,
session.tmuxSession,
session.tmuxWindow,
transcriptBaseline,
);
try {
const resumed = await this.resumeWithRetry(handle);
this.handles.set(resumed.sessionId, resumed);
await this.markStartupRecoverySucceeded(session, resumed);
recoveredSessionIds.push(resumed.sessionId);
} catch (error) {
await this.markRecoveryFailed(session, error);
failedSessionIds.push(session.id);
}
}
return { recoveredSessionIds, failedSessionIds };
}
private async promptDeliveryAlreadyRecorded(envelope: PromptEnvelope): Promise<boolean> {
if (this.db === undefined) {
return false;
}
const [event] = await this.db
.select({ id: runEvents.id })
.from(runEvents)
.where(
and(
eq(runEvents.runId, envelope.runId),
inArray(runEvents.idempotencyKey, [
`prompt.sent:${envelope.dedupKey}`,
`prompt.repaired:${envelope.dedupKey}`,
]),
),
)
.limit(1);
return event !== undefined;
}
private async markStartupRecoverySucceeded(
session: {
id: string;
runId: string;
roleId: string;
backend: string;
recoveryAttempts: number;
state: string;
},
handle: SessionHandle,
): Promise<void> {
if (this.db === undefined || !["CREATED", "BOOTSTRAPPING"].includes(session.state)) {
return;
}
if (session.state === "CREATED") {
assertSessionTransition("CREATED", "BOOTSTRAPPING");
assertSessionTransition("BOOTSTRAPPING", "READY");
} else {
assertSessionTransition(session.state, "READY");
}
const eventRepository = new RunEventRepository(this.db);
const sessionUpdate: {
state: "READY";
lastKnownPanePid?: number;
tmuxSession?: string;
tmuxWindow?: string;
} = { state: "READY" };
if (handle.pid !== undefined) {
sessionUpdate.lastKnownPanePid = handle.pid;
}
if (handle.tmuxSession !== undefined) {
sessionUpdate.tmuxSession = handle.tmuxSession;
}
if (handle.tmuxWindow !== undefined) {
sessionUpdate.tmuxWindow = handle.tmuxWindow;
}
await this.db.transaction(async (tx) => {
await tx.update(tuiSessions).set(sessionUpdate).where(eq(tuiSessions.id, session.id));
await eventRepository.appendInTransaction(tx, {
runId: session.runId,
type: "session.created",
payload: { sessionId: session.id, roleId: session.roleId, backend: session.backend },
idempotencyKey: `session.created:${session.id}`,
});
await eventRepository.appendInTransaction(tx, {
runId: session.runId,
type: "session.ready",
payload: {
sessionId: session.id,
roleId: session.roleId,
recoveryAttempts: session.recoveryAttempts,
},
idempotencyKey: `session.ready:${session.id}:${session.recoveryAttempts}`,
});
});
}
private async markRecoveryFailed(
session: {
id: string;
runId: string;
roleId: string;
backend: string;
cwd: string;
recoveryAttempts: number;
state: string;
},
error: unknown,
): Promise<void> {
if (this.db === undefined) {
return;
}
const eventRepository = new RunEventRepository(this.db);
const recoveryAttempts = session.recoveryAttempts + 1;
const gateKey = "session_recovery_required";
const approvalIdempotencyKey = `${session.runId}:${gateKey}:${session.id}:${recoveryAttempts}`;
const pauseCause = `session_recovery_failed:${session.id}:${recoveryAttempts}`;
assertSessionTransition(session.state, "FAILED_NEEDS_HUMAN");
await this.db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM ${runs} WHERE ${runs.id} = ${session.runId} FOR UPDATE`);
const [run] = await tx
.select({ state: runs.state })
.from(runs)
.where(eq(runs.id, session.runId))
.limit(1);
await tx
.update(tuiSessions)
.set({ state: "FAILED_NEEDS_HUMAN", recoveryAttempts })
.where(eq(tuiSessions.id, session.id));
await eventRepository.appendInTransaction(tx, {
runId: session.runId,
type: "session.failed",
payload: { sessionId: session.id, roleId: session.roleId },
idempotencyKey: `session.failed:${session.id}`,
});
if (run === undefined || isTerminalRunState(run.state)) {
return;
}
const inserted = await tx
.insert(approvalRequests)
.values({
runId: session.runId,
gateKey,
state: "pending",
idempotencyKey: approvalIdempotencyKey,
payload: {
sessionId: session.id,
roleId: session.roleId,
backend: session.backend,
cwd: session.cwd,
recoveryHint: recoveryHintFor(error),
},
})
.onConflictDoNothing({ target: approvalRequests.idempotencyKey })
.returning({ id: approvalRequests.id, idempotencyKey: approvalRequests.idempotencyKey });
if (run.state !== "paused") {
await tx
.update(runs)
.set({ state: "paused", pausedFromState: run.state, updatedAt: new Date() })
.where(eq(runs.id, session.runId));
await eventRepository.appendInTransaction(tx, {
runId: session.runId,
type: "run.paused",
payload: { cause: pauseCause, pausedFromState: run.state },
idempotencyKey: `run.paused:${session.runId}:${pauseCause}`,
});
}
const request =
inserted[0] ??
(
await tx
.select({ id: approvalRequests.id, idempotencyKey: approvalRequests.idempotencyKey })
.from(approvalRequests)
.where(eq(approvalRequests.idempotencyKey, approvalIdempotencyKey))
.limit(1)
)[0];
if (request !== undefined) {
await eventRepository.appendInTransaction(tx, {
runId: session.runId,
type: "approval.requested",
payload: {
approvalRequestId: request.id,
approvalIdempotencyKey: request.idempotencyKey,
gateKey,
},
idempotencyKey: `approval.requested:${request.idempotencyKey}`,
});
}
});
}
private async resumeWithRetry(handle: SessionHandle): Promise<SessionHandle> {
return retryRecoverable("resume", () => this.track(this.adapter.resume(handle)));
}
private async loadTranscriptBaseline(
sessionId: string,
lastCaptureSeq: bigint,
): Promise<TranscriptBaseline | undefined> {
if (this.db === undefined || lastCaptureSeq === 0n) {
return undefined;
}
const rowsDescending = await this.db
.select({ seq: tuiTranscriptChunks.seq, content: tuiTranscriptChunks.content })
.from(tuiTranscriptChunks)
.where(
and(
eq(tuiTranscriptChunks.sessionId, sessionId),
gt(tuiTranscriptChunks.seq, 0n),
lte(tuiTranscriptChunks.seq, lastCaptureSeq),
),
)
.orderBy(desc(tuiTranscriptChunks.seq))
.limit(transcriptBaselineLineLimit);
if (rowsDescending.length === 0) {
return undefined;
}
const rows = [...rowsDescending].reverse();
const startSeq = rows[0]?.seq;
if (startSeq === undefined) {
return undefined;
}
return {
startSeq,
lines: rows.map((row) => row.content),
};
}
private async captureTrackedTranscripts(): Promise<void> {
if (this.db === undefined || this.handles.size === 0) {
return;
}
const sessionRows = await this.db
.select({ id: tuiSessions.id, lastCaptureSeq: tuiSessions.lastCaptureSeq })
.from(tuiSessions)
.where(inArray(tuiSessions.id, [...this.handles.keys()]));
const sink = new TuiTranscriptRepository(this.db);
const results = await Promise.allSettled(
sessionRows.map((session) =>
captureAndPersistTranscript({
adapter: this.adapter,
handle: this.handleFor({ sessionId: session.id }),
fromSeq: session.lastCaptureSeq,
sink,
}),
),
);
const failed = results.find((result) => result.status === "rejected");
if (failed !== undefined) {
throw failed.reason;
}
}
private async track<T>(operation: Promise<T>): Promise<T> {
const tracked = operation.finally(() => {
this.inFlight.delete(tracked);
});
this.inFlight.add(tracked);
return tracked;
}
private beginTrackedOperation(): () => void {
let finishOperation!: () => void;
const tracked = new Promise<void>((resolve) => {
finishOperation = resolve;
}).finally(() => {
this.inFlight.delete(tracked);
});
this.inFlight.add(tracked);
return finishOperation;
}
private async waitForInFlight(): Promise<void> {
if (this.inFlight.size === 0) {
return;
}
await Promise.race([
Promise.allSettled([...this.inFlight]),
new Promise((resolveWait) => setTimeout(resolveWait, this.shutdownDrainMs)),
]);
}
private handleFor(handle: SessionHandle): SessionHandle {
const tracked = this.handles.get(handle.sessionId);
if (tracked === undefined) {
return handle;
}
const merged = mergeSessionHandles(tracked, handle);
this.handles.set(handle.sessionId, merged);
return merged;
}
private assertAcceptingPrompts(): void {
if (this.draining) {
throw new DevflowError("SessionManager is draining", {
class: "human_required",
code: "session_manager_draining",
});
}
}
}
const terminalRunStates = ["completed", "failed", "aborted"] as const;
const nonRecoverableSessionStates = ["FAILED_NEEDS_HUMAN"] as const;
function isTerminalRunState(state: string): state is (typeof terminalRunStates)[number] {
return terminalRunStates.includes(state as (typeof terminalRunStates)[number]);
}
function compactHandle(
sessionId: string,
runId: string,
roleId: string,
backend: string,
pid: number | null,
tmuxSession: string | null,
tmuxWindow: string | null,
transcriptBaseline: TranscriptBaseline | undefined,
): SessionHandle {
return {
sessionId,
runId,
roleId,
...(pid === null ? {} : { pid }),
...(tmuxSession === null ? {} : { tmuxSession }),
...(tmuxWindow === null ? {} : { tmuxWindow }),
...(backend === "fake" ? {} : { requirePreludeReplay: true }),
...(transcriptBaseline === undefined ? {} : { transcriptBaseline }),
};
}
const transcriptBaselineLineLimit = 200;
function mergeSessionHandles(tracked: SessionHandle, incoming: SessionHandle): SessionHandle {
return {
...tracked,
...Object.fromEntries(Object.entries(incoming).filter(([, value]) => value !== undefined)),
};
}
function recoveryHintFor(error: unknown): string {
if (error instanceof DevflowError && error.recoveryHint !== undefined) {
return error.recoveryHint;
}
if (error instanceof Error) {
return error.message;
}
return "session resume failed";
}

View File

@@ -1,103 +0,0 @@
import { describe, expect, it } from "vitest";
import { DevflowError } from "@devflow/core";
import {
SessionRecoveryBudget,
assertSessionStateAssignment,
assertSessionTransition,
isSessionHung,
retryRecoverable,
} from "./recovery.js";
describe("session recovery policy", () => {
it("allows only locked session state-machine transitions", () => {
expect(() => assertSessionTransition("CREATED", "BOOTSTRAPPING")).not.toThrow();
expect(() => assertSessionTransition("BOOTSTRAPPING", "READY")).not.toThrow();
expect(() => assertSessionTransition("READY", "BUSY")).not.toThrow();
expect(() => assertSessionTransition("BUSY", "READY")).not.toThrow();
expect(() => assertSessionTransition("BUSY", "ARTIFACT_TIMEOUT")).not.toThrow();
expect(() => assertSessionTransition("ARTIFACT_TIMEOUT", "RESUMING")).not.toThrow();
expect(() => assertSessionTransition("RESUMING", "REBOOTSTRAPPED")).not.toThrow();
expect(() => assertSessionTransition("REBOOTSTRAPPED", "READY")).not.toThrow();
expect(() => assertSessionTransition("READY", "REBOOTSTRAPPED")).toThrow(
/Invalid session state transition/,
);
expect(() => assertSessionTransition("CRASHED", "CRASHED")).toThrow(
/Invalid session state transition/,
);
expect(() => assertSessionTransition("FAILED_NEEDS_HUMAN", "READY")).toThrow(
/Invalid session state transition/,
);
});
it("allows no-op state assignment without treating it as a transition", () => {
expect(() => assertSessionStateAssignment("READY", "READY")).not.toThrow();
expect(() => assertSessionStateAssignment("BUSY", "READY")).not.toThrow();
expect(() => assertSessionStateAssignment("FAILED_NEEDS_HUMAN", "READY")).toThrow(
/Invalid session state assignment/,
);
});
it("retries recoverable errors for one initial prompt send plus two retries", async () => {
let attempts = 0;
const result = await retryRecoverable("sendPrompt", async () => {
attempts += 1;
if (attempts < SessionRecoveryBudget.sendPrompt.physicalAttempts) {
throw new DevflowError("temporary prompt failure", {
class: "recoverable",
code: "prompt_send_transient",
});
}
return "sent";
});
expect(result).toBe("sent");
expect(attempts).toBe(3);
});
it("throws the final recoverable error after the retry budget is exhausted", async () => {
let attempts = 0;
await expect(
retryRecoverable("rebootstrap", async () => {
attempts += 1;
throw new DevflowError("pane briefly unresponsive", {
class: "recoverable",
code: "pane_briefly_unresponsive",
});
}),
).rejects.toMatchObject({
class: "recoverable",
code: "pane_briefly_unresponsive",
});
expect(attempts).toBe(SessionRecoveryBudget.rebootstrap.physicalAttempts);
});
it("does not retry human-required or fatal errors", async () => {
let attempts = 0;
await expect(
retryRecoverable("resume", async () => {
attempts += 1;
throw new DevflowError("operator action required", {
class: "human_required",
code: "session_recovery_required",
});
}),
).rejects.toMatchObject({
class: "human_required",
code: "session_recovery_required",
});
expect(attempts).toBe(1);
});
it("uses the locked hung-session timeout boundary", () => {
const now = new Date("2026-05-13T10:20:00.000Z");
expect(isSessionHung(new Date("2026-05-13T10:00:00.000Z"), now)).toBe(true);
expect(isSessionHung(new Date("2026-05-13T10:00:01.000Z"), now)).toBe(false);
expect(isSessionHung(undefined, now)).toBe(false);
});
});

View File

@@ -1,127 +0,0 @@
import { DevflowError, SessionState, type SessionState as SessionStateName } from "@devflow/core";
export const SessionRecoveryBudget = Object.freeze({
sendPrompt: Object.freeze({ retries: 2, physicalAttempts: 3 }),
resume: Object.freeze({ retries: 2, physicalAttempts: 3 }),
rebootstrap: Object.freeze({ retries: 1, physicalAttempts: 2 }),
artifactRepair: Object.freeze({ retries: 1, physicalAttempts: 2 }),
maxHungMs: 20 * 60 * 1000,
});
export type SessionRetryOperation = "sendPrompt" | "resume" | "rebootstrap" | "artifactRepair";
const allowedSessionTransitions: ReadonlyMap<
SessionStateName,
ReadonlySet<SessionStateName>
> = new Map([
["CREATED", new Set(["BOOTSTRAPPING", "FAILED_NEEDS_HUMAN"])],
["BOOTSTRAPPING", new Set(["READY", "FAILED_NEEDS_HUMAN"])],
["READY", new Set(["BUSY", "FAILED_NEEDS_HUMAN"])],
[
"BUSY",
new Set([
"READY",
"WAITING_FOR_APPROVAL",
"ARTIFACT_TIMEOUT",
"HUNG",
"CRASHED",
"FAILED_NEEDS_HUMAN",
]),
],
["WAITING_FOR_APPROVAL", new Set(["READY", "FAILED_NEEDS_HUMAN"])],
["ARTIFACT_TIMEOUT", new Set(["RESUMING", "FAILED_NEEDS_HUMAN"])],
["HUNG", new Set(["RESUMING", "FAILED_NEEDS_HUMAN"])],
["CRASHED", new Set(["RESUMING", "FAILED_NEEDS_HUMAN"])],
["RESUMING", new Set(["READY", "REBOOTSTRAPPED", "FAILED_NEEDS_HUMAN"])],
["REBOOTSTRAPPED", new Set(["READY", "FAILED_NEEDS_HUMAN"])],
["FAILED_NEEDS_HUMAN", new Set()],
]);
export function isAllowedSessionTransition(from: string, to: string): boolean {
const parsedFrom = SessionState.safeParse(from);
const parsedTo = SessionState.safeParse(to);
if (!parsedFrom.success || !parsedTo.success) {
return false;
}
return allowedSessionTransitions.get(parsedFrom.data)?.has(parsedTo.data) ?? false;
}
export function isAllowedSessionStateAssignment(from: string, to: string): boolean {
const parsedFrom = SessionState.safeParse(from);
const parsedTo = SessionState.safeParse(to);
if (!parsedFrom.success || !parsedTo.success) {
return false;
}
if (parsedFrom.data === parsedTo.data) {
return true;
}
return isAllowedSessionTransition(parsedFrom.data, parsedTo.data);
}
export function assertSessionTransition(from: string, to: string): void {
if (isAllowedSessionTransition(from, to)) {
return;
}
throw new DevflowError("Invalid session state transition", {
class: "fatal",
code: "internal_state_corruption",
recoveryHint: `${from}->${to}`,
});
}
export function assertSessionStateAssignment(from: string, to: string): void {
if (isAllowedSessionStateAssignment(from, to)) {
return;
}
throw new DevflowError("Invalid session state assignment", {
class: "fatal",
code: "internal_state_corruption",
recoveryHint: `${from}->${to}`,
});
}
export async function retryRecoverable<T>(
operation: SessionRetryOperation,
run: (physicalAttempt: number) => Promise<T>,
): Promise<T> {
const physicalAttempts = recoveryPhysicalAttempts(operation);
let lastError: unknown;
for (let physicalAttempt = 1; physicalAttempt <= physicalAttempts; physicalAttempt += 1) {
try {
return await run(physicalAttempt);
} catch (error) {
lastError = error;
if (!(error instanceof DevflowError) || error.class !== "recoverable") {
throw error;
}
}
}
throw lastError;
}
export function isSessionHung(
lastOutputAt: Date | undefined,
now: Date,
maxHungMs = SessionRecoveryBudget.maxHungMs,
): boolean {
if (lastOutputAt === undefined) {
return false;
}
return now.getTime() - lastOutputAt.getTime() >= maxHungMs;
}
function recoveryPhysicalAttempts(operation: SessionRetryOperation): number {
switch (operation) {
case "sendPrompt":
return SessionRecoveryBudget.sendPrompt.physicalAttempts;
case "resume":
return SessionRecoveryBudget.resume.physicalAttempts;
case "rebootstrap":
return SessionRecoveryBudget.rebootstrap.physicalAttempts;
case "artifactRepair":
return SessionRecoveryBudget.artifactRepair.physicalAttempts;
}
}

View File

@@ -1,917 +0,0 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { DevflowError, type PromptEnvelope, renderPromptEnvelope } from "@devflow/core";
import {
TmuxCommandError,
type TmuxDriver,
type TmuxDriverExecOptions,
TmuxSessionAdapter,
} from "./tmux.js";
const runId = "00000000-0000-4000-8000-000000000001";
const sessionId = "00000000-0000-4000-8000-000000000042";
const dedupKey = "a".repeat(64);
interface RecordedTmuxCommand {
args: string[];
cwd?: string;
input?: string;
}
class RecordingTmuxDriver implements TmuxDriver {
readonly commands: RecordedTmuxCommand[] = [];
captureOutput = "first line\nsecond line\n";
panePid = 4242;
private readonly buffers = new Set<string>();
private readonly failures: { tmuxCommand: string; error: unknown }[] = [];
private readonly liveSessions = new Set<string>();
async exec(args: readonly string[], options: TmuxDriverExecOptions = {}): Promise<string> {
const command: RecordedTmuxCommand = { args: [...args] };
if (options.cwd !== undefined) {
command.cwd = options.cwd;
}
if (options.input !== undefined) {
command.input = options.input;
}
this.commands.push(command);
const [tmuxCommand] = args;
const failureIndex = this.failures.findIndex((failure) => failure.tmuxCommand === tmuxCommand);
if (failureIndex >= 0) {
const [failure] = this.failures.splice(failureIndex, 1);
throw failure?.error ?? new Error(`failed ${tmuxCommand}`);
}
if (tmuxCommand === "new-session") {
const sessionName = valueAfter(args, "-s");
this.liveSessions.add(sessionName);
return "";
}
if (tmuxCommand === "display-message") {
return `${this.panePid}\n`;
}
if (tmuxCommand === "load-buffer") {
this.buffers.add(valueAfter(args, "-b"));
return "";
}
if (tmuxCommand === "paste-buffer") {
const bufferName = valueAfter(args, "-b");
if (!this.buffers.has(bufferName)) {
throw new Error(`can't find buffer: ${bufferName}`);
}
return "";
}
if (tmuxCommand === "has-session") {
const target = valueAfter(args, "-t");
if (!this.liveSessions.has(sessionNameFromTarget(target))) {
throw new Error(`missing tmux session ${target}`);
}
return "";
}
if (tmuxCommand === "capture-pane") {
return this.captureOutput;
}
if (tmuxCommand === "respawn-pane") {
this.panePid += 1;
return "";
}
if (tmuxCommand === "kill-session") {
const target = valueAfter(args, "-t");
this.liveSessions.delete(sessionNameFromTarget(target));
return "";
}
return "";
}
commandCount(tmuxCommand: string): number {
return this.commands.filter((command) => command.args[0] === tmuxCommand).length;
}
failNext(tmuxCommand: string, error: unknown): void {
this.failures.push({ tmuxCommand, error });
}
deleteBuffer(bufferName: string): void {
this.buffers.delete(bufferName);
}
clearCommands(): void {
this.commands.length = 0;
}
}
describe("TmuxSessionAdapter", () => {
const tempRoots: string[] = [];
afterEach(() => {
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
it("starts a detached tmux session with a shell-quoted backend command and bootstrap prelude", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-start-"));
tempRoots.push(cwd);
const driver = new RecordingTmuxDriver();
const adapter = new TmuxSessionAdapter({
driver,
commandForBackend: () => ["codex", "--model", "gpt 5"],
sessionNameFactory: () => "devflow-test-session",
windowNameFactory: () => "implementer",
});
const handle = await adapter.start({
sessionId,
runId,
roleId: "implementer",
backend: "codex",
cwd,
envelopePrelude: "Follow the Devflow protocol",
});
expect(handle).toEqual({
sessionId,
pid: 4242,
tmuxSession: "devflow-test-session",
tmuxWindow: "implementer",
});
expect(driver.commands[0]).toEqual({
args: [
"new-session",
"-d",
"-s",
"devflow-test-session",
"-n",
"implementer",
"-c",
cwd,
"'codex' '--model' 'gpt 5'",
],
cwd,
});
expect(driver.commands).toContainEqual({
args: ["load-buffer", "-b", "devflow-prelude-00000000", "-"],
input: "Follow the Devflow protocol",
});
expect(driver.commands).toContainEqual({
args: [
"paste-buffer",
"-b",
"devflow-prelude-00000000",
"-t",
"devflow-test-session:implementer",
],
});
expect(driver.commands).toContainEqual({
args: ["send-keys", "-t", "devflow-test-session:implementer", "Enter"],
});
});
it("sends rendered prompt envelopes by paste-buffer and treats duplicate dedup keys as idempotent", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-prompt-"));
tempRoots.push(cwd);
const driver = new RecordingTmuxDriver();
const adapter = new TmuxSessionAdapter({
driver,
commandForBackend: () => ["claude"],
sessionNameFactory: () => "devflow-test-session",
windowNameFactory: () => "planner",
});
const handle = await adapter.start({
sessionId,
runId,
roleId: "planner",
backend: "claude",
cwd,
});
const prompt = envelope({ roleId: "planner" });
await expect(adapter.sendPrompt(handle, prompt)).resolves.toEqual({ promptId: dedupKey });
await expect(
adapter.sendPrompt(handle, {
...prompt,
uuid: "00000000-0000-4000-8000-000000000099",
}),
).resolves.toEqual({ promptId: dedupKey });
expect(driver.commandCount("load-buffer")).toBe(1);
expect(driver.commands).toContainEqual({
args: ["load-buffer", "-b", `devflow-prompt-${dedupKey.slice(0, 12)}`, "-"],
input: renderPromptEnvelope(prompt),
});
expect(driver.commands).toContainEqual({
args: [
"paste-buffer",
"-b",
`devflow-prompt-${dedupKey.slice(0, 12)}`,
"-t",
"devflow-test-session:planner",
],
});
const pasteFailurePrompt = envelope({
roleId: "planner",
dedupKey: "c".repeat(64),
});
driver.failNext("paste-buffer", new Error("paste failed before delivery"));
await expect(adapter.sendPrompt(handle, pasteFailurePrompt)).rejects.toMatchObject({
class: "recoverable",
code: "prompt_send_transient",
});
const pasteCountAfterPasteFailure = driver.commandCount("paste-buffer");
await expect(adapter.sendPrompt(handle, pasteFailurePrompt)).resolves.toEqual({
promptId: "c".repeat(64),
});
expect(driver.commandCount("paste-buffer")).toBe(pasteCountAfterPasteFailure + 1);
const partialFailurePrompt = envelope({
roleId: "planner",
dedupKey: "b".repeat(64),
});
driver.failNext("send-keys", new Error("send-keys lost acknowledgement"));
await expect(adapter.sendPrompt(handle, partialFailurePrompt)).rejects.toMatchObject({
class: "recoverable",
code: "prompt_send_transient",
});
const pasteCountAfterPartialFailure = driver.commandCount("paste-buffer");
const enterCountAfterPartialFailure = driver.commandCount("send-keys");
await expect(adapter.sendPrompt(handle, partialFailurePrompt)).resolves.toEqual({
promptId: "b".repeat(64),
});
expect(driver.commandCount("paste-buffer")).toBe(pasteCountAfterPartialFailure);
expect(driver.commandCount("send-keys")).toBe(enterCountAfterPartialFailure + 1);
});
it("does not re-paste after an uncertain paste-buffer timeout", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-uncertain-paste-"));
tempRoots.push(cwd);
const driver = new RecordingTmuxDriver();
const adapter = new TmuxSessionAdapter({
driver,
commandForBackend: () => ["claude"],
sessionNameFactory: () => "devflow-test-session",
windowNameFactory: () => "planner",
});
const handle = await adapter.start({
sessionId,
runId,
roleId: "planner",
backend: "claude",
cwd,
});
const prompt = envelope({
roleId: "planner",
dedupKey: "f".repeat(64),
});
driver.failNext(
"paste-buffer",
new TmuxCommandError("paste timed out", {
args: ["paste-buffer"],
reason: "timeout",
}),
);
await expect(adapter.sendPrompt(handle, prompt)).rejects.toMatchObject({
class: "recoverable",
code: "prompt_send_transient",
});
const pasteCountAfterTimeout = driver.commandCount("paste-buffer");
const enterCountAfterTimeout = driver.commandCount("send-keys");
await expect(adapter.sendPrompt(handle, prompt)).resolves.toEqual({
promptId: "f".repeat(64),
});
expect(driver.commandCount("paste-buffer")).toBe(pasteCountAfterTimeout);
expect(driver.commandCount("send-keys")).toBe(enterCountAfterTimeout + 1);
});
it("re-pastes a partially delivered prompt after rebootstrap", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-rebootstrap-prompt-"));
tempRoots.push(cwd);
const driver = new RecordingTmuxDriver();
const adapter = new TmuxSessionAdapter({
driver,
commandForBackend: () => ["claude"],
sessionNameFactory: () => "devflow-test-session",
windowNameFactory: () => "planner",
});
const handle = await adapter.start({
sessionId,
runId,
roleId: "planner",
backend: "claude",
cwd,
});
const prompt = envelope({
roleId: "planner",
dedupKey: "d".repeat(64),
});
driver.failNext("send-keys", new Error("send-keys lost acknowledgement"));
await expect(adapter.sendPrompt(handle, prompt)).rejects.toMatchObject({
class: "recoverable",
code: "prompt_send_transient",
});
await adapter.rebootstrap(handle);
driver.clearCommands();
await expect(adapter.sendPrompt(handle, prompt)).resolves.toEqual({
promptId: "d".repeat(64),
});
expect(driver.commands).toContainEqual({
args: ["load-buffer", "-b", "devflow-prompt-dddddddddddd", "-"],
input: renderPromptEnvelope(prompt),
});
expect(driver.commands).toContainEqual({
args: [
"paste-buffer",
"-b",
"devflow-prompt-dddddddddddd",
"-t",
"devflow-test-session:planner",
],
});
});
it("treats a previously delivered prompt as idempotent after rebootstrap", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-rebootstrap-sent-prompt-"));
tempRoots.push(cwd);
const driver = new RecordingTmuxDriver();
const adapter = new TmuxSessionAdapter({
driver,
commandForBackend: () => ["claude"],
sessionNameFactory: () => "devflow-test-session",
windowNameFactory: () => "planner",
});
const handle = await adapter.start({
sessionId,
runId,
roleId: "planner",
backend: "claude",
cwd,
});
const prompt = envelope({
roleId: "planner",
dedupKey: "e".repeat(64),
});
await adapter.sendPrompt(handle, prompt);
await adapter.rebootstrap(handle);
driver.clearCommands();
await expect(adapter.sendPrompt(handle, prompt)).resolves.toEqual({
promptId: "e".repeat(64),
});
expect(driver.commands).toEqual([]);
});
it("does not treat a recovered last prompt hash as durable prompt delivery proof", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-resume-not-dedup-"));
tempRoots.push(cwd);
const driver = new RecordingTmuxDriver();
const starter = new TmuxSessionAdapter({
driver,
commandForBackend: () => ["claude"],
sessionNameFactory: () => "devflow-test-session",
windowNameFactory: () => "planner",
});
await starter.start({
sessionId,
runId,
roleId: "planner",
backend: "claude",
cwd,
});
const prompt = envelope({ roleId: "planner" });
await starter.sendPrompt(
{
sessionId,
tmuxSession: "devflow-test-session",
tmuxWindow: "planner",
},
prompt,
);
const adapter = new TmuxSessionAdapter({ driver });
driver.clearCommands();
const resumed = await adapter.resume({
sessionId,
tmuxSession: "devflow-test-session",
tmuxWindow: "planner",
});
await expect(adapter.sendPrompt(resumed, prompt)).resolves.toEqual({
promptId: dedupKey,
});
expect(driver.commandCount("load-buffer")).toBe(1);
});
it("replays a preserved prelude buffer after resume and rebootstrap", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-resume-prelude-"));
tempRoots.push(cwd);
const driver = new RecordingTmuxDriver();
const starter = new TmuxSessionAdapter({
driver,
commandForBackend: () => ["codex"],
sessionNameFactory: () => "devflow-test-session",
windowNameFactory: () => "implementer",
});
await starter.start({
sessionId,
runId,
roleId: "implementer",
backend: "codex",
cwd,
envelopePrelude: "Follow the Devflow protocol",
});
const adapter = new TmuxSessionAdapter({ driver });
const resumed = await adapter.resume({
sessionId,
tmuxSession: "devflow-test-session",
tmuxWindow: "implementer",
});
driver.clearCommands();
await expect(adapter.rebootstrap(resumed)).resolves.toMatchObject({
sessionId,
});
expect(driver.commands).toContainEqual({
args: [
"paste-buffer",
"-b",
"devflow-prelude-00000000",
"-t",
"devflow-test-session:implementer",
],
});
});
it("fails closed when a recovered real-backend session cannot replay its prelude", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-missing-recovered-prelude-"));
tempRoots.push(cwd);
const driver = new RecordingTmuxDriver();
const starter = new TmuxSessionAdapter({
driver,
commandForBackend: () => ["codex"],
sessionNameFactory: () => "devflow-test-session",
windowNameFactory: () => "implementer",
});
await starter.start({
sessionId,
runId,
roleId: "implementer",
backend: "codex",
cwd,
envelopePrelude: "Follow the Devflow protocol",
});
const adapter = new TmuxSessionAdapter({ driver });
const resumed = await adapter.resume({
sessionId,
runId,
roleId: "implementer",
tmuxSession: "devflow-test-session",
tmuxWindow: "implementer",
requirePreludeReplay: true,
});
driver.deleteBuffer("devflow-prelude-00000000");
await expect(adapter.rebootstrap(resumed)).rejects.toMatchObject({
class: "recoverable",
code: "pane_briefly_unresponsive",
});
});
it("uses recovered transcript baseline to continue capture after resume", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-resume-transcript-"));
tempRoots.push(cwd);
const driver = new RecordingTmuxDriver();
const starter = new TmuxSessionAdapter({
driver,
commandForBackend: () => ["codex"],
sessionNameFactory: () => "devflow-test-session",
windowNameFactory: () => "implementer",
now: () => new Date("2026-05-13T00:00:00.000Z"),
});
await starter.start({
sessionId,
runId,
roleId: "implementer",
backend: "codex",
cwd,
});
const adapter = new TmuxSessionAdapter({
driver,
now: () => new Date("2026-05-13T00:00:00.000Z"),
});
driver.captureOutput = "first line\nsecond line\nthird line\n";
const resumed = await adapter.resume({
sessionId,
tmuxSession: "devflow-test-session",
tmuxWindow: "implementer",
transcriptBaseline: {
startSeq: 1n,
lines: ["first line", "second line"],
},
});
await expect(collect(adapter.capture(resumed, 2n))).resolves.toEqual([
{
seq: 3n,
content: "third line",
capturedAt: new Date("2026-05-13T00:00:00.000Z"),
},
]);
driver.captureOutput = "rolled first line\nchanged second line\nchanged third line\n";
await expect(collect(adapter.capture(resumed, 2n))).rejects.toMatchObject({
class: "human_required",
code: "transcript_history_unavailable",
});
});
it("uses a transcript baseline passed directly to capture", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-direct-baseline-transcript-"));
tempRoots.push(cwd);
const driver = new RecordingTmuxDriver();
const starter = new TmuxSessionAdapter({
driver,
commandForBackend: () => ["codex"],
sessionNameFactory: () => "devflow-test-session",
windowNameFactory: () => "implementer",
now: () => new Date("2026-05-13T00:00:00.000Z"),
});
await starter.start({
sessionId,
runId,
roleId: "implementer",
backend: "codex",
cwd,
});
const adapter = new TmuxSessionAdapter({
driver,
now: () => new Date("2026-05-13T00:00:00.000Z"),
});
driver.captureOutput = "first line\nsecond line\nthird line\n";
await expect(
collect(
adapter.capture(
{
sessionId,
tmuxSession: "devflow-test-session",
tmuxWindow: "implementer",
transcriptBaseline: {
startSeq: 1n,
lines: ["first line", "second line"],
},
},
2n,
),
),
).resolves.toEqual([
{
seq: 3n,
content: "third line",
capturedAt: new Date("2026-05-13T00:00:00.000Z"),
},
]);
});
it("continues capture from a retained suffix when tmux history partially rolled", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-rolled-suffix-transcript-"));
tempRoots.push(cwd);
const driver = new RecordingTmuxDriver();
const starter = new TmuxSessionAdapter({
driver,
commandForBackend: () => ["codex"],
sessionNameFactory: () => "devflow-test-session",
windowNameFactory: () => "implementer",
now: () => new Date("2026-05-13T00:00:00.000Z"),
});
await starter.start({
sessionId,
runId,
roleId: "implementer",
backend: "codex",
cwd,
});
const baselineLines = Array.from({ length: 200 }, (_, index) => `line ${801 + index}`);
const retainedHistory = Array.from({ length: 105 }, (_, index) => `line ${901 + index}`);
const adapter = new TmuxSessionAdapter({
driver,
now: () => new Date("2026-05-13T00:00:00.000Z"),
});
driver.captureOutput = `${retainedHistory.join("\n")}\n`;
const resumed = await adapter.resume({
sessionId,
tmuxSession: "devflow-test-session",
tmuxWindow: "implementer",
transcriptBaseline: {
startSeq: 801n,
lines: baselineLines,
},
});
await expect(collect(adapter.capture(resumed, 1000n))).resolves.toEqual([
{
seq: 1001n,
content: "line 1001",
capturedAt: new Date("2026-05-13T00:00:00.000Z"),
},
{
seq: 1002n,
content: "line 1002",
capturedAt: new Date("2026-05-13T00:00:00.000Z"),
},
{
seq: 1003n,
content: "line 1003",
capturedAt: new Date("2026-05-13T00:00:00.000Z"),
},
{
seq: 1004n,
content: "line 1004",
capturedAt: new Date("2026-05-13T00:00:00.000Z"),
},
{
seq: 1005n,
content: "line 1005",
capturedAt: new Date("2026-05-13T00:00:00.000Z"),
},
]);
});
it("fails closed when a recovered transcript baseline matches multiple history positions", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-ambiguous-transcript-"));
tempRoots.push(cwd);
const driver = new RecordingTmuxDriver();
const starter = new TmuxSessionAdapter({
driver,
commandForBackend: () => ["codex"],
sessionNameFactory: () => "devflow-test-session",
windowNameFactory: () => "implementer",
});
await starter.start({
sessionId,
runId,
roleId: "implementer",
backend: "codex",
cwd,
});
const adapter = new TmuxSessionAdapter({ driver });
driver.captureOutput = "A\nB\nA\nB\nC\n";
const resumed = await adapter.resume({
sessionId,
tmuxSession: "devflow-test-session",
tmuxWindow: "implementer",
transcriptBaseline: {
startSeq: 3n,
lines: ["A", "B"],
},
});
await expect(collect(adapter.capture(resumed, 4n))).rejects.toMatchObject({
class: "human_required",
code: "transcript_history_unavailable",
});
});
it("probes, resumes, rebootstraps, captures transcript lines, and disposes tmux sessions", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-lifecycle-"));
tempRoots.push(cwd);
const driver = new RecordingTmuxDriver();
const adapter = new TmuxSessionAdapter({
driver,
commandForBackend: () => ["codex"],
sessionNameFactory: () => "devflow-test-session",
windowNameFactory: () => "implementer",
now: () => new Date("2026-05-13T00:00:00.000Z"),
});
const handle = await adapter.start({
sessionId,
runId,
roleId: "implementer",
backend: "codex",
cwd,
envelopePrelude: "Follow the Devflow protocol",
});
await expect(adapter.probe(handle)).resolves.toEqual({
alive: true,
hint: "tmux_liveness_only",
paneActive: true,
lastOutputAt: new Date("2026-05-13T00:00:00.000Z"),
});
await expect(adapter.resume(handle)).resolves.toMatchObject({
sessionId,
pid: 4242,
tmuxSession: "devflow-test-session",
tmuxWindow: "implementer",
});
driver.clearCommands();
await expect(adapter.rebootstrap(handle)).resolves.toMatchObject({
sessionId,
pid: 4243,
});
expect(driver.commands).toContainEqual({
args: ["load-buffer", "-b", "devflow-prelude-00000000", "-"],
input: "Follow the Devflow protocol",
});
const chunks = await collect(adapter.capture(handle, 0n));
expect(chunks).toEqual([
{
seq: 1n,
content: "first line",
capturedAt: new Date("2026-05-13T00:00:00.000Z"),
},
{
seq: 2n,
content: "second line",
capturedAt: new Date("2026-05-13T00:00:00.000Z"),
},
]);
await expect(collect(adapter.capture(handle, 2n))).resolves.toEqual([]);
expect(driver.commands).toContainEqual({
args: ["capture-pane", "-p", "-t", "devflow-test-session:implementer", "-S", "-"],
});
driver.captureOutput = "only current line\n";
await expect(collect(adapter.capture(handle, 2n))).rejects.toMatchObject({
class: "human_required",
code: "transcript_history_unavailable",
});
driver.captureOutput = "rolled first line\nchanged second line\n";
await expect(collect(adapter.capture(handle, 1n))).rejects.toMatchObject({
class: "human_required",
code: "transcript_history_unavailable",
});
await adapter.dispose(handle);
await expect(adapter.probe(handle)).resolves.toMatchObject({
alive: false,
paneActive: false,
});
});
it("cleans up a tmux session when bootstrap fails after new-session", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-start-cleanup-"));
tempRoots.push(cwd);
const driver = new RecordingTmuxDriver();
driver.failNext("display-message", new Error("pid unavailable"));
const adapter = new TmuxSessionAdapter({
driver,
commandForBackend: () => ["codex"],
sessionNameFactory: () => "devflow-test-session",
windowNameFactory: () => "implementer",
});
await expect(
adapter.start({
sessionId,
runId,
roleId: "implementer",
backend: "codex",
cwd,
}),
).rejects.toMatchObject({
class: "recoverable",
});
expect(driver.commands).toContainEqual({
args: ["kill-session", "-t", "devflow-test-session"],
});
driver.failNext("display-message", new Error("pid unavailable"));
driver.failNext("kill-session", new Error("permission denied"));
await expect(
adapter.start({
sessionId,
runId,
roleId: "implementer",
backend: "codex",
cwd,
}),
).rejects.toMatchObject({
class: "recoverable",
recoveryHint: "permission denied",
});
});
it("only ignores dispose failures when the tmux session is already gone", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-dispose-"));
tempRoots.push(cwd);
const driver = new RecordingTmuxDriver();
const adapter = new TmuxSessionAdapter({
driver,
commandForBackend: () => ["codex"],
sessionNameFactory: () => "devflow-test-session",
windowNameFactory: () => "implementer",
});
const handle = await adapter.start({
sessionId,
runId,
roleId: "implementer",
backend: "codex",
cwd,
});
driver.failNext("kill-session", new Error("permission denied"));
await expect(adapter.dispose(handle)).rejects.toMatchObject({
class: "recoverable",
code: "pane_briefly_unresponsive",
});
driver.failNext("kill-session", new Error("missing tmux session devflow-test-session"));
await expect(adapter.dispose(handle)).resolves.toBeUndefined();
});
it("classifies missing backend commands as human-required backend_unavailable", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-tmux-unavailable-"));
tempRoots.push(cwd);
const adapter = new TmuxSessionAdapter({
driver: new RecordingTmuxDriver(),
commandForBackend: () => undefined,
});
await expect(
adapter.start({
sessionId,
runId,
roleId: "implementer",
backend: "codex",
cwd,
}),
).rejects.toMatchObject({
class: "human_required",
code: "backend_unavailable",
});
});
it("classifies missing session cwd as fatal workspace_permissions", async () => {
const adapter = new TmuxSessionAdapter({
driver: new RecordingTmuxDriver(),
commandForBackend: () => ["codex"],
});
await expect(
adapter.start({
sessionId,
runId,
roleId: "implementer",
backend: "codex",
cwd: join(tmpdir(), `devflow-missing-cwd-${sessionId}`),
}),
).rejects.toMatchObject({
class: "fatal",
code: "workspace_permissions",
});
});
});
function envelope(overrides: Partial<PromptEnvelope> = {}): PromptEnvelope {
return {
uuid: "00000000-0000-4000-8000-000000000010",
runId,
roleId: "implementer",
phaseKey: "implement",
attempt: 0,
expectedArtifact: "/tmp/devflow-artifact.json",
expectedSchema: "dev/spec@1",
dedupKey,
instructions: "Build the artifact",
...overrides,
};
}
async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> {
const items: T[] = [];
for await (const item of iterable) {
items.push(item);
}
return items;
}
function valueAfter(values: readonly string[], flag: string): string {
const index = values.indexOf(flag);
const value = values[index + 1];
if (index < 0 || value === undefined) {
throw new DevflowError(`Missing tmux flag ${flag}`, {
class: "fatal",
code: "test_driver_missing_flag",
});
}
return value;
}
function sessionNameFromTarget(target: string): string {
return target.split(":")[0] ?? target;
}

View File

@@ -1,857 +0,0 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { DevflowError, type PromptEnvelope, renderPromptEnvelope } from "@devflow/core";
import type {
ProbeResult,
SessionAdapter,
SessionHandle,
StartInput,
TranscriptChunk,
} from "./adapter.js";
export interface TmuxDriverExecOptions {
cwd?: string;
input?: string;
timeoutMs?: number;
}
export interface TmuxDriver {
exec(args: readonly string[], options?: TmuxDriverExecOptions): Promise<string>;
}
export interface ChildProcessTmuxDriverOptions {
binaryPath?: string;
timeoutMs?: number;
}
export class TmuxCommandError extends Error {
readonly args: readonly string[];
readonly stderr: string | undefined;
readonly exitCode: number | undefined;
readonly reason: "exit_nonzero" | "spawn_failed" | "timeout";
constructor(
message: string,
options: {
args: readonly string[];
reason: "exit_nonzero" | "spawn_failed" | "timeout";
stderr?: string;
exitCode?: number;
cause?: unknown;
},
) {
super(message, { cause: options.cause });
this.name = "TmuxCommandError";
this.args = options.args;
this.reason = options.reason;
this.stderr = options.stderr;
this.exitCode = options.exitCode;
}
}
export class ChildProcessTmuxDriver implements TmuxDriver {
private readonly binaryPath: string;
private readonly timeoutMs: number;
constructor(options: ChildProcessTmuxDriverOptions = {}) {
this.binaryPath = options.binaryPath ?? "tmux";
this.timeoutMs = options.timeoutMs ?? 10_000;
}
exec(args: readonly string[], options: TmuxDriverExecOptions = {}): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(this.binaryPath, [...args], {
cwd: options.cwd,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
const timer = setTimeout(() => {
if (settled) {
return;
}
settled = true;
child.kill("SIGKILL");
reject(
new TmuxCommandError(`tmux command timed out: ${args.join(" ")}`, {
args,
reason: "timeout",
stderr,
}),
);
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", (cause) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
reject(
new TmuxCommandError(`failed to spawn tmux: ${this.binaryPath}`, {
args,
reason: "spawn_failed",
cause,
}),
);
});
child.on("close", (exitCode) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
if (exitCode === 0) {
resolve(stdout);
return;
}
reject(
new TmuxCommandError(`tmux command failed: ${args.join(" ")}`, {
args,
reason: "exit_nonzero",
stderr,
...(exitCode === null ? {} : { exitCode }),
}),
);
});
if (options.input !== undefined) {
child.stdin.end(options.input);
} else {
child.stdin.end();
}
});
}
}
export interface TmuxSessionAdapterOptions {
driver?: TmuxDriver;
sessionIdFactory?: () => string;
sessionNameFactory?: (input: StartInput & { sessionId: string }) => string;
windowNameFactory?: (input: StartInput & { sessionId: string }) => string;
commandForBackend?: (input: StartInput) => readonly string[] | undefined;
now?: () => Date;
}
interface TmuxSessionRecord {
handle: SessionHandle;
pastedDedupKeys: Set<string>;
sentDedupKeys: Set<string>;
runId?: string;
roleId?: string;
envelopePrelude?: string;
requirePreludeReplay: boolean;
transcriptAnchor: TranscriptAnchor | undefined;
lastOutputAt?: Date;
disposed: boolean;
}
interface TranscriptAnchor {
startSeq: bigint;
lines: string[];
}
export class TmuxSessionAdapter implements SessionAdapter {
private readonly driver: TmuxDriver;
private readonly sessionIdFactory: () => string;
private readonly sessionNameFactory: (input: StartInput & { sessionId: string }) => string;
private readonly windowNameFactory: (input: StartInput & { sessionId: string }) => string;
private readonly commandForBackend: (input: StartInput) => readonly string[] | undefined;
private readonly now: () => Date;
private readonly records = new Map<string, TmuxSessionRecord>();
constructor(options: TmuxSessionAdapterOptions = {}) {
this.driver = options.driver ?? new ChildProcessTmuxDriver();
this.sessionIdFactory = options.sessionIdFactory ?? randomUUID;
this.sessionNameFactory =
options.sessionNameFactory ??
((input) => `devflow_${compactIdentifier(input.sessionId).slice(0, 32)}`);
this.windowNameFactory = options.windowNameFactory ?? ((input) => input.roleId);
this.commandForBackend = options.commandForBackend ?? defaultCommandForBackend;
this.now = options.now ?? (() => new Date());
}
async start(input: StartInput): Promise<SessionHandle> {
if (!existsSync(input.cwd)) {
throw new DevflowError(`Session cwd does not exist: ${input.cwd}`, {
class: "fatal",
code: "workspace_permissions",
runId: input.runId,
});
}
const command = this.commandForBackend(input);
if (command === undefined || command.length === 0) {
throw new DevflowError(`No tmux backend command registered for ${input.backend}`, {
class: "human_required",
code: "backend_unavailable",
runId: input.runId,
});
}
const sessionId = input.sessionId ?? this.sessionIdFactory();
const factoryInput = { ...input, sessionId };
const tmuxSession = sanitizeTmuxName(this.sessionNameFactory(factoryInput), "session");
const tmuxWindow = sanitizeTmuxName(this.windowNameFactory(factoryInput), "main");
const handle: SessionHandle = { sessionId, tmuxSession, tmuxWindow };
let sessionCreated = false;
try {
await this.runTmux(
[
"new-session",
"-d",
"-s",
tmuxSession,
"-n",
tmuxWindow,
"-c",
input.cwd,
shellJoin(command),
],
{ cwd: input.cwd },
input.runId,
);
sessionCreated = true;
const record: TmuxSessionRecord = {
handle,
pastedDedupKeys: new Set(),
sentDedupKeys: new Set(),
runId: input.runId,
roleId: input.roleId,
...(input.envelopePrelude === undefined ? {} : { envelopePrelude: input.envelopePrelude }),
requirePreludeReplay:
input.envelopePrelude !== undefined && input.envelopePrelude.length > 0,
transcriptAnchor: undefined,
lastOutputAt: this.now(),
disposed: false,
};
this.records.set(sessionId, record);
if (input.envelopePrelude !== undefined && input.envelopePrelude.length > 0) {
await this.pasteText(
handle,
preludeBufferName(sessionId),
input.envelopePrelude,
input.runId,
);
}
const pid = await this.readPanePid(handle, input.runId);
const handleWithPid = { ...handle, pid };
record.handle = handleWithPid;
return handleWithPid;
} catch (error) {
this.records.delete(sessionId);
if (sessionCreated) {
await this.killSession(handle, { ignoreMissing: true });
}
throw error;
}
}
async sendPrompt(handle: SessionHandle, envelope: PromptEnvelope): Promise<{ promptId: string }> {
const record = this.recordFor(handle);
if (record.runId !== undefined && envelope.runId !== record.runId) {
throw new DevflowError("Prompt does not match tmux session run", {
class: "fatal",
code: "prompt_session_mismatch",
runId: envelope.runId,
});
}
if (record.roleId !== undefined && envelope.roleId !== record.roleId) {
throw new DevflowError("Prompt does not match tmux session role", {
class: "fatal",
code: "prompt_session_mismatch",
runId: envelope.runId,
});
}
if (record.sentDedupKeys.has(envelope.dedupKey)) {
return { promptId: envelope.dedupKey };
}
const bufferName = promptBufferName(envelope.dedupKey);
if (!record.pastedDedupKeys.has(envelope.dedupKey)) {
await this.loadBuffer(
bufferName,
renderPromptEnvelope(envelope),
envelope.runId,
"prompt_send_transient",
);
try {
await this.pasteBuffer(record.handle, bufferName, envelope.runId, "prompt_send_transient");
} catch (error) {
if (isUncertainPasteFailure(error)) {
record.pastedDedupKeys.add(envelope.dedupKey);
}
throw error;
}
record.pastedDedupKeys.add(envelope.dedupKey);
}
await this.sendEnter(record.handle, envelope.runId, "prompt_send_transient");
record.sentDedupKeys.add(envelope.dedupKey);
record.lastOutputAt = this.now();
return { promptId: envelope.dedupKey };
}
async probe(handle: SessionHandle): Promise<ProbeResult> {
const probeHandle = this.handleFor(handle);
try {
await this.driver.exec(["has-session", "-t", tmuxSessionName(probeHandle)]);
await this.readPanePid(probeHandle);
} catch (error) {
return {
alive: false,
paneActive: false,
hint: recoveryHint(error),
};
}
const result: ProbeResult = {
alive: true,
paneActive: true,
hint: "tmux_liveness_only",
};
const lastOutputAt = this.records.get(handle.sessionId)?.lastOutputAt;
if (lastOutputAt !== undefined) {
return { ...result, lastOutputAt };
}
return result;
}
async resume(handle: SessionHandle): Promise<SessionHandle> {
const resumeHandle = this.handleFor(handle);
await this.runTmux(["has-session", "-t", tmuxSessionName(resumeHandle)]);
const pid = await this.readPanePid(resumeHandle);
const resumed: SessionHandle = { ...resumeHandle, pid };
const record = this.records.get(handle.sessionId);
if (record === undefined) {
this.records.set(handle.sessionId, {
handle: resumed,
pastedDedupKeys: new Set(),
sentDedupKeys: new Set(),
...(handle.runId === undefined ? {} : { runId: handle.runId }),
...(handle.roleId === undefined ? {} : { roleId: handle.roleId }),
...(handle.envelopePrelude === undefined
? {}
: { envelopePrelude: handle.envelopePrelude }),
requirePreludeReplay: handle.requirePreludeReplay === true,
...(handle.transcriptBaseline === undefined
? { transcriptAnchor: undefined }
: {
transcriptAnchor: {
startSeq: handle.transcriptBaseline.startSeq,
lines: [...handle.transcriptBaseline.lines],
},
}),
disposed: false,
});
} else {
record.handle = resumed;
if (handle.runId !== undefined) {
record.runId = handle.runId;
}
if (handle.roleId !== undefined) {
record.roleId = handle.roleId;
}
if (handle.envelopePrelude !== undefined) {
record.envelopePrelude = handle.envelopePrelude;
}
record.requirePreludeReplay =
record.requirePreludeReplay || handle.requirePreludeReplay === true;
record.disposed = false;
}
return resumed;
}
async rebootstrap(handle: SessionHandle): Promise<SessionHandle> {
const rebootHandle = this.handleFor(handle);
const record = this.recordFor(handle);
await this.runTmux(["respawn-pane", "-k", "-t", tmuxTarget(rebootHandle)]);
const pid = await this.readPanePid(rebootHandle);
const rebootstrapped: SessionHandle = { ...rebootHandle, pid };
record.handle = rebootstrapped;
record.pastedDedupKeys.clear();
if (record.envelopePrelude !== undefined && record.envelopePrelude.length > 0) {
await this.pasteText(
rebootstrapped,
preludeBufferName(rebootstrapped.sessionId),
record.envelopePrelude,
record.runId,
);
} else if (record.requirePreludeReplay) {
await this.pasteExistingBuffer(rebootstrapped, preludeBufferName(rebootstrapped.sessionId), {
ignoreMissing: false,
});
} else {
await this.pasteExistingBuffer(rebootstrapped, preludeBufferName(rebootstrapped.sessionId), {
ignoreMissing: true,
});
}
record.lastOutputAt = this.now();
record.disposed = false;
return rebootstrapped;
}
async *capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk> {
if (fromSeq > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new DevflowError("Transcript cursor is too large for tmux line capture", {
class: "fatal",
code: "transcript_sequence_invalid",
});
}
const record = this.recordFor(handle);
const captureHandle = record.handle;
const stdout = await this.runTmux([
"capture-pane",
"-p",
"-t",
tmuxTarget(captureHandle),
"-S",
"-",
]);
const lines = trimTrailingEmptyLines(stdout.replace(/\r\n/g, "\n").split("\n"));
const anchor = record.transcriptAnchor;
const firstAvailableSeq = firstAvailableTranscriptSeq(lines, anchor, fromSeq);
const lastAvailableSeq = firstAvailableSeq + BigInt(lines.length) - 1n;
if (fromSeq > lastAvailableSeq) {
throw transcriptHistoryUnavailable("tmux transcript cursor is ahead of available history");
}
const chunks: TranscriptChunk[] = [];
let seq = fromSeq;
const startIndex = Number(fromSeq - firstAvailableSeq + 1n);
for (const line of lines.slice(startIndex)) {
seq += 1n;
chunks.push({
seq,
content: line,
capturedAt: this.now(),
});
}
const nextAnchor = tailTranscriptAnchor(firstAvailableSeq, lines);
if (nextAnchor === undefined) {
record.transcriptAnchor = undefined;
} else {
record.transcriptAnchor = nextAnchor;
}
for (const chunk of chunks) {
yield chunk;
}
}
async dispose(handle: SessionHandle): Promise<void> {
const disposeHandle = this.handleFor(handle);
await this.killSession(disposeHandle, { ignoreMissing: true });
const record = this.records.get(handle.sessionId);
if (record !== undefined) {
record.disposed = true;
}
}
private recordFor(handle: SessionHandle): TmuxSessionRecord {
const existing = this.records.get(handle.sessionId);
if (existing !== undefined && !existing.disposed) {
mergeRecordFromHandle(existing, handle);
return existing;
}
if (existing?.disposed === true) {
throw new DevflowError("Tmux session is disposed", {
class: "recoverable",
code: "pane_briefly_unresponsive",
});
}
const resolvedHandle = this.handleFor(handle);
const record: TmuxSessionRecord = {
handle: resolvedHandle,
pastedDedupKeys: new Set(),
sentDedupKeys: new Set(),
...(handle.runId === undefined ? {} : { runId: handle.runId }),
...(handle.roleId === undefined ? {} : { roleId: handle.roleId }),
...(handle.envelopePrelude === undefined ? {} : { envelopePrelude: handle.envelopePrelude }),
requirePreludeReplay: handle.requirePreludeReplay === true,
transcriptAnchor:
handle.transcriptBaseline === undefined
? undefined
: {
startSeq: handle.transcriptBaseline.startSeq,
lines: [...handle.transcriptBaseline.lines],
},
disposed: false,
};
this.records.set(handle.sessionId, record);
return record;
}
private handleFor(handle: SessionHandle): SessionHandle {
const record = this.records.get(handle.sessionId);
if (record !== undefined) {
mergeRecordFromHandle(record, handle);
}
const existing = record?.handle ?? handle;
const tmuxSession = existing.tmuxSession ?? defaultSessionName(existing.sessionId);
return {
...existing,
tmuxSession,
};
}
private async pasteText(
handle: SessionHandle,
bufferName: string,
text: string,
runId?: string,
): Promise<void> {
await this.loadBuffer(bufferName, text, runId);
await this.pasteBuffer(handle, bufferName, runId);
await this.sendEnter(handle, runId);
}
private async loadBuffer(
bufferName: string,
text: string,
runId?: string,
recoverableCode = "pane_briefly_unresponsive",
): Promise<void> {
await this.runTmux(
["load-buffer", "-b", bufferName, "-"],
{ input: text },
runId,
recoverableCode,
);
}
private async pasteBuffer(
handle: SessionHandle,
bufferName: string,
runId?: string,
recoverableCode = "pane_briefly_unresponsive",
): Promise<void> {
await this.runTmux(
["paste-buffer", "-b", bufferName, "-t", tmuxTarget(handle)],
undefined,
runId,
recoverableCode,
);
}
private async sendEnter(
handle: SessionHandle,
runId?: string,
recoverableCode = "pane_briefly_unresponsive",
): Promise<void> {
await this.runTmux(
["send-keys", "-t", tmuxTarget(handle), "Enter"],
undefined,
runId,
recoverableCode,
);
}
private async pasteExistingBuffer(
handle: SessionHandle,
bufferName: string,
options: { ignoreMissing: boolean },
): Promise<void> {
try {
await this.pasteBuffer(handle, bufferName);
await this.sendEnter(handle);
} catch (error) {
if (options.ignoreMissing && isMissingBufferFailure(error)) {
return;
}
throw error;
}
}
private async readPanePid(handle: SessionHandle, runId?: string): Promise<number> {
const stdout = await this.runTmux(
["display-message", "-p", "-t", tmuxTarget(handle), "#{pane_pid}"],
undefined,
runId,
);
const pid = Number.parseInt(stdout.trim(), 10);
if (!Number.isInteger(pid) || pid <= 0) {
throw new DevflowError(`Unable to parse tmux pane pid from ${JSON.stringify(stdout)}`, {
class: "recoverable",
code: "pane_briefly_unresponsive",
...(runId === undefined ? {} : { runId }),
});
}
return pid;
}
private async runTmux(
args: readonly string[],
options?: TmuxDriverExecOptions,
runId?: string,
recoverableCode = "pane_briefly_unresponsive",
): Promise<string> {
try {
return await this.driver.exec(args, options);
} catch (cause) {
throw classifyTmuxFailure(cause, runId, recoverableCode);
}
}
private async killSession(
handle: SessionHandle,
options: { ignoreMissing: boolean },
): Promise<void> {
try {
await this.driver.exec(["kill-session", "-t", tmuxSessionName(handle)]);
} catch (cause) {
if (options.ignoreMissing && isMissingSessionFailure(cause)) {
return;
}
throw classifyTmuxFailure(cause);
}
}
}
function defaultCommandForBackend(input: StartInput): readonly string[] | undefined {
if (input.backend === "fake") {
return undefined;
}
return [input.backend];
}
function mergeRecordFromHandle(record: TmuxSessionRecord, handle: SessionHandle): void {
record.handle = mergeSessionHandles(record.handle, handle);
if (handle.runId !== undefined) {
record.runId = handle.runId;
}
if (handle.roleId !== undefined) {
record.roleId = handle.roleId;
}
if (handle.envelopePrelude !== undefined) {
record.envelopePrelude = handle.envelopePrelude;
}
record.requirePreludeReplay = record.requirePreludeReplay || handle.requirePreludeReplay === true;
if (handle.transcriptBaseline !== undefined) {
record.transcriptAnchor = {
startSeq: handle.transcriptBaseline.startSeq,
lines: [...handle.transcriptBaseline.lines],
};
}
}
function mergeSessionHandles(tracked: SessionHandle, incoming: SessionHandle): SessionHandle {
return {
...tracked,
...Object.fromEntries(Object.entries(incoming).filter(([, value]) => value !== undefined)),
};
}
function tmuxSessionName(handle: SessionHandle): string {
return handle.tmuxSession ?? defaultSessionName(handle.sessionId);
}
function tmuxTarget(handle: SessionHandle): string {
const sessionName = tmuxSessionName(handle);
return handle.tmuxWindow === undefined ? sessionName : `${sessionName}:${handle.tmuxWindow}`;
}
function defaultSessionName(sessionId: string): string {
return `devflow_${compactIdentifier(sessionId).slice(0, 32)}`;
}
function preludeBufferName(sessionId: string): string {
return `devflow-prelude-${compactIdentifier(sessionId).slice(0, 8)}`;
}
function promptBufferName(dedupKey: string): string {
return `devflow-prompt-${dedupKey.slice(0, 12)}`;
}
function sanitizeTmuxName(value: string, fallback: string): string {
const sanitized = value.replace(/[^A-Za-z0-9_-]/g, "_").replace(/^_+|_+$/g, "");
return sanitized.length === 0 ? fallback : sanitized.slice(0, 80);
}
function compactIdentifier(value: string): string {
return value.replace(/[^A-Za-z0-9]/g, "");
}
function shellJoin(argv: readonly string[]): string {
return argv.map(shellQuote).join(" ");
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`;
}
function trimTrailingEmptyLines(lines: string[]): string[] {
const trimmed = [...lines];
while (trimmed.length > 0 && trimmed[trimmed.length - 1] === "") {
trimmed.pop();
}
return trimmed;
}
function firstAvailableTranscriptSeq(
lines: readonly string[],
anchor: TranscriptAnchor | undefined,
fromSeq: bigint,
): bigint {
if (anchor === undefined) {
if (fromSeq > 0n) {
throw transcriptHistoryUnavailable("session was recovered without transcript baseline");
}
return 1n;
}
if (anchor.lines.length === 0) {
if (fromSeq > 0n) {
throw transcriptHistoryUnavailable("transcript baseline is empty");
}
return 1n;
}
const anchorMatch = findAnchorMatch(lines, anchor, fromSeq);
if (anchorMatch === undefined) {
throw transcriptHistoryUnavailable("tmux history rolled, was cleared, or was truncated");
}
const firstSeq =
anchor.startSeq + BigInt(anchorMatch.anchorOffset) - BigInt(anchorMatch.historyIndex);
if (firstSeq < 1n) {
throw transcriptHistoryUnavailable("tmux history does not align with transcript baseline");
}
return firstSeq;
}
function findAnchorMatch(
lines: readonly string[],
anchor: TranscriptAnchor,
fromSeq: bigint,
): { anchorOffset: number; historyIndex: number } | undefined {
for (let anchorOffset = 0; anchorOffset < anchor.lines.length; anchorOffset += 1) {
const suffix = anchor.lines.slice(anchorOffset);
let match: { anchorOffset: number; historyIndex: number } | undefined;
for (let historyIndex = 0; historyIndex <= lines.length - suffix.length; historyIndex += 1) {
if (!suffix.every((line, offset) => lines[historyIndex + offset] === line)) {
continue;
}
const firstSeq = anchor.startSeq + BigInt(anchorOffset) - BigInt(historyIndex);
const lastSeq = firstSeq + BigInt(lines.length) - 1n;
if (firstSeq < 1n || fromSeq < firstSeq - 1n || fromSeq > lastSeq) {
continue;
}
if (match !== undefined) {
throw transcriptHistoryUnavailable(
"transcript baseline matches multiple history positions",
);
}
match = { anchorOffset, historyIndex };
}
if (match !== undefined) {
return match;
}
}
return undefined;
}
function tailTranscriptAnchor(
firstAvailableSeq: bigint,
lines: readonly string[],
): TranscriptAnchor | undefined {
if (lines.length === 0) {
return undefined;
}
const tail = lines.slice(-transcriptAnchorLineLimit);
return {
startSeq: firstAvailableSeq + BigInt(lines.length - tail.length),
lines: tail,
};
}
function transcriptHistoryUnavailable(reason: string): DevflowError {
return new DevflowError("Tmux transcript history no longer contains requested cursor", {
class: "human_required",
code: "transcript_history_unavailable",
recoveryHint: reason,
});
}
const transcriptAnchorLineLimit = 200;
function classifyTmuxFailure(
cause: unknown,
runId?: string,
recoverableCode = "pane_briefly_unresponsive",
): DevflowError {
if (cause instanceof DevflowError) {
return cause;
}
if (cause instanceof TmuxCommandError && cause.reason === "spawn_failed") {
return new DevflowError("tmux is unavailable", {
class: "human_required",
code: "backend_unavailable",
...(runId === undefined ? {} : { runId }),
recoveryHint: "install tmux >= 3.3 or configure the tmux binary path",
cause,
});
}
return new DevflowError("tmux session is briefly unresponsive", {
class: "recoverable",
code: recoverableCode,
...(runId === undefined ? {} : { runId }),
recoveryHint: recoveryHint(cause),
cause,
});
}
function isMissingSessionFailure(error: unknown): boolean {
const hint = recoveryHint(error).toLowerCase();
return (
hint.includes("missing tmux session") ||
hint.includes("can't find session") ||
hint.includes("can't find pane") ||
hint.includes("no server running")
);
}
function isMissingBufferFailure(error: unknown): boolean {
const hint = recoveryHint(error).toLowerCase();
return hint.includes("can't find buffer") || hint.includes("no buffer");
}
function isUncertainPasteFailure(error: unknown): boolean {
return (
error instanceof DevflowError &&
error.cause instanceof TmuxCommandError &&
error.cause.reason === "timeout"
);
}
function recoveryHint(error: unknown): string {
if (error instanceof DevflowError) {
if (error.recoveryHint !== undefined && error.recoveryHint.length > 0) {
return error.recoveryHint;
}
if (error.cause !== undefined) {
return recoveryHint(error.cause);
}
}
if (error instanceof TmuxCommandError && error.stderr !== undefined && error.stderr.length > 0) {
return error.stderr;
}
if (error instanceof Error) {
return error.message;
}
return "tmux command failed";
}

View File

@@ -1,154 +0,0 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { PromptEnvelope } from "@devflow/core";
import type { TuiTranscriptRepository } from "@devflow/db";
import type { TranscriptChunk } from "./adapter.js";
import { FakeSessionAdapter } from "./fake.js";
import { captureAndPersistTranscript } from "./transcript.js";
const runId = "00000000-0000-4000-8000-000000000001";
function envelope(overrides: Partial<PromptEnvelope> = {}): PromptEnvelope {
return {
uuid: "00000000-0000-4000-8000-000000000010",
runId,
roleId: "implementer",
phaseKey: "implement",
attempt: 0,
expectedArtifact: join(mkdtempSync(join(tmpdir(), "devflow-transcript-artifact-")), "out.json"),
expectedSchema: "dev/spec@1",
dedupKey: "a".repeat(64),
instructions: "Scenario: timeout\nNo artifact needed",
...overrides,
};
}
async function collectSink() {
const calls: Array<{ sessionId: string; chunks: TranscriptChunk[] }> = [];
return {
calls,
sink: {
async append(sessionId: string, chunks: readonly TranscriptChunk[]) {
calls.push({ sessionId, chunks: [...chunks] });
},
},
};
}
describe("captureAndPersistTranscript", () => {
const tempRoots: string[] = [];
afterEach(() => {
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
it("captures adapter chunks after fromSeq and persists them to the sink", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-transcript-cwd-"));
tempRoots.push(cwd);
const adapter = new FakeSessionAdapter({ fixtureRoot: cwd, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd,
});
await adapter.sendPrompt(handle, envelope());
const { calls, sink } = await collectSink();
const result = await captureAndPersistTranscript({
adapter,
handle,
fromSeq: 1n,
sink,
});
expect(result).toEqual({ captured: 1, lastSeq: 2n });
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
sessionId: handle.sessionId,
chunks: [
{
seq: 2n,
content: expect.stringContaining("timeout"),
},
],
});
});
it("accepts the DB transcript repository as a sink contract", () => {
const repository = null as unknown as TuiTranscriptRepository;
const sink: Parameters<typeof captureAndPersistTranscript>[0]["sink"] = repository;
expect(sink).toBe(repository);
});
it("does not call the sink when there are no new chunks", async () => {
const cwd = mkdtempSync(join(tmpdir(), "devflow-transcript-cwd-"));
tempRoots.push(cwd);
const adapter = new FakeSessionAdapter({ fixtureRoot: cwd, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd,
});
const { calls, sink } = await collectSink();
const result = await captureAndPersistTranscript({
adapter,
handle,
fromSeq: 1n,
sink,
});
expect(result).toEqual({ captured: 0, lastSeq: 1n });
expect(calls).toEqual([]);
});
it("rejects non-monotonic adapter chunks before persistence", async () => {
const handle = { sessionId: "00000000-0000-4000-8000-000000000020" };
const now = new Date("2026-05-09T00:00:00.000Z");
const adapter = {
async *capture() {
yield { seq: 2n, content: "second", capturedAt: now };
yield { seq: 2n, content: "duplicate", capturedAt: now };
},
};
const { sink } = await collectSink();
await expect(
captureAndPersistTranscript({
adapter,
handle,
fromSeq: 1n,
sink,
}),
).rejects.toMatchObject({ code: "transcript_sequence_invalid" });
});
it("rejects sequence gaps before advancing the capture watermark", async () => {
const handle = { sessionId: "00000000-0000-4000-8000-000000000021" };
const now = new Date("2026-05-09T00:00:00.000Z");
const adapter = {
async *capture() {
yield { seq: 3n, content: "third", capturedAt: now };
},
};
const { sink } = await collectSink();
await expect(
captureAndPersistTranscript({
adapter,
handle,
fromSeq: 1n,
sink,
}),
).rejects.toMatchObject({ code: "transcript_sequence_gap" });
});
});

View File

@@ -1,51 +0,0 @@
import { DevflowError } from "@devflow/core";
import type { SessionAdapter, SessionHandle, TranscriptChunk } from "./adapter.js";
export interface TranscriptChunkSink {
append(sessionId: string, chunks: readonly TranscriptChunk[]): Promise<unknown>;
}
export interface CaptureAndPersistTranscriptInput {
adapter: Pick<SessionAdapter, "capture">;
handle: SessionHandle;
fromSeq: bigint;
sink: TranscriptChunkSink;
}
export interface CaptureAndPersistTranscriptResult {
captured: number;
lastSeq: bigint;
}
export async function captureAndPersistTranscript(
input: CaptureAndPersistTranscriptInput,
): Promise<CaptureAndPersistTranscriptResult> {
const chunks: TranscriptChunk[] = [];
let lastSeq = input.fromSeq;
for await (const chunk of input.adapter.capture(input.handle, input.fromSeq)) {
if (chunk.seq <= lastSeq) {
throw new DevflowError("Transcript chunks must be strictly increasing", {
class: "fatal",
code: "transcript_sequence_invalid",
});
}
if (chunk.seq !== lastSeq + 1n) {
throw new DevflowError("Transcript chunks must be contiguous", {
class: "fatal",
code: "transcript_sequence_gap",
});
}
chunks.push(chunk);
lastSeq = chunk.seq;
}
if (chunks.length === 0) {
return { captured: 0, lastSeq: input.fromSeq };
}
await input.sink.append(input.handle.sessionId, chunks);
return { captured: chunks.length, lastSeq };
}

View File

@@ -1,10 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": true,
"noEmit": false
},
"references": [],
"exclude": ["src/**/*.test.ts"]
}

View File

@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node", "vitest"]
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../core" }, { "path": "../db" }]
}

View File

@@ -1,27 +0,0 @@
{
"name": "@devflow/workflows",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --clean && tsc -p tsconfig.build.json",
"typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit",
"test": "cd ../.. && vitest run --project packages/workflows"
},
"dependencies": {
"@devflow/core": "workspace:*",
"@devflow/db": "workspace:*",
"@devflow/run-engine": "workspace:*",
"@devflow/session": "workspace:*",
"@temporalio/activity": "^1.17.1",
"@temporalio/client": "^1.17.1",
"@temporalio/worker": "^1.17.1",
"@temporalio/workflow": "^1.17.1"
},
"devDependencies": {
"@temporalio/testing": "^1.17.1"
}
}

View File

@@ -1,310 +0,0 @@
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { loadPersonaFiles, loadTemplateFiles } from "@devflow/core";
import {
type DbClient,
agentPersonas,
approvalDecisions,
approvalRequests,
createDbClient,
runs,
workflowTemplates,
} from "@devflow/db";
import { FakeSessionAdapter, SessionManager } from "@devflow/session";
import { ApplicationFailure } from "@temporalio/activity";
import { eq, inArray } from "drizzle-orm";
import { afterEach, describe, expect, it } from "vitest";
import { createDevflowActivities } from "./activities.js";
const databaseUrl =
process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow";
describe("createDevflowActivities", () => {
let client: DbClient | undefined;
const runIds: 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]));
}
await client.close();
client = undefined;
}
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
runIds.length = 0;
});
it("preserves M4 fake development run behavior through worker activities", async () => {
client = createDbClient(databaseUrl);
await seedDevelopmentRegistry(client.db);
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-workflows-workspace-")));
const repoPath = createGitRepo();
tempRoots.push(workspaceRoot, repoPath);
const activities = createDevflowActivities({
db: client.db,
sessions: new SessionManager({
db: client.db,
adapter: new FakeSessionAdapter({ writeDelayMs: 0 }),
}),
workspaceRoot,
maxConcurrentRuns: 100,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
});
const input = {
requirementsMd: "Run through the M5 worker activity surface.",
repoPath,
baseBranch: "main",
scenarios: {
spec: "ok",
phase_plan: "ok",
},
};
const { runId } = await activities.prepareRunActivity(input);
runIds.push(runId);
await activities.lockBindingsActivity({ ...input, runId });
await activities.advanceRunActivity({ runId });
let status = await activities.getStatusActivity(runId);
expect(status.run.state).toBe("awaiting_approval");
expect(status.approvals).toMatchObject([{ gateKey: "spec_approved", state: "pending" }]);
await activities.signalApprovalActivity({
runId,
approvalRequestId: pendingApprovalId(status, "spec_approved"),
action: "approve",
clientToken: randomUUID(),
});
await activities.advanceRunActivity({ runId });
status = await activities.getStatusActivity(runId);
await activities.signalApprovalActivity({
runId,
approvalRequestId: pendingApprovalId(status, "phase_plan_approved"),
action: "approve",
clientToken: randomUUID(),
});
await activities.advanceRunActivity({ runId });
status = await activities.getStatusActivity(runId);
expect(status.run.state).toBe("completed");
expect(status.run.finalReportPath).toMatch(/\.report\.md$/);
expect(existsSync(status.run.finalReportPath ?? "")).toBe(true);
});
it("prepares a run idempotently when Temporal replays the same activity", async () => {
client = createDbClient(databaseUrl);
await seedDevelopmentRegistry(client.db);
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-workflows-workspace-")));
const repoPath = createGitRepo();
tempRoots.push(workspaceRoot, repoPath);
const activities = createDevflowActivities({
db: client.db,
sessions: new SessionManager({
db: client.db,
adapter: new FakeSessionAdapter({ writeDelayMs: 0 }),
}),
workspaceRoot,
maxConcurrentRuns: 100,
});
const runId = randomUUID();
const input = {
runId,
requirementsMd: "Replay-safe prepare should return the same run.",
repoPath,
baseBranch: "main",
};
await expect(activities.prepareRunActivity(input)).resolves.toEqual({ runId });
await expect(activities.prepareRunActivity(input)).resolves.toEqual({ runId });
const rows = await client.db.select({ id: runs.id }).from(runs).where(eq(runs.id, runId));
expect(rows).toEqual([{ id: runId }]);
runIds.push(runId);
});
it("rejects a prepare replay with the same run id but different inputs", async () => {
client = createDbClient(databaseUrl);
await seedDevelopmentRegistry(client.db);
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-workflows-workspace-")));
const repoPath = createGitRepo();
tempRoots.push(workspaceRoot, repoPath);
const activities = createDevflowActivities({
db: client.db,
sessions: new SessionManager({
db: client.db,
adapter: new FakeSessionAdapter({ writeDelayMs: 0 }),
}),
workspaceRoot,
maxConcurrentRuns: 100,
});
const runId = randomUUID();
const input = {
runId,
requirementsMd: "Original run requirements.",
repoPath,
baseBranch: "main",
scenarios: { spec: "ok" },
};
await expect(activities.prepareRunActivity(input)).resolves.toEqual({ runId });
await expectDevflowActivityFailure(
activities.prepareRunActivity({
...input,
requirementsMd: "Changed requirements must not be accepted as replay.",
}),
"internal_state_corruption",
);
await expectDevflowActivityFailure(
activities.prepareRunActivity({
...input,
scenarios: { spec: "timeout" },
}),
"internal_state_corruption",
);
runIds.push(runId);
});
it("can fail an active prepared run when lock binding cannot complete", async () => {
client = createDbClient(databaseUrl);
await seedDevelopmentRegistry(client.db);
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-workflows-workspace-")));
const repoPath = createGitRepo();
tempRoots.push(workspaceRoot, repoPath);
const activities = createDevflowActivities({
db: client.db,
sessions: new SessionManager({
db: client.db,
adapter: new FakeSessionAdapter({ writeDelayMs: 0 }),
}),
workspaceRoot,
maxConcurrentRuns: 100,
});
const input = {
requirementsMd: "Binding should fail when no backend is enabled.",
repoPath,
baseBranch: "main",
overrides: { roles: { spec_writer: { persona: "missing-persona" } } },
};
const { runId } = await activities.prepareRunActivity(input);
runIds.push(runId);
await expectDevflowActivityFailure(
activities.lockBindingsActivity({ ...input, runId }),
"no_eligible_persona",
);
await activities.failRunActivity({ runId, reason: "lock_bindings_failed" });
const [run] = await client.db
.select({ state: runs.state })
.from(runs)
.where(eq(runs.id, runId));
expect(run).toEqual({ state: "failed" });
});
});
function pendingApprovalId(
status: Awaited<ReturnType<ReturnType<typeof createDevflowActivities>["getStatusActivity"]>>,
gateKey: string,
) {
const approval = status.approvals.find(
(candidate) => candidate.gateKey === gateKey && candidate.state === "pending",
);
expect(approval).toBeDefined();
if (approval === undefined) {
throw new Error(`${gateKey} approval missing`);
}
return approval.id;
}
function createGitRepo(): string {
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-workflows-repo-")));
execFileSync("git", ["init", "-b", "main"], { cwd: repoPath, stdio: "ignore" });
writeFileSync(join(repoPath, "README.md"), "# Workflows fixture\n");
execFileSync("git", ["add", "README.md"], { cwd: repoPath, stdio: "ignore" });
execFileSync(
"git",
[
"-c",
"user.name=Devflow Test",
"-c",
"user.email=devflow@example.test",
"commit",
"-m",
"initial",
],
{ cwd: repoPath, stdio: "ignore" },
);
return repoPath;
}
async function expectDevflowActivityFailure(operation: Promise<unknown>, code: string) {
try {
await operation;
} catch (error) {
expect(error).toBeInstanceOf(ApplicationFailure);
const failure = error as ApplicationFailure;
expect(failure.type).toBe("DevflowError");
expect(failure.nonRetryable).toBe(true);
expect(failure.details?.[0]).toMatchObject({ code });
return;
}
throw new Error(`Expected Devflow activity failure ${code}`);
}
async function seedDevelopmentRegistry(db: DbClient["db"]) {
const [templateEntry] = loadTemplateFiles(resolve("docs/schemas/templates")).filter(
(entry) => entry.name === "development" && entry.version === 1,
);
if (templateEntry === undefined) {
throw new Error("development@1 template fixture is missing");
}
await db
.insert(workflowTemplates)
.values({
name: templateEntry.name,
version: templateEntry.version,
hash: templateEntry.hash,
definition: templateEntry.definition,
})
.onConflictDoUpdate({
target: [workflowTemplates.name, workflowTemplates.version],
set: { hash: templateEntry.hash, definition: templateEntry.definition },
});
for (const personaEntry of loadPersonaFiles(resolve("docs/schemas/personas"))) {
await db
.insert(agentPersonas)
.values({
name: personaEntry.name,
version: personaEntry.version,
hash: personaEntry.hash,
definition: personaEntry.definition,
})
.onConflictDoNothing({ target: [agentPersonas.name, agentPersonas.version] });
}
}

Some files were not shown because too many files have changed in this diff Show More