2337 lines
70 KiB
TypeScript
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,
|
|
});
|
|
}
|