Files
dev-puppeteer/packages/run-engine/src/engine.ts
2026-05-11 00:46:45 +09:00

2337 lines
70 KiB
TypeScript

import { execFile } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { realpathSync } from "node:fs";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join, relative, resolve } from "node:path";
import { promisify } from "node:util";
import {
ApprovalDecisionAction,
type ApprovalDecisionAction as ApprovalDecisionActionValue,
type BackendConfig,
type BindingOverrides,
DevflowError,
Persona,
Template,
bindTemplatePersonas,
hash,
validateArtifact,
} from "@devflow/core";
import {
type DbClient,
RunEventRepository,
agentPersonas,
approvalDecisions,
approvalRequests,
artifacts,
commands,
reviewFindings,
runBindings,
runEvents,
runInputs,
runPhases,
runs,
tuiSessions,
workflowTemplates,
} from "@devflow/db";
import type { SessionRuntime } from "@devflow/session";
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
import { runSingleFakePhase } from "./fake-phase-harness.js";
type Database = DbClient["db"];
type TransactionDb = Parameters<Parameters<Database["transaction"]>[0]>[0];
const terminalRunStates = ["completed", "failed", "aborted"] as const;
const phaseMutationRunStates = ["executing", "planning"] as const;
const execFileAsync = promisify(execFile);
export interface RunEngine {
startRun(input: RunStartInput): Promise<{ runId: string }>;
signalApproval(
runId: string,
approvalRequestId: string,
action: ApprovalDecisionActionValue,
clientToken: string,
comment?: string,
): Promise<void>;
pauseRun(runId: string): Promise<void>;
resumeRun(runId: string): Promise<void>;
abortRun(runId: string, reason: string): Promise<void>;
getStatus(runId: string): Promise<RunStatus>;
}
export interface RunStartInput {
requirementsMd: string;
repoPath: string;
baseBranch: string;
templateName?: string;
templateVersion?: number;
worktreeRoot?: string;
objective?: unknown;
extra?: Record<string, unknown>;
overrides?: Partial<BindingOverrides>;
scenarios?: Record<string, FakePhaseScenario>;
runId?: string;
}
export type FakePhaseScenario =
| string
| {
scenario?: string;
repairScenario?: string;
};
export interface DbRunEngineOptions {
db: Database;
sessions: SessionRuntime;
workspaceRoot: string;
availableBackends?: readonly BackendConfig[];
maxConcurrentRuns?: number;
wait?: {
timeoutMs?: number;
pollIntervalMs?: number;
stableMs?: number;
};
}
export interface RunStatus {
run: {
id: string;
state: string;
repoPath: string;
baseBranch: string;
worktreeRoot: string;
currentPhaseId: string | null;
finalReportPath: string | null;
startedAt: Date | null;
endedAt: Date | null;
};
phases: Array<{
id: string;
phaseKey: string;
seq: number;
state: string;
attempts: number;
}>;
approvals: Array<{
id: string;
phaseId: string | null;
gateKey: string;
state: string;
}>;
eventsTail: Array<{
id: string;
seq: string;
type: string;
payload: unknown;
ts: Date;
}>;
}
interface TemplateRecord {
id: string;
hash: string;
definition: unknown;
}
interface PersonaRecord {
id: string;
name: string;
version: number;
hash: string;
definition: unknown;
}
interface StoredRunContext {
template: Template;
input: {
requirementsMd: string;
extra: unknown;
};
}
interface EnginePhaseDefinition {
key: string;
title: string;
roles: string[];
expectedArtifact?: {
path: string;
schema: string;
};
gates: string[];
timeoutMs?: number;
}
export class DbRunEngine implements RunEngine {
private readonly db: Database;
private readonly sessions: SessionRuntime;
private readonly workspaceRoot: string;
private readonly availableBackends: readonly BackendConfig[];
private readonly maxConcurrentRuns: number;
private readonly wait: DbRunEngineOptions["wait"];
constructor(options: DbRunEngineOptions) {
this.db = options.db;
this.sessions = options.sessions;
this.workspaceRoot = realpathSync(resolve(options.workspaceRoot));
this.availableBackends = options.availableBackends ?? [
{ id: "fake", enabled: true, binaryPath: undefined },
];
this.maxConcurrentRuns = options.maxConcurrentRuns ?? 4;
this.wait = options.wait;
}
async startRun(input: RunStartInput): Promise<{ runId: string }> {
const runId = input.runId ?? randomUUID();
const templateName = input.templateName ?? "development";
const templateVersion = input.templateVersion ?? 1;
const repoPath = canonicalExistingPath(input.repoPath);
const worktreeRoot = await this.resolveWorktreeRoot(runId, input.worktreeRoot);
const templateRecord = await this.loadTemplate(templateName, templateVersion);
const template = Template.parse(templateRecord.definition);
const personaRecords = await this.loadPersonas();
const personas = personaRecords.map((row) => Persona.parse(row.definition));
const inputExtra = storeEngineMetadata(input.extra, input.scenarios);
const inputHash = hash({
templateHash: templateRecord.hash,
bindings: [],
requirementsMd: input.requirementsMd,
objective: input.objective ?? null,
repoPath,
baseBranch: input.baseBranch,
extra: inputExtra,
});
let runInserted = false;
try {
await this.db.transaction(async (tx) => {
await this.lockStartAttempt(tx, repoPath, input.baseBranch);
await this.assertRunCanStart(tx, repoPath, input.baseBranch);
await tx.insert(runs).values({
id: runId,
templateId: templateRecord.id,
templateHash: templateRecord.hash,
state: "created",
repoPath,
baseBranch: input.baseBranch,
worktreeRoot,
});
await tx.insert(runInputs).values({
runId,
requirementsMd: input.requirementsMd,
objective: input.objective ?? null,
extra: inputExtra,
inputHash,
});
await tx.insert(runPhases).values(
template.phases.map((phase, index) => ({
runId,
phaseKey: phase.key,
seq: index + 1,
state: "pending",
})),
);
await new RunEventRepository(this.db).appendInTransaction(tx, {
runId,
type: "run.created",
payload: { templateName, templateVersion },
idempotencyKey: `run.created:${runId}`,
});
});
runInserted = true;
const canonicalWorktreeRoot = await this.createGitWorktree(
repoPath,
input.baseBranch,
runId,
worktreeRoot,
);
if (canonicalWorktreeRoot !== worktreeRoot) {
await this.db
.update(runs)
.set({ worktreeRoot: canonicalWorktreeRoot, updatedAt: new Date() })
.where(eq(runs.id, runId));
}
} catch (error) {
if (isPgConstraintViolation(error, "ux_active_run_repo_base")) {
throw await this.activeRunConflict(repoPath, input.baseBranch);
}
if (runInserted) {
await this.markRunFailedIfActive(runId, "worktree_create_failed");
}
throw error;
}
try {
await this.lockBindings(
runId,
template,
templateRecord.hash,
personaRecords,
personas,
input,
);
await this.advanceRun(runId);
} catch (error) {
if (await this.shouldPreserveHumanGateRun(runId, error)) {
return { runId };
}
await this.markRunFailedIfActive(runId, "start_run_failed");
throw error;
}
return { runId };
}
private async lockStartAttempt(
tx: TransactionDb,
repoPath: string,
baseBranch: string,
): Promise<void> {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('devflow:start-run-global'))`);
await tx.execute(
sql`SELECT pg_advisory_xact_lock(hashtext('devflow:start-run'), hashtext(${`${repoPath}:${baseBranch}`}))`,
);
}
private async assertRunCanStart(
tx: TransactionDb,
repoPath: string,
baseBranch: string,
): Promise<void> {
const [existing] = await activeRunForRepoBase(tx, repoPath, baseBranch);
if (existing !== undefined) {
throw activeRunExists(existing.id, existing.state);
}
const [count] = await tx
.select({ value: sql<number>`count(*)::int` })
.from(runs)
.where(sql`${runs.state} NOT IN ('completed', 'failed', 'aborted')`);
if ((count?.value ?? 0) >= this.maxConcurrentRuns) {
throw new DevflowError("Maximum concurrent runs reached", {
class: "human_required",
code: "max_concurrent_runs",
recoveryHint: `maxConcurrentRuns=${this.maxConcurrentRuns}`,
});
}
}
private async activeRunConflict(repoPath: string, baseBranch: string): Promise<DevflowError> {
const [existing] = await activeRunForRepoBase(this.db, repoPath, baseBranch);
return activeRunExists(existing?.id ?? "unknown", existing?.state ?? "unknown");
}
async signalApproval(
runId: string,
approvalRequestId: string,
action: ApprovalDecisionActionValue,
clientToken: string,
comment?: string,
): Promise<void> {
const parsedAction = ApprovalDecisionAction.parse(action);
const decision = await this.recordApprovalDecision(
runId,
approvalRequestId,
parsedAction,
clientToken,
comment,
);
if (parsedAction === "approve" || parsedAction === "request_changes") {
try {
await this.advanceRun(runId);
} catch (error) {
if (await this.shouldPreserveHumanGateRun(runId, error)) {
return;
}
await this.markRunFailedIfActive(runId, "approval_advance_failed");
throw error;
}
return;
}
if (parsedAction === "reject") {
await this.composeFinalReportBestEffort(runId, "failed");
return;
}
await this.composeFinalReportBestEffort(runId, "aborted");
}
async pauseRun(runId: string): Promise<void> {
const eventRepository = new RunEventRepository(this.db);
await this.db.transaction(async (tx) => {
const [run] = await lockRun(tx, runId);
if (run === undefined || isTerminalRunState(run.state)) {
return;
}
if (run.state === "paused") {
return;
}
if (!["planning", "executing", "awaiting_approval"].includes(run.state)) {
return;
}
const cause = `signal:${randomUUID()}`;
await tx
.update(runs)
.set({ state: "paused", pausedFromState: run.state, updatedAt: new Date() })
.where(eq(runs.id, runId));
await eventRepository.appendInTransaction(tx, {
runId,
type: "run.paused",
payload: { cause, pausedFromState: run.state },
idempotencyKey: `run.paused:${runId}:${cause}`,
});
});
}
async resumeRun(runId: string): Promise<void> {
const eventRepository = new RunEventRepository(this.db);
let shouldAdvance = false;
await this.db.transaction(async (tx) => {
const [run] = await lockRun(tx, runId);
if (run === undefined || run.state !== "paused") {
return;
}
if (await hasPendingHumanRequiredGate(tx, runId)) {
throw approvalConflict(runId, "pending human-required gate must be resolved first");
}
const nextState = run.pausedFromState ?? "executing";
const cause = `signal:${randomUUID()}`;
await tx
.update(runs)
.set({ state: nextState, pausedFromState: null, updatedAt: new Date() })
.where(eq(runs.id, runId));
await eventRepository.appendInTransaction(tx, {
runId,
type: "run.resumed",
payload: { cause },
idempotencyKey: `run.resumed:${runId}:${cause}`,
});
shouldAdvance = nextState === "executing" || nextState === "planning";
});
if (shouldAdvance) {
try {
await this.advanceRun(runId, { resumeActivePhase: true });
} catch (error) {
if (await this.shouldPreserveHumanGateRun(runId, error)) {
return;
}
await this.markRunFailedIfActive(runId, "resume_advance_failed");
throw error;
}
}
}
async abortRun(runId: string, reason: string): Promise<void> {
const eventRepository = new RunEventRepository(this.db);
let aborted = false;
let sessionsToDispose: string[] = [];
await this.db.transaction(async (tx) => {
const [run] = await lockRun(tx, runId);
if (run === undefined || isTerminalRunState(run.state)) {
return;
}
await tx
.update(runs)
.set({
state: "aborted",
currentPhaseId: null,
pausedFromState: null,
endedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(runs.id, runId));
await eventRepository.appendInTransaction(tx, {
runId,
type: "run.aborted",
payload: { reason },
idempotencyKey: `run.aborted:${runId}`,
});
await failActivePhasesInTransaction(tx, eventRepository, runId, "abort");
await abortPendingApprovalsInTransaction(tx, runId);
sessionsToDispose = await markSessionsFailedInTransaction(tx, eventRepository, runId);
aborted = true;
});
if (aborted) {
await this.disposeSessions(sessionsToDispose);
await this.composeFinalReportBestEffort(runId, "aborted");
}
}
async getStatus(runId: string): Promise<RunStatus> {
const [run] = await this.db
.select({
id: runs.id,
state: runs.state,
repoPath: runs.repoPath,
baseBranch: runs.baseBranch,
worktreeRoot: runs.worktreeRoot,
currentPhaseId: runs.currentPhaseId,
finalReportPath: runs.finalReportPath,
startedAt: runs.startedAt,
endedAt: runs.endedAt,
})
.from(runs)
.where(eq(runs.id, runId))
.limit(1);
if (run === undefined) {
throw runNotFound(runId);
}
const [phases, approvals, eventsTail] = await Promise.all([
this.db
.select({
id: runPhases.id,
phaseKey: runPhases.phaseKey,
seq: runPhases.seq,
state: runPhases.state,
attempts: runPhases.attempts,
})
.from(runPhases)
.where(eq(runPhases.runId, runId))
.orderBy(asc(runPhases.seq)),
this.db
.select({
id: approvalRequests.id,
phaseId: approvalRequests.phaseId,
gateKey: approvalRequests.gateKey,
state: approvalRequests.state,
})
.from(approvalRequests)
.where(eq(approvalRequests.runId, runId))
.orderBy(asc(approvalRequests.createdAt)),
this.db
.select({
id: runEvents.id,
seq: runEvents.seq,
type: runEvents.type,
payload: runEvents.payload,
ts: runEvents.ts,
})
.from(runEvents)
.where(eq(runEvents.runId, runId))
.orderBy(desc(runEvents.seq))
.limit(20),
]);
return {
run,
phases,
approvals,
eventsTail: eventsTail.reverse().map((event) => ({
id: event.id.toString(),
seq: event.seq.toString(),
type: event.type,
payload: event.payload,
ts: event.ts,
})),
};
}
private async lockBindings(
runId: string,
template: Template,
templateHash: string,
personaRecords: PersonaRecord[],
personas: TemplateCompatiblePersona[],
input: RunStartInput,
): Promise<void> {
const bindInput = {
runId,
template,
personas,
templateHash,
availableBackends: this.availableBackends,
...(input.overrides === undefined ? {} : { overrides: input.overrides }),
};
const result = bindTemplatePersonas(bindInput);
const personaRowsByIdentity = new Map(
personaRecords.map((row) => [`${row.name}@${row.version}`, row]),
);
const bindingHashes = result.bindings
.map((binding) => binding.bindingHash)
.sort((left, right) => left.localeCompare(right));
const inputHashWithBindings = hash({
templateHash,
bindings: bindingHashes,
requirementsMd: input.requirementsMd,
objective: input.objective ?? null,
repoPath: canonicalExistingPath(input.repoPath),
baseBranch: input.baseBranch,
extra: storeEngineMetadata(input.extra, input.scenarios),
});
await this.db.transaction(async (tx) => {
const [run] = await lockRun(tx, runId);
if (run === undefined || run.state !== "created") {
throw runStateChanged(runId, undefined, run?.state ?? "missing");
}
await tx.insert(runBindings).values(
result.bindings.map((binding) => {
const personaRow = personaRowsByIdentity.get(
`${binding.persona.name}@${binding.persona.version}`,
);
if (personaRow === undefined) {
throw new DevflowError("Binding persona row is missing", {
class: "fatal",
code: "internal_state_corruption",
runId,
});
}
return {
runId,
roleId: binding.roleId,
personaId: personaRow.id,
personaHash: binding.personaHash,
backend: binding.backend,
bindingHash: binding.bindingHash,
};
}),
);
await tx
.update(runInputs)
.set({ inputHash: inputHashWithBindings })
.where(eq(runInputs.runId, runId));
await tx
.update(runs)
.set({ state: "bound", startedAt: new Date(), updatedAt: new Date() })
.where(and(eq(runs.id, runId), eq(runs.state, "created")));
await new RunEventRepository(this.db).appendInTransaction(tx, {
runId,
type: "run.started",
payload: { templateHash },
idempotencyKey: `run.started:${runId}`,
});
});
}
private async advanceRun(
runId: string,
options: { resumeActivePhase?: boolean } = {},
): Promise<void> {
while (true) {
const context = await this.loadRunContext(runId);
const [run] = await this.db
.select({
state: runs.state,
currentPhaseId: runs.currentPhaseId,
finalReportPath: runs.finalReportPath,
worktreeRoot: runs.worktreeRoot,
})
.from(runs)
.where(eq(runs.id, runId))
.limit(1);
if (run === undefined) {
throw runNotFound(runId);
}
if (run.state === "bound") {
await this.promoteBoundRun(runId);
continue;
}
if (run.state === "awaiting_approval" || run.state === "paused") {
return;
}
if (isTerminalRunState(run.state)) {
if (run.finalReportPath === null) {
await this.composeFinalReportBestEffort(runId, run.state);
}
return;
}
if (run.state !== "executing" && run.state !== "planning") {
throw new DevflowError("Run is not executable", {
class: "fatal",
code: "internal_state_corruption",
runId,
recoveryHint: `run_state=${run.state}`,
});
}
const phaseDefinitions = [
...context.template.phases.map(toEnginePhaseDefinition),
...(await this.loadPlannedPhaseDefinitions(runId, run.worktreeRoot)),
];
const activePhase = await this.activePhase(runId);
if (activePhase !== undefined) {
if (!options.resumeActivePhase || run.currentPhaseId !== activePhase.id) {
return;
}
await this.executePhase(runId, run.worktreeRoot, context, activePhase, phaseDefinitions);
continue;
}
const nextPhase = await this.nextPendingPhase(runId);
if (nextPhase === undefined) {
if (
await this.ensurePlannedPhaseRows(
runId,
run.worktreeRoot,
context.template.phases.map((phase) => phase.key),
)
) {
continue;
}
await this.completeRun(runId);
return;
}
await this.executePhase(runId, run.worktreeRoot, context, nextPhase, phaseDefinitions);
}
}
private async promoteBoundRun(runId: string): Promise<void> {
await this.db.transaction(async (tx) => {
const [run] = await lockRun(tx, runId);
if (run === undefined || run.state !== "bound") {
return;
}
await tx
.update(runs)
.set({ state: "executing", updatedAt: new Date() })
.where(and(eq(runs.id, runId), eq(runs.state, "bound")));
});
}
private async executePhase(
runId: string,
worktreeRoot: string,
context: StoredRunContext,
phaseRow: { id: string; phaseKey: string },
phaseDefinitions: readonly EnginePhaseDefinition[],
): Promise<void> {
const phaseDefinition = phaseDefinitions.find((phase) => phase.key === phaseRow.phaseKey);
if (phaseDefinition === undefined) {
throw new DevflowError("Run phase is missing from template", {
class: "fatal",
code: "internal_state_corruption",
runId,
phaseId: phaseRow.id,
});
}
if (phaseDefinition.expectedArtifact === undefined) {
await this.setCurrentPhase(runId, phaseRow.id);
await this.skipPhase(runId, phaseRow.id, phaseRow.phaseKey);
await this.clearCurrentPhase(runId, phaseRow.id);
return;
}
await this.prepareRunForPhase(runId, phaseRow.id, phaseDefinition.expectedArtifact.schema);
const binding = await this.bindingForPhase(runId, phaseDefinition.roles);
const expectedArtifactPath = resolve(worktreeRoot, phaseDefinition.expectedArtifact.path);
const wait =
phaseDefinition.timeoutMs === undefined
? this.wait
: { ...this.wait, timeoutMs: phaseDefinition.timeoutMs };
const workflowApprovalGateKey = phaseDefinition.gates[0];
await this.sessions.trackOperation(
runSingleFakePhase({
db: this.db,
sessions: this.sessions,
runId,
phaseId: phaseRow.id,
phaseKey: phaseRow.phaseKey,
roleId: binding.roleId,
worktreeRoot,
expectedArtifactPath,
expectedSchema: phaseDefinition.expectedArtifact.schema,
instructions: buildPhaseInstructions(
phaseRow.phaseKey,
phaseDefinition.title,
context.input.requirementsMd,
scenarioForPhase(context.input.extra, phaseRow.phaseKey),
),
...(wait === undefined ? {} : { wait }),
terminalRun: false,
...(workflowApprovalGateKey === undefined
? {}
: {
workflowApprovalGateKey,
workflowApprovalPayload: {
phaseKey: phaseRow.phaseKey,
title: phaseDefinition.title,
expectedArtifactPath,
expectedSchema: phaseDefinition.expectedArtifact.schema,
},
}),
}),
);
if (workflowApprovalGateKey === undefined) {
await this.clearCurrentPhase(runId, phaseRow.id);
}
}
private async completeRun(runId: string): Promise<void> {
const eventRepository = new RunEventRepository(this.db);
let completed = false;
await this.db.transaction(async (tx) => {
const [run] = await lockRun(tx, runId);
if (run === undefined || isTerminalRunState(run.state)) {
return;
}
if (!isPhaseMutationRunState(run.state)) {
throw runStateChanged(runId, undefined, run.state);
}
completed = true;
await tx
.update(runs)
.set({
state: "completed",
currentPhaseId: null,
pausedFromState: null,
endedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(runs.id, runId));
await eventRepository.appendInTransaction(tx, {
runId,
type: "run.completed",
payload: {},
idempotencyKey: `run.completed:${runId}`,
});
});
if (!completed) {
return;
}
await this.composeFinalReportBestEffort(runId, "completed");
}
private async prepareRunForPhase(
runId: string,
phaseId: string,
expectedSchema: string,
): Promise<void> {
const state = expectedSchema === "dev/phase-plan@1" ? "planning" : "executing";
await this.db.transaction(async (tx) => {
await assertRunCanMutatePhaseInTransaction(tx, runId, phaseId);
await tx
.update(runs)
.set({ state, currentPhaseId: phaseId, updatedAt: new Date() })
.where(and(eq(runs.id, runId), inArray(runs.state, ["executing", "planning"])));
});
}
private async setCurrentPhase(runId: string, phaseId: string): Promise<void> {
await this.db.transaction(async (tx) => {
await assertRunCanMutatePhaseInTransaction(tx, runId, phaseId);
await tx
.update(runs)
.set({ currentPhaseId: phaseId, updatedAt: new Date() })
.where(and(eq(runs.id, runId), inArray(runs.state, ["executing", "planning"])));
});
}
private async clearCurrentPhase(runId: string, phaseId: string): Promise<void> {
await this.db
.update(runs)
.set({ currentPhaseId: null, updatedAt: new Date() })
.where(and(eq(runs.id, runId), eq(runs.currentPhaseId, phaseId)));
}
private async recordApprovalDecision(
runId: string,
approvalRequestId: string,
action: ApprovalDecisionActionValue,
clientToken: string,
comment: string | undefined,
): Promise<{ replayed: boolean }> {
const decisionIdempotencyKey = `${approvalRequestId}:${action}:${clientToken}`;
const eventRepository = new RunEventRepository(this.db);
let sessionsToDispose: string[] = [];
const result = await this.db.transaction(async (tx) => {
const [run] = await lockRun(tx, runId);
if (run === undefined) {
throw runNotFound(runId);
}
await tx.execute(
sql`SELECT 1 FROM ${approvalRequests} WHERE ${approvalRequests.id} = ${approvalRequestId} FOR UPDATE`,
);
const [request] = await tx
.select({
id: approvalRequests.id,
runId: approvalRequests.runId,
phaseId: approvalRequests.phaseId,
state: approvalRequests.state,
})
.from(approvalRequests)
.where(and(eq(approvalRequests.id, approvalRequestId), eq(approvalRequests.runId, runId)))
.limit(1);
if (request === undefined) {
throw new DevflowError("Approval request does not exist", {
class: "human_required",
code: "approval_not_found",
runId,
});
}
const existingDecision = await existingDecisionForToken(tx, approvalRequestId, clientToken);
if (existingDecision !== undefined) {
if (existingDecision.action !== action) {
throw approvalConflict(runId, "client token already used for a different action");
}
return { replayed: true };
}
if (isTerminalRunState(run.state)) {
throw approvalConflict(runId, `run_state=${run.state}`);
}
if (run.state !== "awaiting_approval" && run.state !== "paused") {
throw approvalConflict(runId, `run_state=${run.state}`);
}
if (run.state === "paused") {
const resolvesHumanRequiredGate =
(action === "reject" || action === "abort") &&
(request.phaseId === null ||
(await isHumanRequiredApprovalPhase(tx, runId, request.phaseId)));
if (!resolvesHumanRequiredGate) {
throw approvalConflict(runId, "paused runs must be resumed before approval decisions");
}
}
if (request.state !== "pending") {
throw approvalConflict(runId, `approval_state=${request.state}`);
}
await tx.insert(approvalDecisions).values({
approvalRequestId,
action,
comment,
idempotencyKey: decisionIdempotencyKey,
});
await tx
.update(approvalRequests)
.set({ state: approvalStateForAction(action), resolvedAt: new Date() })
.where(eq(approvalRequests.id, approvalRequestId));
await eventRepository.appendInTransaction(tx, {
runId,
...(request.phaseId === null ? {} : { phaseId: request.phaseId }),
type: "approval.resolved",
payload: { approvalRequestId, action },
idempotencyKey: `approval.resolved:${approvalRequestId}:${action}`,
});
if (action === "approve") {
if (request.phaseId !== null) {
await completeApprovedPhase(tx, eventRepository, runId, request.phaseId);
}
await tx
.update(runs)
.set({
state: "executing",
currentPhaseId: null,
pausedFromState: null,
updatedAt: new Date(),
})
.where(eq(runs.id, runId));
await eventRepository.appendInTransaction(tx, {
runId,
type: "run.resumed",
payload: { cause: `approval:${approvalRequestId}:${action}` },
idempotencyKey: `run.resumed:${runId}:approval:${approvalRequestId}:${action}`,
});
return { replayed: false };
}
if (action === "request_changes") {
if (request.phaseId !== null) {
await resetPhaseForChanges(tx, eventRepository, runId, request.phaseId);
}
await tx
.update(runs)
.set({
state: "planning",
currentPhaseId: request.phaseId,
pausedFromState: null,
updatedAt: new Date(),
})
.where(eq(runs.id, runId));
await eventRepository.appendInTransaction(tx, {
runId,
type: "run.resumed",
payload: { cause: `approval:${approvalRequestId}:${action}` },
idempotencyKey: `run.resumed:${runId}:approval:${approvalRequestId}:${action}`,
});
return { replayed: false };
}
const state: "aborted" | "failed" = action === "abort" ? "aborted" : "failed";
await tx
.update(runs)
.set({
state,
currentPhaseId: null,
pausedFromState: null,
endedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(runs.id, runId));
if (request.phaseId !== null) {
await failApprovalPhase(tx, eventRepository, runId, request.phaseId, action);
}
if (action === "abort" || action === "reject") {
await abortPendingApprovalsInTransaction(tx, runId);
sessionsToDispose = await markSessionsFailedInTransaction(tx, eventRepository, runId);
}
await eventRepository.appendInTransaction(tx, {
runId,
type: action === "abort" ? "run.aborted" : "run.failed",
payload: { reason: `approval_${action}` },
idempotencyKey: `${action === "abort" ? "run.aborted" : "run.failed"}:${runId}`,
});
return { replayed: false };
});
if (sessionsToDispose.length > 0) {
await this.disposeSessions(sessionsToDispose);
}
return result;
}
private async composeFinalReport(
runId: string,
status: "completed" | "failed" | "aborted",
): Promise<string> {
const [run] = await this.db
.select({
id: runs.id,
templateHash: runs.templateHash,
worktreeRoot: runs.worktreeRoot,
finalReportPath: runs.finalReportPath,
endedAt: runs.endedAt,
})
.from(runs)
.where(eq(runs.id, runId))
.limit(1);
if (run === undefined) {
throw runNotFound(runId);
}
const endedAt = (run.endedAt ?? new Date()).toISOString();
const report = await this.buildFinalReport(runId, run.templateHash, endedAt, status);
const validation = validateArtifact("common/final-report@1", report);
if (!validation.ok) {
throw new DevflowError("Composed final report failed schema validation", {
class: "fatal",
code: "internal_state_corruption",
runId,
recoveryHint: JSON.stringify(validation.errors),
});
}
const reportRoot = join(this.workspaceRoot, runId);
await mkdir(reportRoot, { recursive: true });
const jsonPath = join(reportRoot, `${runId}.report.json`);
const markdownPath = join(reportRoot, `${runId}.report.md`);
await atomicWriteFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
await atomicWriteFile(markdownPath, renderMarkdownReport(report));
await this.db
.update(runs)
.set({
finalReportPath: markdownPath,
endedAt: new Date(endedAt),
updatedAt: new Date(),
})
.where(eq(runs.id, runId));
return markdownPath;
}
private async buildFinalReport(
runId: string,
templateHash: string,
endedAt: string,
status: "completed" | "failed" | "aborted",
): Promise<Record<string, unknown>> {
const [input, bindings, phases, approvals, findings, commandRows, artifactRows, eventsTail] =
await Promise.all([
this.db.select().from(runInputs).where(eq(runInputs.runId, runId)).limit(1),
this.db
.select({
roleId: runBindings.roleId,
personaHash: runBindings.personaHash,
backend: runBindings.backend,
})
.from(runBindings)
.where(eq(runBindings.runId, runId))
.orderBy(asc(runBindings.roleId)),
this.db
.select()
.from(runPhases)
.where(eq(runPhases.runId, runId))
.orderBy(asc(runPhases.seq)),
this.db
.select()
.from(approvalRequests)
.where(eq(approvalRequests.runId, runId))
.orderBy(asc(approvalRequests.createdAt)),
this.db
.select()
.from(reviewFindings)
.where(eq(reviewFindings.runId, runId))
.orderBy(asc(reviewFindings.createdAt)),
this.db
.select({
kind: commands.kind,
argv: commands.argv,
exitCode: commands.exitCode,
})
.from(commands)
.where(eq(commands.runId, runId))
.orderBy(asc(commands.createdAt)),
this.db
.select()
.from(artifacts)
.where(eq(artifacts.runId, runId))
.orderBy(asc(artifacts.createdAt)),
this.db
.select({
id: runEvents.id,
seq: runEvents.seq,
type: runEvents.type,
payload: runEvents.payload,
ts: runEvents.ts,
})
.from(runEvents)
.where(eq(runEvents.runId, runId))
.orderBy(desc(runEvents.seq))
.limit(200),
]);
const unresolved = approvals
.filter((approval) => approval.state === "pending" || approval.state === "paused")
.map((approval) => ({
type: "approval",
approvalId: approval.id,
gateKey: approval.gateKey,
state: approval.state,
}));
return {
runId,
templateHash,
bindings,
inputs: serializeJson({
requirementsMd: input[0]?.requirementsMd ?? "",
objective: input[0]?.objective ?? null,
extra: input[0]?.extra ?? null,
inputHash: input[0]?.inputHash ?? "",
}),
phases: serializeJson(phases),
approvals: serializeJson(approvals),
findings: serializeJson(findings),
commands: commandRows.map((command) => ({
kind: command.kind,
argv: command.argv,
exit_code: command.exitCode,
})),
artifacts: serializeJson(artifactRows),
events: {
tail: eventsTail.reverse().map((event) => ({
id: event.id.toString(),
seq: event.seq.toString(),
type: event.type,
payload: event.payload,
ts: event.ts.toISOString(),
})),
},
unresolved,
endedAt,
status,
};
}
private async loadTemplate(name: string, version: number): Promise<TemplateRecord> {
const [template] = await this.db
.select({
id: workflowTemplates.id,
hash: workflowTemplates.hash,
definition: workflowTemplates.definition,
})
.from(workflowTemplates)
.where(and(eq(workflowTemplates.name, name), eq(workflowTemplates.version, version)))
.limit(1);
if (template === undefined) {
throw new DevflowError("Workflow template is not seeded", {
class: "fatal",
code: "template_load_failed",
recoveryHint: `${name}@${version}`,
});
}
return template;
}
private async loadPersonas(): Promise<PersonaRecord[]> {
return this.db
.select({
id: agentPersonas.id,
name: agentPersonas.name,
version: agentPersonas.version,
hash: agentPersonas.hash,
definition: agentPersonas.definition,
})
.from(agentPersonas)
.orderBy(asc(agentPersonas.name), desc(agentPersonas.version));
}
private async loadRunContext(runId: string): Promise<StoredRunContext> {
const [row] = await this.db
.select({
templateDefinition: workflowTemplates.definition,
requirementsMd: runInputs.requirementsMd,
extra: runInputs.extra,
})
.from(runs)
.innerJoin(workflowTemplates, eq(runs.templateId, workflowTemplates.id))
.innerJoin(runInputs, eq(runInputs.runId, runs.id))
.where(eq(runs.id, runId))
.limit(1);
if (row === undefined) {
throw runNotFound(runId);
}
return {
template: Template.parse(row.templateDefinition),
input: {
requirementsMd: row.requirementsMd,
extra: row.extra,
},
};
}
private async nextPendingPhase(
runId: string,
): Promise<{ id: string; phaseKey: string } | undefined> {
const [phase] = await this.db
.select({ id: runPhases.id, phaseKey: runPhases.phaseKey })
.from(runPhases)
.where(and(eq(runPhases.runId, runId), eq(runPhases.state, "pending")))
.orderBy(asc(runPhases.seq))
.limit(1);
return phase;
}
private async activePhase(runId: string): Promise<{ id: string; phaseKey: string } | undefined> {
const [phase] = await this.db
.select({ id: runPhases.id, phaseKey: runPhases.phaseKey })
.from(runPhases)
.where(
and(
eq(runPhases.runId, runId),
inArray(runPhases.state, [
"running",
"awaiting_artifact",
"validating",
"awaiting_approval",
]),
),
)
.orderBy(asc(runPhases.seq))
.limit(1);
return phase;
}
private async bindingForPhase(
runId: string,
roleIds: readonly string[],
): Promise<{ roleId: string }> {
const bindings = await this.db
.select({ roleId: runBindings.roleId })
.from(runBindings)
.where(eq(runBindings.runId, runId))
.orderBy(asc(runBindings.roleId));
const binding = bindings.find((candidate) =>
roleIds.some(
(roleId) => candidate.roleId === roleId || candidate.roleId.startsWith(`${roleId}#`),
),
);
if (binding === undefined) {
throw new DevflowError("No run binding satisfies phase roles", {
class: "fatal",
code: "internal_state_corruption",
runId,
recoveryHint: roleIds.join(","),
});
}
return binding;
}
private async ensurePlannedPhaseRows(
runId: string,
worktreeRoot: string,
templatePhaseKeys: readonly string[],
): Promise<boolean> {
const plannedPhases = await this.loadPlannedPhaseDefinitions(runId, worktreeRoot);
if (plannedPhases.length === 0) {
return false;
}
assertPlannedPhaseKeys(runId, plannedPhases, templatePhaseKeys);
await this.assertPlannedPhaseBindings(runId, plannedPhases);
return this.db.transaction(async (tx) => {
await assertRunCanMutatePhaseInTransaction(tx, runId);
const existingPhases = await tx
.select({ phaseKey: runPhases.phaseKey, seq: runPhases.seq })
.from(runPhases)
.where(eq(runPhases.runId, runId));
const existingKeys = new Set(existingPhases.map((phase) => phase.phaseKey));
const missingPhases = plannedPhases.filter((phase) => !existingKeys.has(phase.key));
if (missingPhases.length === 0) {
return false;
}
const maxSeq = existingPhases.reduce((max, phase) => Math.max(max, phase.seq), 0);
await tx.insert(runPhases).values(
missingPhases.map((phase, index) => ({
runId,
phaseKey: phase.key,
seq: maxSeq + index + 1,
state: "pending",
})),
);
return true;
});
}
private async loadPlannedPhaseDefinitions(
runId: string,
_worktreeRoot: string,
): Promise<EnginePhaseDefinition[]> {
const [phasePlanArtifact] = await this.db
.select({ path: artifacts.path, hash: artifacts.hash, schemaId: artifacts.schemaId })
.from(artifacts)
.where(
and(
eq(artifacts.runId, runId),
eq(artifacts.schemaId, "dev/phase-plan@1"),
eq(artifacts.valid, true),
),
)
.orderBy(desc(artifacts.createdAt))
.limit(1);
if (phasePlanArtifact === undefined) {
return [];
}
const bytes = await readFile(phasePlanArtifact.path);
const currentHash = sha256Hex(bytes);
if (currentHash !== phasePlanArtifact.hash) {
throw new DevflowError("Phase plan artifact changed after validation", {
class: "fatal",
code: "internal_state_corruption",
runId,
recoveryHint: phasePlanArtifact.path,
});
}
const parsed = JSON.parse(bytes.toString("utf8")) as unknown;
const validation = validateArtifact(phasePlanArtifact.schemaId, parsed);
if (!validation.ok) {
throw new DevflowError("Stored phase plan artifact no longer validates", {
class: "fatal",
code: "internal_state_corruption",
runId,
recoveryHint: JSON.stringify(validation.errors),
});
}
return parsePhasePlanDefinitions(runId, parsed);
}
private async assertPlannedPhaseBindings(
runId: string,
plannedPhases: readonly EnginePhaseDefinition[],
): Promise<void> {
for (const phase of plannedPhases) {
await this.assertAllPhaseRolesBound(runId, phase.roles);
}
}
private async assertAllPhaseRolesBound(runId: string, roleIds: readonly string[]): Promise<void> {
const bindings = await this.db
.select({ roleId: runBindings.roleId })
.from(runBindings)
.where(eq(runBindings.runId, runId))
.orderBy(asc(runBindings.roleId));
const missingRoles = roleIds.filter(
(roleId) =>
!bindings.some(
(binding) => binding.roleId === roleId || binding.roleId.startsWith(`${roleId}#`),
),
);
if (missingRoles.length > 0) {
throw new DevflowError("Planned phase role is not bound", {
class: "fatal",
code: "internal_state_corruption",
runId,
recoveryHint: missingRoles.join(","),
});
}
}
private async skipPhase(runId: string, phaseId: string, phaseKey: string): Promise<void> {
const eventRepository = new RunEventRepository(this.db);
await this.db.transaction(async (tx) => {
await assertRunCanMutatePhaseInTransaction(tx, runId, phaseId);
const [phase] = await tx
.update(runPhases)
.set({
attempts: sql`${runPhases.attempts} + 1`,
state: "skipped",
endedAt: new Date(),
})
.where(
and(
eq(runPhases.id, phaseId),
eq(runPhases.runId, runId),
eq(runPhases.state, "pending"),
),
)
.returning({ attempts: runPhases.attempts });
if (phase === undefined) {
return;
}
const attempt = phase?.attempts ?? 1;
await eventRepository.appendInTransaction(tx, {
runId,
phaseId,
type: "phase.skipped",
payload: { phaseKey, attempt },
idempotencyKey: `phase.skipped:${phaseId}:${attempt}`,
});
});
}
private async markRunFailedIfActive(runId: string, reason: string): Promise<void> {
const eventRepository = new RunEventRepository(this.db);
let sessionsToDispose: string[] = [];
let markedFailed = false;
let reportStatus: "completed" | "failed" | "aborted" | undefined;
await this.db.transaction(async (tx) => {
const [run] = await lockRun(tx, runId);
if (run === undefined) {
return;
}
if (isTerminalRunState(run.state)) {
if (run.finalReportPath === null) {
reportStatus = run.state;
}
return;
}
markedFailed = true;
reportStatus = "failed";
await failActivePhasesInTransaction(tx, eventRepository, runId, reason);
await tx
.update(runs)
.set({
state: "failed",
currentPhaseId: null,
pausedFromState: null,
endedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(runs.id, runId));
await eventRepository.appendInTransaction(tx, {
runId,
type: "run.failed",
payload: { reason },
idempotencyKey: `run.failed:${runId}`,
});
sessionsToDispose = await markSessionsFailedInTransaction(tx, eventRepository, runId);
});
if (markedFailed) {
await this.disposeSessions(sessionsToDispose);
}
if (reportStatus !== undefined) {
await this.composeFinalReportBestEffort(runId, reportStatus);
}
}
private async composeFinalReportBestEffort(
runId: string,
status: "completed" | "failed" | "aborted",
): Promise<void> {
try {
await this.composeFinalReport(runId, status);
return;
} catch {
await this.writeStubFinalReport(runId, status).catch(() => undefined);
}
}
private async writeStubFinalReport(
runId: string,
status: "completed" | "failed" | "aborted",
): Promise<void> {
const [run] = await this.db
.select({ templateHash: runs.templateHash, endedAt: runs.endedAt })
.from(runs)
.where(eq(runs.id, runId))
.limit(1);
const endedAt = (run?.endedAt ?? new Date()).toISOString();
const report = {
runId,
templateHash: run?.templateHash ?? "0".repeat(64),
bindings: [],
inputs: {},
phases: [],
approvals: [],
findings: [],
commands: [],
artifacts: [],
events: { tail: [] },
unresolved: ["final_report_compose_failed"],
endedAt,
status,
};
const reportRoot = join(this.workspaceRoot, runId);
await mkdir(reportRoot, { recursive: true });
const jsonPath = join(reportRoot, `${runId}.report.json`);
const markdownPath = join(reportRoot, `${runId}.report.md`);
await atomicWriteFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
await atomicWriteFile(markdownPath, renderMarkdownReport(report));
await this.db
.update(runs)
.set({
finalReportPath: markdownPath,
endedAt: new Date(endedAt),
updatedAt: new Date(),
})
.where(eq(runs.id, runId));
}
private async shouldPreserveHumanGateRun(runId: string, error: unknown): Promise<boolean> {
if (!(error instanceof DevflowError) || error.class !== "human_required") {
return false;
}
const [run] = await this.db
.select({ state: runs.state })
.from(runs)
.where(eq(runs.id, runId))
.limit(1);
return run?.state === "paused" || run?.state === "awaiting_approval";
}
async recoverMissingFinalReports(
options: { runIds?: readonly string[] } = {},
): Promise<string[]> {
const conditions = [
inArray(runs.state, ["completed", "failed", "aborted"]),
sql`${runs.finalReportPath} IS NULL`,
];
if (options.runIds !== undefined) {
if (options.runIds.length === 0) {
return [];
}
conditions.push(inArray(runs.id, [...options.runIds]));
}
const terminalRuns = await this.db
.select({ id: runs.id, state: runs.state })
.from(runs)
.where(and(...conditions));
const recoveredRunIds: string[] = [];
for (const run of terminalRuns) {
await this.composeFinalReportBestEffort(
run.id,
run.state as "completed" | "failed" | "aborted",
);
const [updated] = await this.db
.select({ finalReportPath: runs.finalReportPath })
.from(runs)
.where(eq(runs.id, run.id))
.limit(1);
if (updated?.finalReportPath !== null && updated?.finalReportPath !== undefined) {
recoveredRunIds.push(run.id);
}
}
return recoveredRunIds;
}
private async resolveWorktreeRoot(
runId: string,
requestedWorktreeRoot?: string,
): Promise<string> {
const runRoot = join(this.workspaceRoot, runId);
const worktreeRoot = requestedWorktreeRoot ?? join(runRoot, "main");
if (!isPathInsideOrEqual(resolve(worktreeRoot), resolve(runRoot))) {
throw new DevflowError("Worktree root must live under the run workspace root", {
class: "fatal",
code: "workspace_permissions",
recoveryHint: worktreeRoot,
});
}
await mkdir(runRoot, { recursive: true });
const canonicalRunRoot = realpathSync(runRoot);
const resolvedWorktreeRoot = resolve(worktreeRoot);
await mkdir(dirname(resolvedWorktreeRoot), { recursive: true });
if (!isPathInsideOrEqual(resolvedWorktreeRoot, canonicalRunRoot)) {
throw new DevflowError("Resolved worktree root escaped the run workspace root", {
class: "fatal",
code: "workspace_permissions",
recoveryHint: resolvedWorktreeRoot,
});
}
return resolvedWorktreeRoot;
}
private async createGitWorktree(
repoPath: string,
baseBranch: string,
runId: string,
worktreeRoot: string,
): Promise<string> {
const branchName = `devflow/${runId}/main`;
try {
await execFileAsync(
"git",
["-C", repoPath, "worktree", "add", "-b", branchName, worktreeRoot, baseBranch],
{ env: gitChildEnv(), maxBuffer: 1024 * 1024 },
);
return realpathSync(worktreeRoot);
} catch (cause) {
throw new DevflowError("Failed to create git worktree", {
class: "human_required",
code: "workspace_permissions",
runId,
cause,
recoveryHint: `git worktree add -b ${branchName} ${worktreeRoot} ${baseBranch}`,
});
}
}
private async disposeSessions(sessionIds: readonly string[]): Promise<void> {
await Promise.all(
sessionIds.map((sessionId) => this.sessions.dispose({ sessionId }).catch(() => undefined)),
);
}
}
export interface M4ProcessRestartSweepOptions {
runIds?: readonly string[];
}
export async function sweepM4ProcessRestart(
db: Database,
options: M4ProcessRestartSweepOptions = {},
): Promise<{ sweptRunIds: string[]; failedSessionIds: string[] }> {
if (options.runIds !== undefined && options.runIds.length === 0) {
return { sweptRunIds: [], failedSessionIds: [] };
}
const eventRepository = new RunEventRepository(db);
const sweptRunIds: string[] = [];
const failedSessionIds: string[] = [];
const activeRunFilter =
options.runIds === undefined
? sql`${runs.state} NOT IN ('completed', 'failed', 'aborted')`
: and(
inArray(runs.id, [...options.runIds]),
sql`${runs.state} NOT IN ('completed', 'failed', 'aborted')`,
);
await db.transaction(async (tx) => {
const activeRuns = await tx
.select({ id: runs.id })
.from(runs)
.where(activeRunFilter)
.orderBy(asc(runs.createdAt));
for (const activeRun of activeRuns) {
const [run] = await lockRun(tx, activeRun.id);
if (run === undefined || isTerminalRunState(run.state)) {
continue;
}
await failActivePhasesInTransaction(
tx,
eventRepository,
activeRun.id,
"process_restart_unrecovered",
);
await tx
.update(runs)
.set({
state: "failed",
currentPhaseId: null,
pausedFromState: null,
finalReportPath: null,
endedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(runs.id, activeRun.id));
await eventRepository.appendInTransaction(tx, {
runId: activeRun.id,
type: "run.failed",
payload: { reason: "process_restart_unrecovered" },
idempotencyKey: `run.failed:${activeRun.id}`,
});
await abortPendingApprovalsInTransaction(tx, activeRun.id);
failedSessionIds.push(
...(await markSessionsFailedInTransaction(tx, eventRepository, activeRun.id)),
);
sweptRunIds.push(activeRun.id);
}
});
return { sweptRunIds, failedSessionIds };
}
type TemplateCompatiblePersona = Persona;
async function activeRunForRepoBase(
db: Database | TransactionDb,
repoPath: string,
baseBranch: string,
) {
return db
.select({ id: runs.id, state: runs.state })
.from(runs)
.where(
and(
eq(runs.repoPath, repoPath),
eq(runs.baseBranch, baseBranch),
sql`${runs.state} NOT IN ('completed', 'failed', 'aborted')`,
),
)
.orderBy(asc(runs.createdAt))
.limit(1);
}
function activeRunExists(currentRunId: string, currentState: string): DevflowError {
return new DevflowError("An active run already exists for this repo and base branch", {
class: "human_required",
code: "active_run_exists",
recoveryHint: JSON.stringify({ currentRunId, currentState }),
});
}
function isPgConstraintViolation(error: unknown, constraint: string): boolean {
return (
typeof error === "object" &&
error !== null &&
"constraint" in error &&
(error as { constraint?: unknown }).constraint === constraint
);
}
async function lockRun(tx: TransactionDb, runId: string) {
await tx.execute(sql`SELECT 1 FROM ${runs} WHERE ${runs.id} = ${runId} FOR UPDATE`);
return tx
.select({
state: runs.state,
pausedFromState: runs.pausedFromState,
finalReportPath: runs.finalReportPath,
})
.from(runs)
.where(eq(runs.id, runId))
.limit(1);
}
async function assertRunCanMutatePhaseInTransaction(
tx: TransactionDb,
runId: string,
phaseId?: string,
) {
const [run] = await lockRun(tx, runId);
if (run === undefined || !isPhaseMutationRunState(run.state)) {
throw runStateChanged(runId, phaseId, run?.state ?? "missing");
}
}
function isPhaseMutationRunState(state: string): boolean {
return phaseMutationRunStates.includes(state as (typeof phaseMutationRunStates)[number]);
}
function runStateChanged(runId: string, phaseId: string | undefined, state: string): DevflowError {
return new DevflowError("Run left active state before engine mutation", {
class: "human_required",
code: "run_state_changed",
runId,
...(phaseId === undefined ? {} : { phaseId }),
recoveryHint: `run_state=${state}`,
});
}
async function hasPendingHumanRequiredGate(tx: TransactionDb, runId: string): Promise<boolean> {
const pendingGates = await tx
.select({ phaseId: approvalRequests.phaseId, phaseState: runPhases.state })
.from(approvalRequests)
.leftJoin(runPhases, eq(approvalRequests.phaseId, runPhases.id))
.where(and(eq(approvalRequests.runId, runId), eq(approvalRequests.state, "pending")));
return pendingGates.some((gate) => gate.phaseId === null || gate.phaseState === "failed");
}
async function isHumanRequiredApprovalPhase(
tx: TransactionDb,
runId: string,
phaseId: string,
): Promise<boolean> {
const [phase] = await tx
.select({ state: runPhases.state })
.from(runPhases)
.where(and(eq(runPhases.id, phaseId), eq(runPhases.runId, runId)))
.limit(1);
return phase?.state === "failed";
}
async function abortPendingApprovalsInTransaction(tx: TransactionDb, runId: string) {
const pendingApprovals = await tx
.select({
id: approvalRequests.id,
})
.from(approvalRequests)
.where(eq(approvalRequests.runId, runId));
for (const approval of pendingApprovals.filter((request) => request.id !== undefined)) {
await tx
.update(approvalRequests)
.set({ state: "aborted", resolvedAt: new Date() })
.where(and(eq(approvalRequests.id, approval.id), eq(approvalRequests.state, "pending")));
}
}
async function failActivePhasesInTransaction(
tx: TransactionDb,
eventRepository: RunEventRepository,
runId: string,
reason: string,
) {
const activePhases = await tx
.select({
id: runPhases.id,
phaseKey: runPhases.phaseKey,
attempts: runPhases.attempts,
})
.from(runPhases)
.where(
and(
eq(runPhases.runId, runId),
inArray(runPhases.state, [
"running",
"awaiting_artifact",
"validating",
"awaiting_approval",
]),
),
);
for (const phase of activePhases) {
const attempt = Math.max(phase.attempts, 1);
const [updated] = await tx
.update(runPhases)
.set({ state: "failed", endedAt: new Date() })
.where(
and(
eq(runPhases.id, phase.id),
inArray(runPhases.state, [
"running",
"awaiting_artifact",
"validating",
"awaiting_approval",
]),
),
)
.returning({ id: runPhases.id });
if (updated === undefined) {
continue;
}
await eventRepository.appendInTransaction(tx, {
runId,
phaseId: phase.id,
type: "phase.failed",
payload: { phaseKey: phase.phaseKey, attempt, reason },
idempotencyKey: `phase.failed:${phase.id}:${attempt}`,
});
}
}
async function markSessionsFailedInTransaction(
tx: TransactionDb,
eventRepository: RunEventRepository,
runId: string,
): Promise<string[]> {
const sessions = await tx
.select({ id: tuiSessions.id, roleId: tuiSessions.roleId })
.from(tuiSessions)
.where(eq(tuiSessions.runId, runId));
const activeSessions = sessions.filter((session) => session.id !== undefined);
if (activeSessions.length === 0) {
return [];
}
await tx
.update(tuiSessions)
.set({ state: "FAILED_NEEDS_HUMAN" })
.where(eq(tuiSessions.runId, runId));
for (const session of activeSessions) {
await eventRepository.appendInTransaction(tx, {
runId,
type: "session.failed",
payload: { sessionId: session.id, roleId: session.roleId },
idempotencyKey: `session.failed:${session.id}`,
});
}
return activeSessions.map((session) => session.id);
}
async function completeApprovedPhase(
tx: TransactionDb,
eventRepository: RunEventRepository,
runId: string,
phaseId: string,
) {
const [phase] = await tx
.select({ phaseKey: runPhases.phaseKey, attempts: runPhases.attempts })
.from(runPhases)
.where(and(eq(runPhases.id, phaseId), eq(runPhases.runId, runId)))
.limit(1);
if (phase === undefined) {
throw new DevflowError("Approval phase does not exist", {
class: "fatal",
code: "internal_state_corruption",
runId,
phaseId,
});
}
await tx
.update(runPhases)
.set({ state: "completed", endedAt: new Date() })
.where(eq(runPhases.id, phaseId));
await releaseWaitingApprovalSessions(tx, eventRepository, runId, phaseId);
await eventRepository.appendInTransaction(tx, {
runId,
phaseId,
type: "phase.completed",
payload: { phaseKey: phase.phaseKey, attempt: phase.attempts },
idempotencyKey: `phase.completed:${phaseId}:${phase.attempts}`,
});
}
async function resetPhaseForChanges(
tx: TransactionDb,
eventRepository: RunEventRepository,
runId: string,
phaseId: string,
) {
const [phase] = await tx
.select({ id: runPhases.id })
.from(runPhases)
.where(and(eq(runPhases.id, phaseId), eq(runPhases.runId, runId)))
.limit(1);
if (phase === undefined) {
throw new DevflowError("Approval phase does not exist", {
class: "fatal",
code: "internal_state_corruption",
runId,
phaseId,
});
}
await tx
.update(runPhases)
.set({ state: "pending", startedAt: null, endedAt: null })
.where(eq(runPhases.id, phaseId));
await releaseWaitingApprovalSessions(tx, eventRepository, runId, phaseId);
}
async function releaseWaitingApprovalSessions(
tx: TransactionDb,
eventRepository: RunEventRepository,
runId: string,
phaseId: string,
) {
const waitingSessions = await tx
.select({
id: tuiSessions.id,
lastPromptHash: tuiSessions.lastPromptHash,
roleId: tuiSessions.roleId,
})
.from(tuiSessions)
.where(and(eq(tuiSessions.runId, runId), eq(tuiSessions.state, "WAITING_FOR_APPROVAL")));
for (const session of waitingSessions) {
if (session.lastPromptHash === null) {
throw new DevflowError("Approval-waiting session is missing prompt hash", {
class: "fatal",
code: "internal_state_corruption",
runId,
phaseId,
recoveryHint: `session_id=${session.id}`,
});
}
await eventRepository.appendInTransaction(tx, {
runId,
phaseId,
type: "session.idle",
payload: {
sessionId: session.id,
roleId: session.roleId,
dedupKey: session.lastPromptHash,
},
idempotencyKey: `session.idle:${session.id}:${session.lastPromptHash}`,
});
}
await tx
.update(tuiSessions)
.set({ state: "READY" })
.where(and(eq(tuiSessions.runId, runId), eq(tuiSessions.state, "WAITING_FOR_APPROVAL")));
}
async function failApprovalPhase(
tx: TransactionDb,
eventRepository: RunEventRepository,
runId: string,
phaseId: string,
action: "reject" | "abort",
) {
const [phase] = await tx
.select({ phaseKey: runPhases.phaseKey, attempts: runPhases.attempts, state: runPhases.state })
.from(runPhases)
.where(and(eq(runPhases.id, phaseId), eq(runPhases.runId, runId)))
.limit(1);
if (phase === undefined) {
return;
}
if (phase.state === "failed") {
return;
}
await tx
.update(runPhases)
.set({ state: "failed", endedAt: new Date() })
.where(eq(runPhases.id, phaseId));
await eventRepository.appendInTransaction(tx, {
runId,
phaseId,
type: "phase.failed",
payload: { phaseKey: phase.phaseKey, attempt: phase.attempts, reason: `approval_${action}` },
idempotencyKey: `phase.failed:${phaseId}:${phase.attempts}`,
});
}
async function existingDecisionForToken(
tx: TransactionDb,
approvalRequestId: string,
clientToken: string,
): Promise<{ action: string } | undefined> {
const decisions = await tx
.select({
action: approvalDecisions.action,
idempotencyKey: approvalDecisions.idempotencyKey,
})
.from(approvalDecisions)
.where(eq(approvalDecisions.approvalRequestId, approvalRequestId));
return decisions.find((decision) => decision.idempotencyKey.endsWith(`:${clientToken}`));
}
function approvalStateForAction(action: ApprovalDecisionActionValue) {
switch (action) {
case "approve":
return "approved";
case "reject":
return "rejected";
case "request_changes":
return "changes_requested";
case "abort":
return "aborted";
}
}
function toEnginePhaseDefinition(phase: Template["phases"][number]): EnginePhaseDefinition {
const definition: EnginePhaseDefinition = {
key: phase.key,
title: phase.title,
roles: [...phase.roles],
gates: [...phase.gates],
};
if (phase.expectedArtifact !== undefined) {
definition.expectedArtifact = {
path: phase.expectedArtifact.path,
schema: phase.expectedArtifact.schema,
};
}
if (phase.timeoutMs !== undefined) {
definition.timeoutMs = phase.timeoutMs;
}
return definition;
}
function parsePhasePlanDefinitions(runId: string, value: unknown): EnginePhaseDefinition[] {
if (
value === null ||
typeof value !== "object" ||
!Array.isArray((value as { phases?: unknown }).phases)
) {
throw new DevflowError("Phase plan artifact is missing phases", {
class: "fatal",
code: "internal_state_corruption",
runId,
});
}
return (value as { phases: unknown[] }).phases.map((phase, index) =>
parsePhasePlanDefinition(runId, phase, index),
);
}
function assertPlannedPhaseKeys(
runId: string,
plannedPhases: readonly EnginePhaseDefinition[],
templatePhaseKeys: readonly string[],
) {
const seen = new Set<string>();
const templateKeys = new Set(templatePhaseKeys);
for (const phase of plannedPhases) {
if (seen.has(phase.key)) {
throw new DevflowError("Phase plan contains duplicate phase keys", {
class: "fatal",
code: "internal_state_corruption",
runId,
recoveryHint: phase.key,
});
}
seen.add(phase.key);
if (templateKeys.has(phase.key)) {
throw new DevflowError("Phase plan phase key collides with template phase key", {
class: "fatal",
code: "internal_state_corruption",
runId,
recoveryHint: phase.key,
});
}
}
}
function parsePhasePlanDefinition(
runId: string,
phase: unknown,
index: number,
): EnginePhaseDefinition {
if (phase === null || typeof phase !== "object") {
throw invalidPhasePlan(runId, index);
}
const record = phase as Record<string, unknown>;
if (
typeof record.key !== "string" ||
typeof record.title !== "string" ||
!Array.isArray(record.roles)
) {
throw invalidPhasePlan(runId, index);
}
const roles = record.roles.filter((role): role is string => typeof role === "string");
if (roles.length !== record.roles.length || roles.length === 0) {
throw invalidPhasePlan(runId, index);
}
const gates =
Array.isArray(record.gates) && record.gates.every((gate) => typeof gate === "string")
? record.gates
: [];
const definition: EnginePhaseDefinition = {
key: record.key,
title: record.title,
roles,
gates,
};
if (record.expectedArtifact !== undefined) {
if (record.expectedArtifact === null || typeof record.expectedArtifact !== "object") {
throw invalidPhasePlan(runId, index);
}
const expectedArtifact = record.expectedArtifact as Record<string, unknown>;
if (typeof expectedArtifact.path !== "string" || typeof expectedArtifact.schema !== "string") {
throw invalidPhasePlan(runId, index);
}
definition.expectedArtifact = {
path: expectedArtifact.path,
schema: expectedArtifact.schema,
};
}
if (typeof record.timeoutMs === "number" && Number.isInteger(record.timeoutMs)) {
definition.timeoutMs = record.timeoutMs;
}
return definition;
}
function invalidPhasePlan(runId: string, index: number): DevflowError {
return new DevflowError("Phase plan artifact contains an invalid phase", {
class: "fatal",
code: "internal_state_corruption",
runId,
recoveryHint: `phase_index=${index}`,
});
}
function storeEngineMetadata(
extra: Record<string, unknown> | undefined,
scenarios: Record<string, FakePhaseScenario> | undefined,
): Record<string, unknown> {
return {
...(extra ?? {}),
devflowM4: {
scenarios: scenarios ?? {},
},
};
}
function scenarioForPhase(extra: unknown, phaseKey: string): Required<FakePhaseScenarioObject> {
const scenario = readScenario(extra, phaseKey);
if (typeof scenario === "string") {
return { scenario, repairScenario: "ok" };
}
return {
scenario: scenario?.scenario ?? "ok",
repairScenario: scenario?.repairScenario ?? "ok",
};
}
interface FakePhaseScenarioObject {
scenario?: string;
repairScenario?: string;
}
function readScenario(extra: unknown, phaseKey: string): FakePhaseScenario | undefined {
if (extra === null || typeof extra !== "object" || !("devflowM4" in extra)) {
return undefined;
}
const metadata = (extra as { devflowM4?: unknown }).devflowM4;
if (metadata === null || typeof metadata !== "object" || !("scenarios" in metadata)) {
return undefined;
}
const scenarios = (metadata as { scenarios?: unknown }).scenarios;
if (scenarios === null || typeof scenarios !== "object" || !(phaseKey in scenarios)) {
return undefined;
}
const value = (scenarios as Record<string, unknown>)[phaseKey];
if (typeof value === "string") {
return value;
}
if (value !== null && typeof value === "object") {
const candidate = value as Record<string, unknown>;
const scenario = typeof candidate.scenario === "string" ? candidate.scenario : undefined;
const repairScenario =
typeof candidate.repairScenario === "string" ? candidate.repairScenario : undefined;
return {
...(scenario === undefined ? {} : { scenario }),
...(repairScenario === undefined ? {} : { repairScenario }),
};
}
return undefined;
}
function buildPhaseInstructions(
phaseKey: string,
title: string,
requirementsMd: string,
scenario: Required<FakePhaseScenarioObject>,
): string {
return [
`Scenario: ${scenario.scenario}`,
`Repair-Scenario: ${scenario.repairScenario}`,
`Phase: ${phaseKey}`,
`Title: ${title}`,
"Requirements:",
requirementsMd,
].join("\n");
}
function canonicalExistingPath(path: string): string {
return realpathSync(resolve(path));
}
function gitChildEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env };
for (const key of gitLocalEnvKeys) {
delete env[key];
}
return env;
}
const gitLocalEnvKeys = [
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_CONFIG",
"GIT_CONFIG_PARAMETERS",
"GIT_CONFIG_COUNT",
"GIT_OBJECT_DIRECTORY",
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_IMPLICIT_WORK_TREE",
"GIT_GRAFT_FILE",
"GIT_INDEX_FILE",
"GIT_NO_REPLACE_OBJECTS",
"GIT_REPLACE_REF_BASE",
"GIT_PREFIX",
"GIT_SHALLOW_FILE",
"GIT_COMMON_DIR",
] as const;
async function atomicWriteFile(path: string, content: string): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tempPath, content, "utf8");
await rename(tempPath, path);
}
function sha256Hex(bytes: Buffer): string {
return createHash("sha256").update(bytes).digest("hex");
}
function renderMarkdownReport(report: Record<string, unknown>): string {
const runId = typeof report.runId === "string" ? report.runId : "unknown";
const status = typeof report.status === "string" ? report.status : "unknown";
const endedAt = typeof report.endedAt === "string" ? report.endedAt : "unknown";
return [`# Devflow Run ${runId}`, "", `Status: ${status}`, `Ended: ${endedAt}`, ""].join("\n");
}
function serializeJson(value: unknown): unknown {
if (typeof value === "bigint") {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return value.map((item) => serializeJson(item));
}
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, child]) => [
key,
serializeJson(child),
]),
);
}
return value;
}
function isTerminalRunState(state: string): state is (typeof terminalRunStates)[number] {
return terminalRunStates.some((terminalState) => terminalState === state);
}
function isPathInsideOrEqual(path: string, parent: string): boolean {
const relativePath = relative(parent, path);
return relativePath === "" || (!relativePath.startsWith("..") && relativePath !== "..");
}
function approvalConflict(runId: string, reason: string): DevflowError {
return new DevflowError("Approval decision conflicts with the current request state", {
class: "human_required",
code: "approval_conflict",
runId,
recoveryHint: reason,
});
}
function runNotFound(runId: string): DevflowError {
return new DevflowError("Run does not exist", {
class: "human_required",
code: "run_not_found",
runId,
});
}