feat: add minimal run engine

This commit is contained in:
chungyeong
2026-05-11 00:46:45 +09:00
parent 64efeabd33
commit 78ebd5ef78
26 changed files with 6045 additions and 209 deletions

17
apps/api/package.json Normal file
View 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
View 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
View 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
View 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
View 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" }
]
}