feat: add minimal run engine
This commit is contained in:
17
apps/api/package.json
Normal file
17
apps/api/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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:*"
|
||||
}
|
||||
}
|
||||
746
apps/api/src/index.test.ts
Normal file
746
apps/api/src/index.test.ts
Normal file
@@ -0,0 +1,746 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
DevflowError,
|
||||
type PromptEnvelope,
|
||||
loadPersonaFiles,
|
||||
loadTemplateFiles,
|
||||
} from "@devflow/core";
|
||||
import {
|
||||
type DbClient,
|
||||
agentPersonas,
|
||||
approvalDecisions,
|
||||
approvalRequests,
|
||||
createDbClient,
|
||||
runEvents,
|
||||
runs,
|
||||
tuiSessions,
|
||||
workflowTemplates,
|
||||
} from "@devflow/db";
|
||||
import { DbRunEngine } from "@devflow/run-engine";
|
||||
import { FakeSessionAdapter, type SessionHandle, SessionManager } from "@devflow/session";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { startApi } from "./index.js";
|
||||
|
||||
const databaseUrl =
|
||||
process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow";
|
||||
|
||||
class ResumeFailsFakeSessionAdapter extends FakeSessionAdapter {
|
||||
resumeAttempts = 0;
|
||||
|
||||
override async resume(_handle: SessionHandle): Promise<SessionHandle> {
|
||||
this.resumeAttempts += 1;
|
||||
throw new DevflowError("resume failed", {
|
||||
class: "recoverable",
|
||||
code: "pane_briefly_unresponsive",
|
||||
recoveryHint: "resume failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class ResumeSucceedsAfterTwoFailuresFakeSessionAdapter extends FakeSessionAdapter {
|
||||
resumeAttempts = 0;
|
||||
|
||||
override async resume(handle: SessionHandle): Promise<SessionHandle> {
|
||||
this.resumeAttempts += 1;
|
||||
if (this.resumeAttempts <= 2) {
|
||||
throw new DevflowError("resume failed transiently", {
|
||||
class: "recoverable",
|
||||
code: "pane_briefly_unresponsive",
|
||||
recoveryHint: "resume failed transiently",
|
||||
});
|
||||
}
|
||||
return super.resume(handle);
|
||||
}
|
||||
}
|
||||
|
||||
class DelayedSendPromptFakeSessionAdapter extends FakeSessionAdapter {
|
||||
readonly promptStarted = deferred<void>();
|
||||
readonly releasePrompt = deferred<void>();
|
||||
|
||||
override async sendPrompt(
|
||||
handle: SessionHandle,
|
||||
envelope: PromptEnvelope,
|
||||
): Promise<{ promptId: string }> {
|
||||
this.promptStarted.resolve();
|
||||
await this.releasePrompt.promise;
|
||||
return super.sendPrompt(handle, envelope);
|
||||
}
|
||||
}
|
||||
|
||||
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, reject, resolve };
|
||||
}
|
||||
|
||||
function createGitRepo(): string {
|
||||
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-")));
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repoPath, stdio: "ignore" });
|
||||
writeFileSync(join(repoPath, "README.md"), "# API fixture\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoPath, stdio: "ignore" });
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"user.name=Devflow Test",
|
||||
"-c",
|
||||
"user.email=devflow@example.test",
|
||||
"commit",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
{ cwd: repoPath, stdio: "ignore" },
|
||||
);
|
||||
return repoPath;
|
||||
}
|
||||
|
||||
async function seedDevelopmentRegistry(db: DbClient["db"]) {
|
||||
const [templateEntry] = loadTemplateFiles(resolve("docs/schemas/templates")).filter(
|
||||
(entry) => entry.name === "development" && entry.version === 1,
|
||||
);
|
||||
if (templateEntry === undefined) {
|
||||
throw new Error("development@1 template fixture is missing");
|
||||
}
|
||||
await db
|
||||
.insert(workflowTemplates)
|
||||
.values({
|
||||
name: templateEntry.name,
|
||||
version: templateEntry.version,
|
||||
hash: templateEntry.hash,
|
||||
definition: templateEntry.definition,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [workflowTemplates.name, workflowTemplates.version],
|
||||
set: { hash: templateEntry.hash, definition: templateEntry.definition },
|
||||
});
|
||||
|
||||
for (const personaEntry of loadPersonaFiles(resolve("docs/schemas/personas"))) {
|
||||
await db
|
||||
.insert(agentPersonas)
|
||||
.values({
|
||||
name: personaEntry.name,
|
||||
version: personaEntry.version,
|
||||
hash: personaEntry.hash,
|
||||
definition: personaEntry.definition,
|
||||
})
|
||||
.onConflictDoNothing({ target: [agentPersonas.name, agentPersonas.version] });
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRunEventType(db: DbClient["db"], runId: string, type: string) {
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (Date.now() < deadline) {
|
||||
const [event] = await db
|
||||
.select({ id: runEvents.id })
|
||||
.from(runEvents)
|
||||
.where(and(eq(runEvents.runId, runId), eq(runEvents.type, type)))
|
||||
.limit(1);
|
||||
if (event !== undefined) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 10));
|
||||
}
|
||||
throw new Error(`timed out waiting for ${type}`);
|
||||
}
|
||||
|
||||
describe("startApi", () => {
|
||||
let client: DbClient | undefined;
|
||||
const runIds: string[] = [];
|
||||
const templateIds: string[] = [];
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
function createApiWorkspaceRoot(): string {
|
||||
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-workspace-")));
|
||||
tempRoots.push(workspaceRoot);
|
||||
return workspaceRoot;
|
||||
}
|
||||
|
||||
function startTestApi(options: Parameters<typeof startApi>[0] = {}) {
|
||||
return startApi({ workspaceRoot: createApiWorkspaceRoot(), ...options });
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (client !== undefined) {
|
||||
if (runIds.length > 0) {
|
||||
const requests = await client.db
|
||||
.select({ id: approvalRequests.id })
|
||||
.from(approvalRequests)
|
||||
.where(inArray(approvalRequests.runId, [...runIds]));
|
||||
if (requests.length > 0) {
|
||||
await client.db.delete(approvalDecisions).where(
|
||||
inArray(
|
||||
approvalDecisions.approvalRequestId,
|
||||
requests.map((request) => request.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
await client.db
|
||||
.delete(approvalRequests)
|
||||
.where(inArray(approvalRequests.runId, [...runIds]));
|
||||
await client.db.delete(runs).where(inArray(runs.id, [...runIds]));
|
||||
}
|
||||
if (templateIds.length > 0) {
|
||||
await client.db
|
||||
.delete(workflowTemplates)
|
||||
.where(inArray(workflowTemplates.id, [...templateIds]));
|
||||
}
|
||||
await client.close();
|
||||
client = undefined;
|
||||
}
|
||||
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
runIds.length = 0;
|
||||
templateIds.length = 0;
|
||||
});
|
||||
|
||||
it("runs M4 restart recovery before startup completes", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
const templateId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const sessionId = randomUUID();
|
||||
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-")));
|
||||
const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-")));
|
||||
tempRoots.push(repoPath, worktreeRoot);
|
||||
templateIds.push(templateId);
|
||||
runIds.push(runId);
|
||||
|
||||
await client.db.insert(workflowTemplates).values({
|
||||
id: templateId,
|
||||
name: `api-startup-${templateId}`,
|
||||
version: 1,
|
||||
hash: "a".repeat(64),
|
||||
definition: { name: "api-startup", version: 1, roles: [], phases: [], defaultGates: [] },
|
||||
});
|
||||
await client.db.insert(runs).values({
|
||||
id: runId,
|
||||
templateId,
|
||||
templateHash: "a".repeat(64),
|
||||
state: "executing",
|
||||
repoPath,
|
||||
baseBranch: "main",
|
||||
worktreeRoot,
|
||||
});
|
||||
await client.db.insert(tuiSessions).values({
|
||||
id: sessionId,
|
||||
runId,
|
||||
roleId: "spec_writer",
|
||||
backend: "fake",
|
||||
cwd: worktreeRoot,
|
||||
state: "READY",
|
||||
});
|
||||
|
||||
const result = await startTestApi({ dbClient: client, recoveryRunIds: [runId] });
|
||||
try {
|
||||
expect(result.recovery).toEqual({
|
||||
failedSessionIds: [sessionId],
|
||||
sweptRunIds: [runId],
|
||||
});
|
||||
expect(result.sessionRecovery).toEqual({
|
||||
failedSessionIds: [],
|
||||
recoveredSessionIds: [],
|
||||
});
|
||||
} finally {
|
||||
await result.stop();
|
||||
}
|
||||
const [run] = await client.db
|
||||
.select({ state: runs.state })
|
||||
.from(runs)
|
||||
.where(eq(runs.id, runId));
|
||||
expect(run).toEqual({ state: "failed" });
|
||||
const [session] = await client.db
|
||||
.select({ state: tuiSessions.state })
|
||||
.from(tuiSessions)
|
||||
.where(eq(tuiSessions.id, sessionId));
|
||||
expect(session).toEqual({ state: "FAILED_NEEDS_HUMAN" });
|
||||
const events = await client.db
|
||||
.select({ type: runEvents.type })
|
||||
.from(runEvents)
|
||||
.where(eq(runEvents.runId, runId))
|
||||
.orderBy(runEvents.seq);
|
||||
expect(events.map((event) => event.type)).toEqual(["run.failed", "session.failed"]);
|
||||
});
|
||||
|
||||
it("holds the SessionManager singleton lock until stopped", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
const recoveryRunIds = [randomUUID()];
|
||||
const first = await startTestApi({ dbClient: client, recoveryRunIds });
|
||||
try {
|
||||
await expect(startTestApi({ dbClient: client, recoveryRunIds })).rejects.toMatchObject({
|
||||
code: "session_manager_already_running",
|
||||
});
|
||||
} finally {
|
||||
await first.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("hosts the M4 run engine behind the API startup boundary", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
await seedDevelopmentRegistry(client.db);
|
||||
const workspaceRoot = createApiWorkspaceRoot();
|
||||
const repoPath = createGitRepo();
|
||||
tempRoots.push(repoPath);
|
||||
|
||||
const api = await startApi({ dbClient: client, workspaceRoot, recoveryRunIds: [] });
|
||||
try {
|
||||
expect(api.engine).toBeInstanceOf(DbRunEngine);
|
||||
const { runId } = await api.engine.startRun({
|
||||
requirementsMd: "Start a fake development run through the API-owned engine.",
|
||||
repoPath,
|
||||
baseBranch: "main",
|
||||
scenarios: { spec: "ok" },
|
||||
});
|
||||
runIds.push(runId);
|
||||
|
||||
const status = await api.engine.getStatus(runId);
|
||||
expect(status.run.state).toBe("awaiting_approval");
|
||||
expect(status.run.worktreeRoot).toBe(resolve(workspaceRoot, runId, "main"));
|
||||
expect(status.approvals).toMatchObject([{ gateKey: "spec_approved", state: "pending" }]);
|
||||
} finally {
|
||||
await api.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("repairs missing terminal final reports during API startup", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
await seedDevelopmentRegistry(client.db);
|
||||
const workspaceRoot = createApiWorkspaceRoot();
|
||||
const template = (
|
||||
await client.db
|
||||
.select({ hash: workflowTemplates.hash, id: workflowTemplates.id })
|
||||
.from(workflowTemplates)
|
||||
.where(eq(workflowTemplates.name, "development"))
|
||||
.limit(1)
|
||||
)[0];
|
||||
if (template === undefined) {
|
||||
throw new Error("development template missing");
|
||||
}
|
||||
const runId = randomUUID();
|
||||
const repoPath = createGitRepo();
|
||||
const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-")));
|
||||
tempRoots.push(repoPath, worktreeRoot);
|
||||
runIds.push(runId);
|
||||
await client.db.insert(runs).values({
|
||||
id: runId,
|
||||
templateId: template.id,
|
||||
templateHash: template.hash,
|
||||
state: "completed",
|
||||
repoPath,
|
||||
baseBranch: "main",
|
||||
worktreeRoot,
|
||||
endedAt: new Date(),
|
||||
finalReportPath: null,
|
||||
});
|
||||
|
||||
const api = await startApi({ dbClient: client, workspaceRoot, recoveryRunIds: [runId] });
|
||||
try {
|
||||
expect(api.finalReportRecovery).toEqual([runId]);
|
||||
const [run] = await client.db
|
||||
.select({ finalReportPath: runs.finalReportPath })
|
||||
.from(runs)
|
||||
.where(eq(runs.id, runId));
|
||||
expect(run?.finalReportPath).toMatch(/\.report\.md$/);
|
||||
} finally {
|
||||
await api.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not sweep active runs when a second API instance fails the singleton lock", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
const first = await startTestApi({ dbClient: client, recoveryRunIds: [] });
|
||||
const templateId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const sessionId = randomUUID();
|
||||
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-")));
|
||||
const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-")));
|
||||
tempRoots.push(repoPath, worktreeRoot);
|
||||
templateIds.push(templateId);
|
||||
runIds.push(runId);
|
||||
try {
|
||||
await client.db.insert(workflowTemplates).values({
|
||||
id: templateId,
|
||||
name: `api-lock-order-${templateId}`,
|
||||
version: 1,
|
||||
hash: "c".repeat(64),
|
||||
definition: { name: "api-lock-order", version: 1, roles: [], phases: [] },
|
||||
});
|
||||
await client.db.insert(runs).values({
|
||||
id: runId,
|
||||
templateId,
|
||||
templateHash: "c".repeat(64),
|
||||
state: "executing",
|
||||
repoPath,
|
||||
baseBranch: "main",
|
||||
worktreeRoot,
|
||||
});
|
||||
await client.db.insert(tuiSessions).values({
|
||||
id: sessionId,
|
||||
runId,
|
||||
roleId: "spec_writer",
|
||||
backend: "fake",
|
||||
cwd: worktreeRoot,
|
||||
state: "READY",
|
||||
});
|
||||
|
||||
await expect(
|
||||
startTestApi({ dbClient: client, recoveryRunIds: [runId] }),
|
||||
).rejects.toMatchObject({
|
||||
code: "session_manager_already_running",
|
||||
});
|
||||
|
||||
const [run] = await client.db
|
||||
.select({ state: runs.state })
|
||||
.from(runs)
|
||||
.where(eq(runs.id, runId));
|
||||
expect(run).toEqual({ state: "executing" });
|
||||
const [session] = await client.db
|
||||
.select({ state: tuiSessions.state })
|
||||
.from(tuiSessions)
|
||||
.where(eq(tuiSessions.id, sessionId));
|
||||
expect(session).toEqual({ state: "READY" });
|
||||
const events = await client.db.select().from(runEvents).where(eq(runEvents.runId, runId));
|
||||
expect(events).toEqual([]);
|
||||
} finally {
|
||||
await first.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores terminal-run sessions during SessionManager startup recovery", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
const templateId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const sessionId = randomUUID();
|
||||
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-")));
|
||||
const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-")));
|
||||
tempRoots.push(repoPath, worktreeRoot);
|
||||
templateIds.push(templateId);
|
||||
runIds.push(runId);
|
||||
const adapter = new FakeSessionAdapter({
|
||||
sessionIdFactory: () => sessionId,
|
||||
writeDelayMs: 0,
|
||||
});
|
||||
await adapter.start({
|
||||
runId,
|
||||
roleId: "spec_writer",
|
||||
backend: "fake",
|
||||
cwd: worktreeRoot,
|
||||
});
|
||||
|
||||
await client.db.insert(workflowTemplates).values({
|
||||
id: templateId,
|
||||
name: `api-session-recovery-${templateId}`,
|
||||
version: 1,
|
||||
hash: "b".repeat(64),
|
||||
definition: { name: "api-session-recovery", version: 1, roles: [], phases: [] },
|
||||
});
|
||||
await client.db.insert(runs).values({
|
||||
id: runId,
|
||||
templateId,
|
||||
templateHash: "b".repeat(64),
|
||||
state: "completed",
|
||||
repoPath,
|
||||
baseBranch: "main",
|
||||
worktreeRoot,
|
||||
});
|
||||
await client.db.insert(tuiSessions).values({
|
||||
id: sessionId,
|
||||
runId,
|
||||
roleId: "spec_writer",
|
||||
backend: "fake",
|
||||
cwd: worktreeRoot,
|
||||
state: "READY",
|
||||
});
|
||||
|
||||
const result = await startTestApi({
|
||||
dbClient: client,
|
||||
recoveryRunIds: [runId],
|
||||
sessionAdapter: adapter,
|
||||
});
|
||||
try {
|
||||
expect(result.recovery).toEqual({ failedSessionIds: [], sweptRunIds: [] });
|
||||
expect(result.sessionRecovery).toEqual({
|
||||
failedSessionIds: [],
|
||||
recoveredSessionIds: [],
|
||||
});
|
||||
} finally {
|
||||
await result.stop();
|
||||
}
|
||||
const [session] = await client.db
|
||||
.select({ state: tuiSessions.state })
|
||||
.from(tuiSessions)
|
||||
.where(eq(tuiSessions.id, sessionId));
|
||||
expect(session).toEqual({ state: "READY" });
|
||||
const approvals = await client.db
|
||||
.select()
|
||||
.from(approvalRequests)
|
||||
.where(eq(approvalRequests.runId, runId));
|
||||
expect(approvals).toEqual([]);
|
||||
const events = await client.db.select().from(runEvents).where(eq(runEvents.runId, runId));
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("retries transient session resume failures during startup recovery", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
const templateId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const sessionId = randomUUID();
|
||||
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-")));
|
||||
const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-")));
|
||||
tempRoots.push(repoPath, worktreeRoot);
|
||||
templateIds.push(templateId);
|
||||
runIds.push(runId);
|
||||
const adapter = new ResumeSucceedsAfterTwoFailuresFakeSessionAdapter({
|
||||
sessionIdFactory: () => sessionId,
|
||||
writeDelayMs: 0,
|
||||
});
|
||||
await adapter.start({
|
||||
runId,
|
||||
roleId: "spec_writer",
|
||||
backend: "fake",
|
||||
cwd: worktreeRoot,
|
||||
});
|
||||
|
||||
await client.db.insert(workflowTemplates).values({
|
||||
id: templateId,
|
||||
name: `api-session-retry-${templateId}`,
|
||||
version: 1,
|
||||
hash: "e".repeat(64),
|
||||
definition: { name: "api-session-retry", version: 1, roles: [], phases: [] },
|
||||
});
|
||||
await client.db.insert(runs).values({
|
||||
id: runId,
|
||||
templateId,
|
||||
templateHash: "e".repeat(64),
|
||||
state: "executing",
|
||||
repoPath,
|
||||
baseBranch: "main",
|
||||
worktreeRoot,
|
||||
});
|
||||
await client.db.insert(tuiSessions).values({
|
||||
id: sessionId,
|
||||
runId,
|
||||
roleId: "spec_writer",
|
||||
backend: "fake",
|
||||
cwd: worktreeRoot,
|
||||
state: "READY",
|
||||
});
|
||||
|
||||
const manager = new SessionManager({
|
||||
dbClient: client,
|
||||
adapter,
|
||||
recoveryRunIds: [runId],
|
||||
});
|
||||
const recovery = await manager.initialize();
|
||||
try {
|
||||
expect(adapter.resumeAttempts).toBe(3);
|
||||
expect(recovery).toEqual({ failedSessionIds: [], recoveredSessionIds: [sessionId] });
|
||||
const approvals = await client.db
|
||||
.select()
|
||||
.from(approvalRequests)
|
||||
.where(eq(approvalRequests.runId, runId));
|
||||
expect(approvals).toEqual([]);
|
||||
} finally {
|
||||
await manager.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("pauses a non-terminal run when SessionManager startup recovery cannot resume a session", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
const templateId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const sessionId = randomUUID();
|
||||
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-")));
|
||||
const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-")));
|
||||
tempRoots.push(repoPath, worktreeRoot);
|
||||
templateIds.push(templateId);
|
||||
runIds.push(runId);
|
||||
|
||||
await client.db.insert(workflowTemplates).values({
|
||||
id: templateId,
|
||||
name: `api-session-recovery-failure-${templateId}`,
|
||||
version: 1,
|
||||
hash: "d".repeat(64),
|
||||
definition: { name: "api-session-recovery-failure", version: 1, roles: [], phases: [] },
|
||||
});
|
||||
await client.db.insert(runs).values({
|
||||
id: runId,
|
||||
templateId,
|
||||
templateHash: "d".repeat(64),
|
||||
state: "executing",
|
||||
repoPath,
|
||||
baseBranch: "main",
|
||||
worktreeRoot,
|
||||
});
|
||||
await client.db.insert(tuiSessions).values({
|
||||
id: sessionId,
|
||||
runId,
|
||||
roleId: "spec_writer",
|
||||
backend: "fake",
|
||||
cwd: worktreeRoot,
|
||||
state: "READY",
|
||||
});
|
||||
|
||||
const adapter = new ResumeFailsFakeSessionAdapter();
|
||||
const manager = new SessionManager({
|
||||
dbClient: client,
|
||||
adapter,
|
||||
recoveryRunIds: [runId],
|
||||
});
|
||||
const recovery = await manager.initialize();
|
||||
try {
|
||||
expect(adapter.resumeAttempts).toBe(3);
|
||||
expect(recovery).toEqual({ failedSessionIds: [sessionId], recoveredSessionIds: [] });
|
||||
const [run] = await client.db
|
||||
.select({ pausedFromState: runs.pausedFromState, state: runs.state })
|
||||
.from(runs)
|
||||
.where(eq(runs.id, runId));
|
||||
expect(run).toEqual({ pausedFromState: "executing", state: "paused" });
|
||||
const [session] = await client.db
|
||||
.select({ recoveryAttempts: tuiSessions.recoveryAttempts, state: tuiSessions.state })
|
||||
.from(tuiSessions)
|
||||
.where(eq(tuiSessions.id, sessionId));
|
||||
expect(session).toEqual({ recoveryAttempts: 1, state: "FAILED_NEEDS_HUMAN" });
|
||||
const [approval] = await client.db
|
||||
.select({
|
||||
gateKey: approvalRequests.gateKey,
|
||||
phaseId: approvalRequests.phaseId,
|
||||
state: approvalRequests.state,
|
||||
})
|
||||
.from(approvalRequests)
|
||||
.where(eq(approvalRequests.runId, runId));
|
||||
expect(approval).toEqual({
|
||||
gateKey: "session_recovery_required",
|
||||
phaseId: null,
|
||||
state: "pending",
|
||||
});
|
||||
const events = await client.db
|
||||
.select({ type: runEvents.type })
|
||||
.from(runEvents)
|
||||
.where(eq(runEvents.runId, runId))
|
||||
.orderBy(runEvents.seq);
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
"session.failed",
|
||||
"run.paused",
|
||||
"approval.requested",
|
||||
]);
|
||||
} finally {
|
||||
await manager.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the singleton lock while shutdown drains in-flight session operations", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
const adapter = new DelayedSendPromptFakeSessionAdapter({ writeDelayMs: 0 });
|
||||
const manager = new SessionManager({
|
||||
dbClient: client,
|
||||
adapter,
|
||||
recoveryRunIds: [],
|
||||
shutdownDrainMs: 5_000,
|
||||
});
|
||||
await manager.initialize();
|
||||
const runId = randomUUID();
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-session-")));
|
||||
tempRoots.push(cwd);
|
||||
const handle = await manager.start({
|
||||
runId,
|
||||
roleId: "spec_writer",
|
||||
backend: "fake",
|
||||
cwd,
|
||||
});
|
||||
const envelope: PromptEnvelope = {
|
||||
uuid: randomUUID(),
|
||||
runId,
|
||||
roleId: "spec_writer",
|
||||
phaseKey: "spec",
|
||||
attempt: 0,
|
||||
expectedArtifact: join(tmpdir(), `${randomUUID()}.json`),
|
||||
expectedSchema: "dev/spec@1",
|
||||
dedupKey: `dedup-${randomUUID()}`,
|
||||
instructions: "Scenario: timeout",
|
||||
};
|
||||
const promptPromise = manager.sendPrompt(handle, envelope);
|
||||
await adapter.promptStarted.promise;
|
||||
|
||||
const shutdownPromise = manager.shutdown();
|
||||
await expect(
|
||||
new SessionManager({
|
||||
dbClient: client,
|
||||
adapter: new FakeSessionAdapter(),
|
||||
recoveryRunIds: [],
|
||||
}).initialize(),
|
||||
).rejects.toMatchObject({ code: "session_manager_already_running" });
|
||||
|
||||
adapter.releasePrompt.resolve(undefined);
|
||||
await expect(promptPromise).resolves.toEqual({ promptId: envelope.dedupKey });
|
||||
await shutdownPromise;
|
||||
|
||||
const nextManager = new SessionManager({
|
||||
dbClient: client,
|
||||
adapter: new FakeSessionAdapter(),
|
||||
recoveryRunIds: [],
|
||||
});
|
||||
await expect(nextManager.initialize()).resolves.toEqual({
|
||||
failedSessionIds: [],
|
||||
recoveredSessionIds: [],
|
||||
});
|
||||
await nextManager.shutdown();
|
||||
});
|
||||
|
||||
it("keeps the singleton lock while shutdown drains in-flight artifact polling", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
await seedDevelopmentRegistry(client.db);
|
||||
const workspaceRoot = createApiWorkspaceRoot();
|
||||
const repoPath = createGitRepo();
|
||||
tempRoots.push(repoPath);
|
||||
const runId = randomUUID();
|
||||
runIds.push(runId);
|
||||
const api = await startApi({
|
||||
dbClient: client,
|
||||
workspaceRoot,
|
||||
recoveryRunIds: [],
|
||||
sessionAdapter: new FakeSessionAdapter({ writeDelayMs: 1_000 }),
|
||||
});
|
||||
const startPromise = api.engine.startRun({
|
||||
runId,
|
||||
requirementsMd: "Keep artifact polling in flight during shutdown.",
|
||||
repoPath,
|
||||
baseBranch: "main",
|
||||
scenarios: { spec: "ok" },
|
||||
});
|
||||
await waitForRunEventType(client.db, runId, "artifact.expected");
|
||||
|
||||
const stopPromise = api.stop();
|
||||
await expect(
|
||||
new SessionManager({
|
||||
dbClient: client,
|
||||
adapter: new FakeSessionAdapter(),
|
||||
recoveryRunIds: [],
|
||||
}).initialize(),
|
||||
).rejects.toMatchObject({ code: "session_manager_already_running" });
|
||||
|
||||
await expect(startPromise).resolves.toEqual({ runId });
|
||||
await stopPromise;
|
||||
const nextManager = new SessionManager({
|
||||
dbClient: client,
|
||||
adapter: new FakeSessionAdapter(),
|
||||
recoveryRunIds: [],
|
||||
});
|
||||
await expect(nextManager.initialize()).resolves.toEqual({
|
||||
failedSessionIds: [],
|
||||
recoveredSessionIds: [],
|
||||
});
|
||||
await nextManager.shutdown();
|
||||
});
|
||||
});
|
||||
128
apps/api/src/index.ts
Normal file
128
apps/api/src/index.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
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 } from "@devflow/run-engine";
|
||||
import {
|
||||
FakeSessionAdapter,
|
||||
type SessionAdapter,
|
||||
SessionManager,
|
||||
type SessionManagerRecoveryResult,
|
||||
} from "@devflow/session";
|
||||
|
||||
import { recoverM4ApiStartup, startM4SessionManager } from "./startup.js";
|
||||
|
||||
export * from "./startup.js";
|
||||
|
||||
export interface StartApiOptions {
|
||||
dbClient?: DbClient;
|
||||
workspaceRoot?: string;
|
||||
availableBackends?: readonly BackendConfig[];
|
||||
recoveryRunIds?: readonly string[];
|
||||
sessionAdapter?: SessionAdapter;
|
||||
sessionManager?: SessionManager;
|
||||
runEngine?: RunEngine;
|
||||
}
|
||||
|
||||
export interface StartApiResult {
|
||||
recovery: Awaited<ReturnType<typeof recoverM4ApiStartup>>;
|
||||
sessionRecovery: SessionManagerRecoveryResult;
|
||||
sessionManager: SessionManager;
|
||||
engine: RunEngine;
|
||||
finalReportRecovery: string[];
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function startApi(options: StartApiOptions = {}): Promise<StartApiResult> {
|
||||
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 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 }),
|
||||
});
|
||||
|
||||
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() {
|
||||
await sessionManager.shutdown();
|
||||
if (ownedClient) {
|
||||
await dbClient.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (options.sessionManager === undefined) {
|
||||
await sessionManager.shutdown().catch(() => undefined);
|
||||
}
|
||||
if (ownedClient) {
|
||||
await dbClient.close();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDirectEntry(import.meta.url, process.argv)) {
|
||||
startApi()
|
||||
.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);
|
||||
});
|
||||
}
|
||||
14
apps/api/src/startup.ts
Normal file
14
apps/api/src/startup.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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();
|
||||
}
|
||||
15
apps/api/tsconfig.json
Normal file
15
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
15
docs/schemas/personas/fake-devflow-agent@1.yaml
Normal file
15
docs/schemas/personas/fake-devflow-agent@1.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
name: fake-devflow-agent
|
||||
version: 1
|
||||
backend: fake
|
||||
capabilities:
|
||||
- spec_write
|
||||
- phase_planning
|
||||
- task_dag_planning
|
||||
- code_edit
|
||||
- test_first_development
|
||||
- command_execute
|
||||
- final_report_compose
|
||||
maxRiskLevel: high
|
||||
promptConfig:
|
||||
instructionsPrelude: "Use the fake backend fixture protocol."
|
||||
modelConfig: {}
|
||||
35
docs/schemas/templates/development@1.yaml
Normal file
35
docs/schemas/templates/development@1.yaml
Normal file
@@ -0,0 +1,35 @@
|
||||
name: development
|
||||
version: 1
|
||||
roles:
|
||||
- id: spec_writer
|
||||
requiredCapabilities:
|
||||
- spec_write
|
||||
preferredBackends:
|
||||
- fake
|
||||
- id: phase_planner
|
||||
requiredCapabilities:
|
||||
- phase_planning
|
||||
preferredBackends:
|
||||
- fake
|
||||
phases:
|
||||
- key: spec
|
||||
title: Development Specification
|
||||
risk: low
|
||||
roles:
|
||||
- spec_writer
|
||||
expectedArtifact:
|
||||
path: artifacts/spec.json
|
||||
schema: dev/spec@1
|
||||
gates:
|
||||
- spec_approved
|
||||
- key: phase_plan
|
||||
title: Phase Plan
|
||||
risk: low
|
||||
roles:
|
||||
- phase_planner
|
||||
expectedArtifact:
|
||||
path: artifacts/phase-plan.json
|
||||
schema: dev/phase-plan@1
|
||||
gates:
|
||||
- phase_plan_approved
|
||||
defaultGates: []
|
||||
1098
packages/run-engine/src/engine.test.ts
Normal file
1098
packages/run-engine/src/engine.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
2336
packages/run-engine/src/engine.ts
Normal file
2336
packages/run-engine/src/engine.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -136,6 +136,18 @@ class StartFailsFakeAdapter extends FakeSessionAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
class ResumeFailsFakeAdapter extends FakeSessionAdapter {
|
||||
resumeAttempts = 0;
|
||||
|
||||
override async resume(_handle: SessionHandle): Promise<SessionHandle> {
|
||||
this.resumeAttempts += 1;
|
||||
throw new DevflowError("transient resume failure", {
|
||||
class: "recoverable",
|
||||
code: "pane_briefly_unresponsive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class PromptWritesArtifactBeforeReturnFakeAdapter extends FakeSessionAdapter {
|
||||
override async sendPrompt(
|
||||
handle: SessionHandle,
|
||||
@@ -334,7 +346,7 @@ describe("runSingleFakePhase", () => {
|
||||
worktreeRoot,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 100 },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "internal_state_corruption" });
|
||||
).rejects.toMatchObject({ code: "run_state_changed" });
|
||||
|
||||
const [run] = await db.select({ state: runs.state }).from(runs).where(eq(runs.id, runId));
|
||||
expect(run).toEqual({ state: runState });
|
||||
@@ -578,6 +590,209 @@ describe("runSingleFakePhase", () => {
|
||||
expect(approvals).toEqual([]);
|
||||
});
|
||||
|
||||
it("moves a successful workflow-gated phase from busy to waiting for approval without an idle event", async () => {
|
||||
const { db, phaseId, runId } = await createRunAndPhase();
|
||||
const worktreeRoot = realpathSync(
|
||||
mkdtempSync(join(tmpdir(), "devflow-fake-phase-workflow-gate-")),
|
||||
);
|
||||
tempRoots.push(worktreeRoot);
|
||||
const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json");
|
||||
const sessionId = randomUUID();
|
||||
|
||||
await runSingleFakePhase({
|
||||
adapter: new FakeSessionAdapter({ sessionIdFactory: () => sessionId, writeDelayMs: 0 }),
|
||||
db,
|
||||
expectedArtifactPath,
|
||||
expectedSchema: "dev/spec@1",
|
||||
instructions: "Scenario: ok\nWrite the development specification.",
|
||||
phaseId,
|
||||
phaseKey: "implement",
|
||||
roleId: "implementer",
|
||||
runId,
|
||||
worktreeRoot,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
workflowApprovalGateKey: "spec_approved",
|
||||
uuidFactory: () => "00000000-0000-4000-8000-000000000024",
|
||||
});
|
||||
|
||||
const [run] = await db
|
||||
.select({ currentPhaseId: runs.currentPhaseId, state: runs.state })
|
||||
.from(runs)
|
||||
.where(eq(runs.id, runId));
|
||||
expect(run).toEqual({ currentPhaseId: phaseId, state: "awaiting_approval" });
|
||||
|
||||
const [phase] = await db
|
||||
.select({ state: runPhases.state })
|
||||
.from(runPhases)
|
||||
.where(eq(runPhases.id, phaseId));
|
||||
expect(phase).toEqual({ state: "awaiting_approval" });
|
||||
|
||||
const [session] = await db
|
||||
.select({ state: tuiSessions.state })
|
||||
.from(tuiSessions)
|
||||
.where(eq(tuiSessions.id, sessionId));
|
||||
expect(session).toEqual({ state: "WAITING_FOR_APPROVAL" });
|
||||
|
||||
const [approval] = await db
|
||||
.select({ gateKey: approvalRequests.gateKey, state: approvalRequests.state })
|
||||
.from(approvalRequests)
|
||||
.where(eq(approvalRequests.runId, runId));
|
||||
expect(approval).toEqual({ gateKey: "spec_approved", state: "pending" });
|
||||
|
||||
const events = await db
|
||||
.select({ type: runEvents.type })
|
||||
.from(runEvents)
|
||||
.where(eq(runEvents.runId, runId))
|
||||
.orderBy(runEvents.seq);
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
"phase.started",
|
||||
"session.created",
|
||||
"session.ready",
|
||||
"session.busy",
|
||||
"prompt.sent",
|
||||
"artifact.expected",
|
||||
"artifact.validated",
|
||||
"approval.requested",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mark a timeout-repaired workflow-gated phase idle before approval", async () => {
|
||||
const { db, phaseId, runId } = await createRunAndPhase();
|
||||
const worktreeRoot = realpathSync(
|
||||
mkdtempSync(join(tmpdir(), "devflow-fake-phase-workflow-timeout-repair-")),
|
||||
);
|
||||
tempRoots.push(worktreeRoot);
|
||||
const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json");
|
||||
const sessionId = randomUUID();
|
||||
|
||||
await runSingleFakePhase({
|
||||
adapter: new FakeSessionAdapter({ sessionIdFactory: () => sessionId, writeDelayMs: 0 }),
|
||||
db,
|
||||
expectedArtifactPath,
|
||||
expectedSchema: "dev/spec@1",
|
||||
instructions: "Scenario: timeout\nWrite the development specification.",
|
||||
phaseId,
|
||||
phaseKey: "implement",
|
||||
roleId: "implementer",
|
||||
runId,
|
||||
worktreeRoot,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 10 },
|
||||
workflowApprovalGateKey: "spec_approved",
|
||||
uuidFactory: () => "00000000-0000-4000-8000-000000000036",
|
||||
});
|
||||
|
||||
const events = await db
|
||||
.select({ type: runEvents.type })
|
||||
.from(runEvents)
|
||||
.where(eq(runEvents.runId, runId))
|
||||
.orderBy(runEvents.seq);
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
"phase.started",
|
||||
"session.created",
|
||||
"session.ready",
|
||||
"session.busy",
|
||||
"prompt.sent",
|
||||
"artifact.expected",
|
||||
"artifact.timeout",
|
||||
"session.recovered",
|
||||
"phase.started",
|
||||
"session.busy",
|
||||
"prompt.repaired",
|
||||
"artifact.expected",
|
||||
"artifact.validated",
|
||||
"approval.requested",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mark a replayed valid artifact idle before requesting workflow approval", async () => {
|
||||
const { db, phaseId, runId } = await createRunAndPhase("executing", "validating", 1);
|
||||
await recordPhaseStarted(db, runId, phaseId);
|
||||
const worktreeRoot = realpathSync(
|
||||
mkdtempSync(join(tmpdir(), "devflow-fake-phase-workflow-gate-replay-")),
|
||||
);
|
||||
tempRoots.push(worktreeRoot);
|
||||
const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json");
|
||||
const instructions = "Scenario: ok\nWrite the development specification.";
|
||||
const promptHash = hash({
|
||||
attempt: 1,
|
||||
expectedArtifact: expectedArtifactPath,
|
||||
expectedSchema: "dev/spec@1",
|
||||
instructions,
|
||||
phaseKey: "implement",
|
||||
roleId: "implementer",
|
||||
runId,
|
||||
});
|
||||
const sessionId = randomUUID();
|
||||
const artifactId = randomUUID();
|
||||
const artifactHash = hash({ replay: "valid-workflow-gate" });
|
||||
await db.insert(tuiSessions).values({
|
||||
id: sessionId,
|
||||
runId,
|
||||
roleId: "implementer",
|
||||
backend: "fake",
|
||||
cwd: worktreeRoot,
|
||||
expectedArtifactPath,
|
||||
expectedSchema: "dev/spec@1",
|
||||
lastPromptHash: promptHash,
|
||||
lastPromptAt: new Date(Date.now() - 1000),
|
||||
state: "BUSY",
|
||||
});
|
||||
await db.insert(artifacts).values({
|
||||
id: artifactId,
|
||||
runId,
|
||||
phaseId,
|
||||
path: expectedArtifactPath,
|
||||
schemaId: "dev/spec@1",
|
||||
hash: artifactHash,
|
||||
valid: true,
|
||||
});
|
||||
await db.insert(runEvents).values({
|
||||
runId,
|
||||
phaseId,
|
||||
seq: 2n,
|
||||
type: "artifact.validated",
|
||||
payload: {
|
||||
artifactId,
|
||||
hash: artifactHash,
|
||||
path: expectedArtifactPath,
|
||||
schemaId: "dev/spec@1",
|
||||
},
|
||||
idempotencyKey: `artifact.validated:${phaseId}:${expectedArtifactPath}:${artifactHash}`,
|
||||
});
|
||||
|
||||
await runSingleFakePhase({
|
||||
adapter: new FakeSessionAdapter({ sessionIdFactory: () => sessionId, writeDelayMs: 0 }),
|
||||
db,
|
||||
expectedArtifactPath,
|
||||
expectedSchema: "dev/spec@1",
|
||||
instructions,
|
||||
phaseId,
|
||||
phaseKey: "implement",
|
||||
roleId: "implementer",
|
||||
runId,
|
||||
worktreeRoot,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
workflowApprovalGateKey: "spec_approved",
|
||||
});
|
||||
|
||||
const [session] = await db
|
||||
.select({ state: tuiSessions.state })
|
||||
.from(tuiSessions)
|
||||
.where(eq(tuiSessions.id, sessionId));
|
||||
expect(session).toEqual({ state: "WAITING_FOR_APPROVAL" });
|
||||
|
||||
const events = await db
|
||||
.select({ type: runEvents.type })
|
||||
.from(runEvents)
|
||||
.where(eq(runEvents.runId, runId))
|
||||
.orderBy(runEvents.seq);
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
"phase.started",
|
||||
"artifact.validated",
|
||||
"approval.requested",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resumes a running phase when prompt delivery succeeded before prompt.sent was recorded", async () => {
|
||||
const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1);
|
||||
await recordPhaseStarted(db, runId, phaseId);
|
||||
@@ -662,6 +877,53 @@ describe("runSingleFakePhase", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("requests a human gate when existing session resume exhausts retries", async () => {
|
||||
const { db, phaseId, runId } = await createRunAndPhase();
|
||||
const worktreeRoot = realpathSync(
|
||||
mkdtempSync(join(tmpdir(), "devflow-fake-phase-resume-fails-")),
|
||||
);
|
||||
tempRoots.push(worktreeRoot);
|
||||
const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json");
|
||||
const sessionId = randomUUID();
|
||||
await db.insert(tuiSessions).values({
|
||||
id: sessionId,
|
||||
runId,
|
||||
roleId: "implementer",
|
||||
backend: "fake",
|
||||
cwd: worktreeRoot,
|
||||
expectedArtifactPath,
|
||||
expectedSchema: "dev/spec@1",
|
||||
state: "READY",
|
||||
});
|
||||
const adapter = new ResumeFailsFakeAdapter();
|
||||
|
||||
await expect(
|
||||
runSingleFakePhase({
|
||||
adapter,
|
||||
db,
|
||||
expectedArtifactPath,
|
||||
expectedSchema: "dev/spec@1",
|
||||
instructions: "Scenario: ok\nExisting session resume fails.",
|
||||
phaseId,
|
||||
phaseKey: "implement",
|
||||
roleId: "implementer",
|
||||
runId,
|
||||
worktreeRoot,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
uuidFactory: () => "00000000-0000-4000-8000-000000000039",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "prompt_send_exhausted" });
|
||||
|
||||
expect(adapter.resumeAttempts).toBe(3);
|
||||
const [run] = await db.select({ state: runs.state }).from(runs).where(eq(runs.id, runId));
|
||||
expect(run).toEqual({ state: "paused" });
|
||||
const [approval] = await db
|
||||
.select({ gateKey: approvalRequests.gateKey, state: approvalRequests.state })
|
||||
.from(approvalRequests)
|
||||
.where(eq(approvalRequests.runId, runId));
|
||||
expect(approval).toEqual({ gateKey: "prompt_send_exhausted", state: "pending" });
|
||||
});
|
||||
|
||||
it("resumes a running phase when the crash happened before session creation", async () => {
|
||||
const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1);
|
||||
await recordPhaseStarted(db, runId, phaseId);
|
||||
@@ -898,6 +1160,65 @@ describe("runSingleFakePhase", () => {
|
||||
expect(run?.state).toBe("executing");
|
||||
});
|
||||
|
||||
it("reuses an idle role session when a later running phase has not sent its prompt yet", async () => {
|
||||
const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1);
|
||||
await recordPhaseStarted(db, runId, phaseId);
|
||||
const worktreeRoot = realpathSync(
|
||||
mkdtempSync(join(tmpdir(), "devflow-fake-phase-reuse-idle-session-")),
|
||||
);
|
||||
tempRoots.push(worktreeRoot);
|
||||
const previousArtifactPath = join(worktreeRoot, "artifacts", "previous-spec.json");
|
||||
const expectedArtifactPath = join(worktreeRoot, "artifacts", "next-spec.json");
|
||||
const adapter = new FakeSessionAdapter({ writeDelayMs: 0 });
|
||||
const sessionHandle = await adapter.start({
|
||||
backend: "fake",
|
||||
cwd: worktreeRoot,
|
||||
expectedArtifactPath: previousArtifactPath,
|
||||
expectedSchema: "dev/spec@1",
|
||||
roleId: "implementer",
|
||||
runId,
|
||||
});
|
||||
await db.insert(tuiSessions).values({
|
||||
id: sessionHandle.sessionId,
|
||||
runId,
|
||||
roleId: "implementer",
|
||||
backend: "fake",
|
||||
cwd: worktreeRoot,
|
||||
expectedArtifactPath: previousArtifactPath,
|
||||
expectedSchema: "dev/spec@1",
|
||||
state: "READY",
|
||||
});
|
||||
|
||||
await runSingleFakePhase({
|
||||
adapter,
|
||||
db,
|
||||
expectedArtifactPath,
|
||||
expectedSchema: "dev/spec@1",
|
||||
instructions: "Scenario: ok\nWrite the development specification.",
|
||||
phaseId,
|
||||
phaseKey: "implement",
|
||||
roleId: "implementer",
|
||||
runId,
|
||||
worktreeRoot,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
const [session] = await db
|
||||
.select({
|
||||
expectedArtifactPath: tuiSessions.expectedArtifactPath,
|
||||
expectedSchema: tuiSessions.expectedSchema,
|
||||
state: tuiSessions.state,
|
||||
})
|
||||
.from(tuiSessions)
|
||||
.where(eq(tuiSessions.id, sessionHandle.sessionId));
|
||||
expect(session).toEqual({
|
||||
expectedArtifactPath,
|
||||
expectedSchema: "dev/spec@1",
|
||||
state: "READY",
|
||||
});
|
||||
await expectRunCompleted(db, runId);
|
||||
});
|
||||
|
||||
it("replays an invalid validating artifact and uses the one repair attempt", async () => {
|
||||
const { db, phaseId, runId } = await createRunAndPhase("executing", "validating", 1);
|
||||
await recordPhaseStarted(db, runId, phaseId);
|
||||
@@ -1674,7 +1995,7 @@ describe("runSingleFakePhase", () => {
|
||||
worktreeRoot,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "internal_state_corruption" });
|
||||
).rejects.toMatchObject({ code: "run_state_changed" });
|
||||
|
||||
const events = await db
|
||||
.select({ type: runEvents.type })
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,3 @@
|
||||
export * from "./engine.js";
|
||||
export * from "./fake-phase-harness.js";
|
||||
export * from "./run-event-repository.js";
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
"test": "cd ../.. && vitest run --project packages/session"
|
||||
},
|
||||
"dependencies": {
|
||||
"@devflow/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@devflow/core": "workspace:*",
|
||||
"@devflow/db": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./adapter.js";
|
||||
export * from "./fake.js";
|
||||
export * from "./manager.js";
|
||||
export * from "./transcript.js";
|
||||
|
||||
410
packages/session/src/manager.ts
Normal file
410
packages/session/src/manager.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import { DevflowError, type PromptEnvelope } from "@devflow/core";
|
||||
import {
|
||||
type DbClient,
|
||||
RunEventRepository,
|
||||
approvalRequests,
|
||||
runs,
|
||||
tuiSessions,
|
||||
} from "@devflow/db";
|
||||
import { and, eq, inArray, ne, notInArray, sql } from "drizzle-orm";
|
||||
|
||||
import type {
|
||||
ProbeResult,
|
||||
SessionAdapter,
|
||||
SessionHandle,
|
||||
StartInput,
|
||||
TranscriptChunk,
|
||||
} from "./adapter.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(hashtext($1)) 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();
|
||||
const client = this.lockClient;
|
||||
this.lockClient = undefined;
|
||||
this.handles.clear();
|
||||
if (client !== undefined) {
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_unlock(hashtext($1))", ["devflow:session-manager"]);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(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,
|
||||
lastKnownPanePid: tuiSessions.lastKnownPanePid,
|
||||
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(
|
||||
ne(tuiSessions.state, "FAILED_NEEDS_HUMAN"),
|
||||
notInArray(runs.state, [...terminalRunStates]),
|
||||
)
|
||||
: and(
|
||||
ne(tuiSessions.state, "FAILED_NEEDS_HUMAN"),
|
||||
notInArray(runs.state, [...terminalRunStates]),
|
||||
inArray(tuiSessions.runId, [...this.recoveryRunIds]),
|
||||
),
|
||||
);
|
||||
|
||||
const recoveredSessionIds: string[] = [];
|
||||
const failedSessionIds: string[] = [];
|
||||
for (const session of sessionRows) {
|
||||
const handle = compactHandle(
|
||||
session.id,
|
||||
session.lastKnownPanePid,
|
||||
session.tmuxSession,
|
||||
session.tmuxWindow,
|
||||
);
|
||||
try {
|
||||
const resumed = await this.resumeWithRetry(handle);
|
||||
this.handles.set(resumed.sessionId, resumed);
|
||||
recoveredSessionIds.push(resumed.sessionId);
|
||||
} catch (error) {
|
||||
await this.markRecoveryFailed(session, error);
|
||||
failedSessionIds.push(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
return { recoveredSessionIds, failedSessionIds };
|
||||
}
|
||||
|
||||
private async markRecoveryFailed(
|
||||
session: {
|
||||
id: string;
|
||||
runId: string;
|
||||
roleId: string;
|
||||
backend: string;
|
||||
cwd: string;
|
||||
recoveryAttempts: number;
|
||||
},
|
||||
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}`;
|
||||
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> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= 2; attempt += 1) {
|
||||
try {
|
||||
return await this.track(this.adapter.resume(handle));
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!(error instanceof DevflowError) || error.class !== "recoverable") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
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 {
|
||||
return this.handles.get(handle.sessionId) ?? handle;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
function isTerminalRunState(state: string): state is (typeof terminalRunStates)[number] {
|
||||
return terminalRunStates.includes(state as (typeof terminalRunStates)[number]);
|
||||
}
|
||||
|
||||
function compactHandle(
|
||||
sessionId: string,
|
||||
pid: number | null,
|
||||
tmuxSession: string | null,
|
||||
tmuxWindow: string | null,
|
||||
): SessionHandle {
|
||||
return {
|
||||
sessionId,
|
||||
...(pid === null ? {} : { pid }),
|
||||
...(tmuxSession === null ? {} : { tmuxSession }),
|
||||
...(tmuxWindow === null ? {} : { tmuxWindow }),
|
||||
};
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -6,5 +6,5 @@
|
||||
"types": ["node", "vitest"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../core" }]
|
||||
"references": [{ "path": "../core" }, { "path": "../db" }]
|
||||
}
|
||||
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -58,6 +58,21 @@ importers:
|
||||
specifier: 2.1.8
|
||||
version: 2.1.8(@types/node@22.10.2)
|
||||
|
||||
apps/api:
|
||||
dependencies:
|
||||
'@devflow/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core
|
||||
'@devflow/db':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/db
|
||||
'@devflow/run-engine':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/run-engine
|
||||
'@devflow/session':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/session
|
||||
|
||||
apps/cli:
|
||||
dependencies:
|
||||
commander:
|
||||
@@ -120,7 +135,6 @@ importers:
|
||||
'@devflow/core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
devDependencies:
|
||||
'@devflow/db':
|
||||
specifier: workspace:*
|
||||
version: link:../db
|
||||
|
||||
24
tests/fixtures/fake-artifacts/dev/phase-plan@1/colliding-spec.json
vendored
Normal file
24
tests/fixtures/fake-artifacts/dev/phase-plan@1/colliding-spec.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"phases": [
|
||||
{
|
||||
"key": "spec",
|
||||
"title": "Colliding planned phase",
|
||||
"objective": "Prove planned phases cannot reuse template phase keys.",
|
||||
"roles": ["spec_writer"],
|
||||
"expectedArtifact": {
|
||||
"path": "artifacts/colliding-spec.json",
|
||||
"schema": "dev/spec@1"
|
||||
},
|
||||
"gates": [],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "TASK-1",
|
||||
"title": "Colliding task",
|
||||
"role": "spec_writer",
|
||||
"writeSet": ["src/**"],
|
||||
"dependsOn": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
24
tests/fixtures/fake-artifacts/dev/phase-plan@1/mixed-unbound-role.json
vendored
Normal file
24
tests/fixtures/fake-artifacts/dev/phase-plan@1/mixed-unbound-role.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"phases": [
|
||||
{
|
||||
"key": "mixed-unbound",
|
||||
"title": "Mixed bound and unbound planned phase",
|
||||
"objective": "Prove every planned phase role is validated before insertion.",
|
||||
"roles": ["spec_writer", "missing_role"],
|
||||
"expectedArtifact": {
|
||||
"path": "artifacts/mixed-unbound-spec.json",
|
||||
"schema": "dev/spec@1"
|
||||
},
|
||||
"gates": [],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "TASK-1",
|
||||
"title": "Impossible mixed-role task",
|
||||
"role": "missing_role",
|
||||
"writeSet": ["src/**"],
|
||||
"dependsOn": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
24
tests/fixtures/fake-artifacts/dev/phase-plan@1/ok.json
vendored
Normal file
24
tests/fixtures/fake-artifacts/dev/phase-plan@1/ok.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"phases": [
|
||||
{
|
||||
"key": "implement",
|
||||
"title": "Implement requested change",
|
||||
"objective": "Use the fake development run to prove the engine path.",
|
||||
"roles": ["spec_writer"],
|
||||
"expectedArtifact": {
|
||||
"path": "artifacts/implementation-spec.json",
|
||||
"schema": "dev/spec@1"
|
||||
},
|
||||
"gates": [],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "TASK-1",
|
||||
"title": "Apply implementation",
|
||||
"role": "spec_writer",
|
||||
"writeSet": ["src/**"],
|
||||
"dependsOn": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
11
tests/fixtures/fake-artifacts/dev/phase-plan@1/skip-only.json
vendored
Normal file
11
tests/fixtures/fake-artifacts/dev/phase-plan@1/skip-only.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"phases": [
|
||||
{
|
||||
"key": "documentation-note",
|
||||
"title": "Documentation Note",
|
||||
"objective": "Record that this planned phase has no artifact and should be skipped.",
|
||||
"roles": ["phase_planner"],
|
||||
"gates": []
|
||||
}
|
||||
]
|
||||
}
|
||||
44
tests/fixtures/fake-artifacts/dev/phase-plan@1/two-phases.json
vendored
Normal file
44
tests/fixtures/fake-artifacts/dev/phase-plan@1/two-phases.json
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"phases": [
|
||||
{
|
||||
"key": "implement-a",
|
||||
"title": "Implement first fake change",
|
||||
"objective": "First planned phase for replay serialization tests.",
|
||||
"roles": ["spec_writer"],
|
||||
"expectedArtifact": {
|
||||
"path": "artifacts/implementation-a-spec.json",
|
||||
"schema": "dev/spec@1"
|
||||
},
|
||||
"gates": [],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "TASK-A",
|
||||
"title": "Apply first implementation",
|
||||
"role": "spec_writer",
|
||||
"writeSet": ["src/a/**"],
|
||||
"dependsOn": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "implement-b",
|
||||
"title": "Implement second fake change",
|
||||
"objective": "Second planned phase for replay serialization tests.",
|
||||
"roles": ["spec_writer"],
|
||||
"expectedArtifact": {
|
||||
"path": "artifacts/implementation-b-spec.json",
|
||||
"schema": "dev/spec@1"
|
||||
},
|
||||
"gates": [],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "TASK-B",
|
||||
"title": "Apply second implementation",
|
||||
"role": "spec_writer",
|
||||
"writeSet": ["src/b/**"],
|
||||
"dependsOn": ["TASK-A"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
24
tests/fixtures/fake-artifacts/dev/phase-plan@1/unbound-role.json
vendored
Normal file
24
tests/fixtures/fake-artifacts/dev/phase-plan@1/unbound-role.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"phases": [
|
||||
{
|
||||
"key": "unbound",
|
||||
"title": "Unbound planned phase",
|
||||
"objective": "Prove approval-triggered advancement fails terminally on invalid plan roles.",
|
||||
"roles": ["missing_role"],
|
||||
"expectedArtifact": {
|
||||
"path": "artifacts/unbound-spec.json",
|
||||
"schema": "dev/spec@1"
|
||||
},
|
||||
"gates": [],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "TASK-1",
|
||||
"title": "Impossible task",
|
||||
"role": "missing_role",
|
||||
"writeSet": ["src/**"],
|
||||
"dependsOn": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
24
tests/fixtures/fake-artifacts/dev/phase-plan@1/unknown-schema.json
vendored
Normal file
24
tests/fixtures/fake-artifacts/dev/phase-plan@1/unknown-schema.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"phases": [
|
||||
{
|
||||
"key": "unknown-schema",
|
||||
"title": "Unknown schema planned phase",
|
||||
"objective": "Prove fatal planned-phase failures clean up every session.",
|
||||
"roles": ["spec_writer"],
|
||||
"expectedArtifact": {
|
||||
"path": "artifacts/unknown-schema.json",
|
||||
"schema": "dev/unknown-schema@1"
|
||||
},
|
||||
"gates": [],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "TASK-1",
|
||||
"title": "Write unknown schema artifact",
|
||||
"role": "spec_writer",
|
||||
"writeSet": ["src/**"],
|
||||
"dependsOn": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
{ "path": "./packages/db" },
|
||||
{ "path": "./packages/run-engine" },
|
||||
{ "path": "./packages/session" },
|
||||
{ "path": "./apps/api" },
|
||||
{ "path": "./apps/cli" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,5 +27,6 @@ export default defineWorkspace([
|
||||
nodeProject("packages/core", ["packages/core/src/**/*.test.ts"]),
|
||||
nodeProject("packages/session", ["packages/session/src/**/*.test.ts"]),
|
||||
nodeProject("packages/run-engine", ["packages/run-engine/src/**/*.test.ts"]),
|
||||
nodeProject("apps/api", ["apps/api/src/**/*.test.ts"]),
|
||||
nodeProject("apps/cli", ["apps/cli/src/**/*.test.ts"]),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user