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 {
|
class PromptWritesArtifactBeforeReturnFakeAdapter extends FakeSessionAdapter {
|
||||||
override async sendPrompt(
|
override async sendPrompt(
|
||||||
handle: SessionHandle,
|
handle: SessionHandle,
|
||||||
@@ -334,7 +346,7 @@ describe("runSingleFakePhase", () => {
|
|||||||
worktreeRoot,
|
worktreeRoot,
|
||||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 100 },
|
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));
|
const [run] = await db.select({ state: runs.state }).from(runs).where(eq(runs.id, runId));
|
||||||
expect(run).toEqual({ state: runState });
|
expect(run).toEqual({ state: runState });
|
||||||
@@ -578,6 +590,209 @@ describe("runSingleFakePhase", () => {
|
|||||||
expect(approvals).toEqual([]);
|
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 () => {
|
it("resumes a running phase when prompt delivery succeeded before prompt.sent was recorded", async () => {
|
||||||
const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1);
|
const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1);
|
||||||
await recordPhaseStarted(db, runId, phaseId);
|
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 () => {
|
it("resumes a running phase when the crash happened before session creation", async () => {
|
||||||
const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1);
|
const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1);
|
||||||
await recordPhaseStarted(db, runId, phaseId);
|
await recordPhaseStarted(db, runId, phaseId);
|
||||||
@@ -898,6 +1160,65 @@ describe("runSingleFakePhase", () => {
|
|||||||
expect(run?.state).toBe("executing");
|
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 () => {
|
it("replays an invalid validating artifact and uses the one repair attempt", async () => {
|
||||||
const { db, phaseId, runId } = await createRunAndPhase("executing", "validating", 1);
|
const { db, phaseId, runId } = await createRunAndPhase("executing", "validating", 1);
|
||||||
await recordPhaseStarted(db, runId, phaseId);
|
await recordPhaseStarted(db, runId, phaseId);
|
||||||
@@ -1674,7 +1995,7 @@ describe("runSingleFakePhase", () => {
|
|||||||
worktreeRoot,
|
worktreeRoot,
|
||||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||||
}),
|
}),
|
||||||
).rejects.toMatchObject({ code: "internal_state_corruption" });
|
).rejects.toMatchObject({ code: "run_state_changed" });
|
||||||
|
|
||||||
const events = await db
|
const events = await db
|
||||||
.select({ type: runEvents.type })
|
.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 "./fake-phase-harness.js";
|
||||||
export * from "./run-event-repository.js";
|
export * from "./run-event-repository.js";
|
||||||
|
|||||||
@@ -12,9 +12,7 @@
|
|||||||
"test": "cd ../.. && vitest run --project packages/session"
|
"test": "cd ../.. && vitest run --project packages/session"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@devflow/core": "workspace:*"
|
"@devflow/core": "workspace:*",
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@devflow/db": "workspace:*"
|
"@devflow/db": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./adapter.js";
|
export * from "./adapter.js";
|
||||||
export * from "./fake.js";
|
export * from "./fake.js";
|
||||||
|
export * from "./manager.js";
|
||||||
export * from "./transcript.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"]
|
"types": ["node", "vitest"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"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
|
specifier: 2.1.8
|
||||||
version: 2.1.8(@types/node@22.10.2)
|
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:
|
apps/cli:
|
||||||
dependencies:
|
dependencies:
|
||||||
commander:
|
commander:
|
||||||
@@ -120,7 +135,6 @@ importers:
|
|||||||
'@devflow/core':
|
'@devflow/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core
|
version: link:../core
|
||||||
devDependencies:
|
|
||||||
'@devflow/db':
|
'@devflow/db':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../db
|
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/db" },
|
||||||
{ "path": "./packages/run-engine" },
|
{ "path": "./packages/run-engine" },
|
||||||
{ "path": "./packages/session" },
|
{ "path": "./packages/session" },
|
||||||
|
{ "path": "./apps/api" },
|
||||||
{ "path": "./apps/cli" }
|
{ "path": "./apps/cli" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,5 +27,6 @@ export default defineWorkspace([
|
|||||||
nodeProject("packages/core", ["packages/core/src/**/*.test.ts"]),
|
nodeProject("packages/core", ["packages/core/src/**/*.test.ts"]),
|
||||||
nodeProject("packages/session", ["packages/session/src/**/*.test.ts"]),
|
nodeProject("packages/session", ["packages/session/src/**/*.test.ts"]),
|
||||||
nodeProject("packages/run-engine", ["packages/run-engine/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"]),
|
nodeProject("apps/cli", ["apps/cli/src/**/*.test.ts"]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user