feat: add temporal run engine integration

This commit is contained in:
chungyeong
2026-05-13 08:39:19 +09:00
parent 78ebd5ef78
commit aa3033771a
37 changed files with 7338 additions and 224 deletions

View File

@@ -0,0 +1,27 @@
{
"name": "@devflow/workflows",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --clean && tsc -p tsconfig.build.json",
"typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit",
"test": "cd ../.. && vitest run --project packages/workflows"
},
"dependencies": {
"@devflow/core": "workspace:*",
"@devflow/db": "workspace:*",
"@devflow/run-engine": "workspace:*",
"@devflow/session": "workspace:*",
"@temporalio/activity": "^1.17.1",
"@temporalio/client": "^1.17.1",
"@temporalio/worker": "^1.17.1",
"@temporalio/workflow": "^1.17.1"
},
"devDependencies": {
"@temporalio/testing": "^1.17.1"
}
}

View File

@@ -0,0 +1,310 @@
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { loadPersonaFiles, loadTemplateFiles } from "@devflow/core";
import {
type DbClient,
agentPersonas,
approvalDecisions,
approvalRequests,
createDbClient,
runs,
workflowTemplates,
} from "@devflow/db";
import { FakeSessionAdapter, SessionManager } from "@devflow/session";
import { ApplicationFailure } from "@temporalio/activity";
import { eq, inArray } from "drizzle-orm";
import { afterEach, describe, expect, it } from "vitest";
import { createDevflowActivities } from "./activities.js";
const databaseUrl =
process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow";
describe("createDevflowActivities", () => {
let client: DbClient | undefined;
const runIds: string[] = [];
const tempRoots: string[] = [];
afterEach(async () => {
if (client !== undefined) {
if (runIds.length > 0) {
const requests = await client.db
.select({ id: approvalRequests.id })
.from(approvalRequests)
.where(inArray(approvalRequests.runId, [...runIds]));
if (requests.length > 0) {
await client.db.delete(approvalDecisions).where(
inArray(
approvalDecisions.approvalRequestId,
requests.map((request) => request.id),
),
);
}
await client.db
.delete(approvalRequests)
.where(inArray(approvalRequests.runId, [...runIds]));
await client.db.delete(runs).where(inArray(runs.id, [...runIds]));
}
await client.close();
client = undefined;
}
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
runIds.length = 0;
});
it("preserves M4 fake development run behavior through worker activities", async () => {
client = createDbClient(databaseUrl);
await seedDevelopmentRegistry(client.db);
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-workflows-workspace-")));
const repoPath = createGitRepo();
tempRoots.push(workspaceRoot, repoPath);
const activities = createDevflowActivities({
db: client.db,
sessions: new SessionManager({
db: client.db,
adapter: new FakeSessionAdapter({ writeDelayMs: 0 }),
}),
workspaceRoot,
maxConcurrentRuns: 100,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
});
const input = {
requirementsMd: "Run through the M5 worker activity surface.",
repoPath,
baseBranch: "main",
scenarios: {
spec: "ok",
phase_plan: "ok",
},
};
const { runId } = await activities.prepareRunActivity(input);
runIds.push(runId);
await activities.lockBindingsActivity({ ...input, runId });
await activities.advanceRunActivity({ runId });
let status = await activities.getStatusActivity(runId);
expect(status.run.state).toBe("awaiting_approval");
expect(status.approvals).toMatchObject([{ gateKey: "spec_approved", state: "pending" }]);
await activities.signalApprovalActivity({
runId,
approvalRequestId: pendingApprovalId(status, "spec_approved"),
action: "approve",
clientToken: randomUUID(),
});
await activities.advanceRunActivity({ runId });
status = await activities.getStatusActivity(runId);
await activities.signalApprovalActivity({
runId,
approvalRequestId: pendingApprovalId(status, "phase_plan_approved"),
action: "approve",
clientToken: randomUUID(),
});
await activities.advanceRunActivity({ runId });
status = await activities.getStatusActivity(runId);
expect(status.run.state).toBe("completed");
expect(status.run.finalReportPath).toMatch(/\.report\.md$/);
expect(existsSync(status.run.finalReportPath ?? "")).toBe(true);
});
it("prepares a run idempotently when Temporal replays the same activity", async () => {
client = createDbClient(databaseUrl);
await seedDevelopmentRegistry(client.db);
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-workflows-workspace-")));
const repoPath = createGitRepo();
tempRoots.push(workspaceRoot, repoPath);
const activities = createDevflowActivities({
db: client.db,
sessions: new SessionManager({
db: client.db,
adapter: new FakeSessionAdapter({ writeDelayMs: 0 }),
}),
workspaceRoot,
maxConcurrentRuns: 100,
});
const runId = randomUUID();
const input = {
runId,
requirementsMd: "Replay-safe prepare should return the same run.",
repoPath,
baseBranch: "main",
};
await expect(activities.prepareRunActivity(input)).resolves.toEqual({ runId });
await expect(activities.prepareRunActivity(input)).resolves.toEqual({ runId });
const rows = await client.db.select({ id: runs.id }).from(runs).where(eq(runs.id, runId));
expect(rows).toEqual([{ id: runId }]);
runIds.push(runId);
});
it("rejects a prepare replay with the same run id but different inputs", async () => {
client = createDbClient(databaseUrl);
await seedDevelopmentRegistry(client.db);
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-workflows-workspace-")));
const repoPath = createGitRepo();
tempRoots.push(workspaceRoot, repoPath);
const activities = createDevflowActivities({
db: client.db,
sessions: new SessionManager({
db: client.db,
adapter: new FakeSessionAdapter({ writeDelayMs: 0 }),
}),
workspaceRoot,
maxConcurrentRuns: 100,
});
const runId = randomUUID();
const input = {
runId,
requirementsMd: "Original run requirements.",
repoPath,
baseBranch: "main",
scenarios: { spec: "ok" },
};
await expect(activities.prepareRunActivity(input)).resolves.toEqual({ runId });
await expectDevflowActivityFailure(
activities.prepareRunActivity({
...input,
requirementsMd: "Changed requirements must not be accepted as replay.",
}),
"internal_state_corruption",
);
await expectDevflowActivityFailure(
activities.prepareRunActivity({
...input,
scenarios: { spec: "timeout" },
}),
"internal_state_corruption",
);
runIds.push(runId);
});
it("can fail an active prepared run when lock binding cannot complete", async () => {
client = createDbClient(databaseUrl);
await seedDevelopmentRegistry(client.db);
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-workflows-workspace-")));
const repoPath = createGitRepo();
tempRoots.push(workspaceRoot, repoPath);
const activities = createDevflowActivities({
db: client.db,
sessions: new SessionManager({
db: client.db,
adapter: new FakeSessionAdapter({ writeDelayMs: 0 }),
}),
workspaceRoot,
maxConcurrentRuns: 100,
});
const input = {
requirementsMd: "Binding should fail when no backend is enabled.",
repoPath,
baseBranch: "main",
overrides: { roles: { spec_writer: { persona: "missing-persona" } } },
};
const { runId } = await activities.prepareRunActivity(input);
runIds.push(runId);
await expectDevflowActivityFailure(
activities.lockBindingsActivity({ ...input, runId }),
"no_eligible_persona",
);
await activities.failRunActivity({ runId, reason: "lock_bindings_failed" });
const [run] = await client.db
.select({ state: runs.state })
.from(runs)
.where(eq(runs.id, runId));
expect(run).toEqual({ state: "failed" });
});
});
function pendingApprovalId(
status: Awaited<ReturnType<ReturnType<typeof createDevflowActivities>["getStatusActivity"]>>,
gateKey: string,
) {
const approval = status.approvals.find(
(candidate) => candidate.gateKey === gateKey && candidate.state === "pending",
);
expect(approval).toBeDefined();
if (approval === undefined) {
throw new Error(`${gateKey} approval missing`);
}
return approval.id;
}
function createGitRepo(): string {
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-workflows-repo-")));
execFileSync("git", ["init", "-b", "main"], { cwd: repoPath, stdio: "ignore" });
writeFileSync(join(repoPath, "README.md"), "# Workflows fixture\n");
execFileSync("git", ["add", "README.md"], { cwd: repoPath, stdio: "ignore" });
execFileSync(
"git",
[
"-c",
"user.name=Devflow Test",
"-c",
"user.email=devflow@example.test",
"commit",
"-m",
"initial",
],
{ cwd: repoPath, stdio: "ignore" },
);
return repoPath;
}
async function expectDevflowActivityFailure(operation: Promise<unknown>, code: string) {
try {
await operation;
} catch (error) {
expect(error).toBeInstanceOf(ApplicationFailure);
const failure = error as ApplicationFailure;
expect(failure.type).toBe("DevflowError");
expect(failure.nonRetryable).toBe(true);
expect(failure.details?.[0]).toMatchObject({ code });
return;
}
throw new Error(`Expected Devflow activity failure ${code}`);
}
async function seedDevelopmentRegistry(db: DbClient["db"]) {
const [templateEntry] = loadTemplateFiles(resolve("docs/schemas/templates")).filter(
(entry) => entry.name === "development" && entry.version === 1,
);
if (templateEntry === undefined) {
throw new Error("development@1 template fixture is missing");
}
await db
.insert(workflowTemplates)
.values({
name: templateEntry.name,
version: templateEntry.version,
hash: templateEntry.hash,
definition: templateEntry.definition,
})
.onConflictDoUpdate({
target: [workflowTemplates.name, workflowTemplates.version],
set: { hash: templateEntry.hash, definition: templateEntry.definition },
});
for (const personaEntry of loadPersonaFiles(resolve("docs/schemas/personas"))) {
await db
.insert(agentPersonas)
.values({
name: personaEntry.name,
version: personaEntry.version,
hash: personaEntry.hash,
definition: personaEntry.definition,
})
.onConflictDoNothing({ target: [agentPersonas.name, agentPersonas.version] });
}
}

View File

@@ -0,0 +1,166 @@
import { type BackendConfig, DevflowError } from "@devflow/core";
import type { DbClient } from "@devflow/db";
import { DbRunEngine, type RunStartInput, type RunStatus } from "@devflow/run-engine";
import type { SessionRuntime } from "@devflow/session";
import { ApplicationFailure, CancelledFailure, Context } from "@temporalio/activity";
import type { AbortSignalPayload, ApprovalSignalPayload, RunSignalPayload } from "./types.js";
type Database = DbClient["db"];
export interface DevflowActivityDependencies {
db: Database;
sessions: SessionRuntime;
workspaceRoot: string;
availableBackends?: readonly BackendConfig[];
maxConcurrentRuns?: number;
wait?: {
timeoutMs?: number;
pollIntervalMs?: number;
stableMs?: number;
};
}
export interface DevflowActivities {
prepareRunActivity(input: RunStartInput): Promise<{ runId: string }>;
lockBindingsActivity(input: RunStartInput): Promise<void>;
failRunActivity(input: { runId: string; reason: string }): Promise<void>;
advanceRunActivity(input: { runId: string; resumeActivePhase?: boolean }): Promise<RunStatus>;
signalApprovalActivity(payload: ApprovalSignalPayload): Promise<void>;
pauseRunActivity(payload: RunSignalPayload): Promise<void>;
resumeRunActivity(payload: RunSignalPayload): Promise<void>;
abortRunActivity(payload: AbortSignalPayload): Promise<void>;
getStatusActivity(runId: string): Promise<RunStatus>;
isRunTerminalActivity(runId: string): Promise<boolean>;
composeFinalReportActivity(runId: string): Promise<void>;
}
export function createDevflowActivities(
dependencies: DevflowActivityDependencies,
): DevflowActivities {
const makeEngine = () => {
const activityWait = withTemporalActivityCancellation(dependencies.wait);
return new DbRunEngine({
db: dependencies.db,
sessions: dependencies.sessions,
workspaceRoot: dependencies.workspaceRoot,
...(dependencies.availableBackends === undefined
? {}
: { availableBackends: dependencies.availableBackends }),
...(dependencies.maxConcurrentRuns === undefined
? {}
: { maxConcurrentRuns: dependencies.maxConcurrentRuns }),
...(activityWait === undefined ? {} : { wait: activityWait }),
});
};
return {
prepareRunActivity(input) {
return runActivity(makeEngine().prepareRun(input));
},
lockBindingsActivity(input) {
return runActivity(makeEngine().lockBindingsForRun(input));
},
failRunActivity(input) {
return runActivity(makeEngine().failRunIfActive(input.runId, input.reason));
},
advanceRunActivity(input) {
return runActivity(
makeEngine().advanceRunUntilBlocked(input.runId, {
...(input.resumeActivePhase === undefined
? {}
: { resumeActivePhase: input.resumeActivePhase }),
failureReason: "temporal_advance_failed",
}),
);
},
signalApprovalActivity(payload) {
return runActivity(
makeEngine().signalApprovalForWorkflow(
payload.runId,
payload.approvalRequestId,
payload.action,
payload.clientToken,
payload.comment,
),
);
},
pauseRunActivity(payload) {
return runActivity(makeEngine().pauseRun(payload.runId));
},
resumeRunActivity(payload) {
return runActivity(makeEngine().resumeRunForWorkflow(payload.runId));
},
abortRunActivity(payload) {
return runActivity(makeEngine().abortRun(payload.runId, payload.reason));
},
getStatusActivity(runId) {
return runActivity(makeEngine().getStatus(runId));
},
async isRunTerminalActivity(runId) {
const status = await runActivity(makeEngine().getStatus(runId));
return ["completed", "failed", "aborted"].includes(status.run.state);
},
async composeFinalReportActivity(runId) {
await runActivity(makeEngine().recoverMissingFinalReports({ runIds: [runId] }));
},
};
}
async function runActivity<T>(operation: Promise<T>): Promise<T> {
try {
return await operation;
} catch (error) {
throw toTemporalActivityFailure(error);
}
}
function toTemporalActivityFailure(error: unknown): unknown {
if (isActivityCancelled(error)) {
return new CancelledFailure("activity_cancelled", [], error as Error);
}
if (error instanceof DevflowError) {
return ApplicationFailure.create({
message: error.message,
type: "DevflowError",
nonRetryable: error.class !== "recoverable",
details: [
{
class: error.class,
code: error.code,
...(error.runId === undefined ? {} : { runId: error.runId }),
...(error.phaseId === undefined ? {} : { phaseId: error.phaseId }),
...(error.recoveryHint === undefined ? {} : { recoveryHint: error.recoveryHint }),
},
],
});
}
return error;
}
function withTemporalActivityCancellation(wait: DevflowActivityDependencies["wait"]) {
const context = currentActivityContext();
if (context === undefined) {
return wait;
}
return {
...wait,
signal: context.cancellationSignal,
onPoll: () => {
context.heartbeat({ operation: "advance_run" });
},
};
}
function currentActivityContext(): Context | undefined {
try {
return Context.current();
} catch {
return undefined;
}
}
function isActivityCancelled(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "activity_cancelled";
}

View File

@@ -0,0 +1,4 @@
export * from "./activities.js";
export * from "./temporal-run-engine.js";
export * from "./types.js";
export * from "./workflow.js";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,666 @@
import { randomUUID } from "node:crypto";
import {
ApplicationFailure,
type WorkflowClient,
WorkflowExecutionAlreadyStartedError,
type WorkflowHandle,
WorkflowNotFoundError,
} from "@temporalio/client";
import { type ApprovalDecisionAction, DevflowError } from "@devflow/core";
import type { RunEngine, RunStartInput, RunStatus } from "@devflow/run-engine";
import type { AbortSignalPayload } from "./types.js";
import { abortSignal, approveSignal, pauseSignal, resumeSignal, runWorkflow } from "./workflow.js";
export const temporalNamespace = "devflow";
export const temporalTaskQueue = "devflow-runs";
export interface TemporalRunEngineOptions {
client: WorkflowClient;
taskQueue?: string;
workflowIdPrefix?: string;
statusReader: Pick<RunEngine, "getStatus">;
controlValidator?: {
validateResumeSignalInput(runId: string): Promise<void>;
};
startReplayValidator?: { validateStartReplay(input: RunStartInput): Promise<void> };
approvalSignalReader?: {
validateApprovalSignalInput(
runId: string,
approvalRequestId: string,
action: ApprovalDecisionAction,
clientToken: string,
): Promise<"pending" | "applied">;
readApprovalSignalResult(
runId: string,
approvalRequestId: string,
action: ApprovalDecisionAction,
clientToken: string,
): Promise<"pending" | "applied">;
replayAppliedApprovalSideEffects?(runId: string, action: ApprovalDecisionAction): Promise<void>;
};
awaitRunStart?: boolean;
awaitSignals?: boolean;
startRunWaitMs?: number;
startRunPollMs?: number;
terminalReportWaitMs?: number;
}
export class TemporalRunEngine implements RunEngine {
private readonly client: WorkflowClient;
private readonly taskQueue: string;
private readonly workflowIdPrefix: string;
private readonly statusReader: Pick<RunEngine, "getStatus">;
private readonly controlValidator:
| {
validateResumeSignalInput(runId: string): Promise<void>;
}
| undefined;
private readonly startReplayValidator:
| { validateStartReplay(input: RunStartInput): Promise<void> }
| undefined;
private readonly approvalSignalReader: TemporalRunEngineOptions["approvalSignalReader"];
private readonly awaitRunStart: boolean;
private readonly awaitSignals: boolean;
private readonly startRunWaitMs: number;
private readonly startRunPollMs: number;
private readonly terminalReportWaitMs: number;
constructor(options: TemporalRunEngineOptions) {
this.client = options.client;
this.taskQueue = options.taskQueue ?? temporalTaskQueue;
this.workflowIdPrefix = options.workflowIdPrefix ?? "devflow-run";
this.statusReader = options.statusReader;
this.controlValidator = options.controlValidator;
this.startReplayValidator = options.startReplayValidator;
this.approvalSignalReader = options.approvalSignalReader;
this.awaitRunStart = options.awaitRunStart ?? true;
this.awaitSignals = options.awaitSignals ?? true;
this.startRunWaitMs = options.startRunWaitMs ?? 30_000;
this.startRunPollMs = options.startRunPollMs ?? 50;
this.terminalReportWaitMs = options.terminalReportWaitMs ?? 90_000;
}
async startRun(input: RunStartInput): Promise<{ runId: string }> {
const runId = input.runId ?? randomUUID();
let handle: Pick<WorkflowHandle<typeof runWorkflow>, "result"> | undefined;
try {
handle = await this.client.start(runWorkflow, {
args: [{ ...input, runId }],
taskQueue: this.taskQueue,
workflowId: this.workflowId(runId),
workflowIdConflictPolicy: "FAIL",
workflowIdReusePolicy: "REJECT_DUPLICATE",
});
} catch (error) {
if (!(error instanceof WorkflowExecutionAlreadyStartedError)) {
throw error;
}
const replayStatus = await this.validateAlreadyStartedReplay({ ...input, runId });
if (isTerminalRunState(replayStatus.run.state)) {
await this.waitForTerminalReportIfNeeded(runId, replayStatus);
return { runId };
}
}
if (this.awaitRunStart) {
await this.waitForRunStart(runId, handle);
}
return { runId };
}
async signalApproval(
runId: string,
approvalRequestId: string,
action: ApprovalDecisionAction,
clientToken: string,
comment?: string,
): Promise<void> {
if (this.awaitSignals && this.approvalSignalReader === undefined) {
throw new DevflowError("Temporal approval signal reader is not configured", {
class: "fatal",
code: "internal_state_corruption",
runId,
});
}
const initialDecision = await this.validateApprovalSignalInput(
runId,
approvalRequestId,
action,
clientToken,
);
if (initialDecision === "applied") {
const status = await this.getStatus(runId);
if (isTerminalRunState(status.run.state)) {
await this.approvalSignalReader?.replayAppliedApprovalSideEffects?.(runId, action);
await this.waitForTerminalReportIfNeeded(runId, status);
return;
}
}
const workflowHandle = this.handle(runId);
try {
await workflowHandle.signal(approveSignal, {
runId,
approvalRequestId,
action,
clientToken,
...(comment === undefined ? {} : { comment }),
});
} catch (error) {
if (
await this.settleClosedApprovalSignal(runId, approvalRequestId, action, clientToken, error)
) {
return;
}
throw error;
}
if (this.awaitSignals) {
await this.waitForApprovalSignalResult(runId, approvalRequestId, action, clientToken);
if (action === "approve" || action === "request_changes") {
const status = await this.waitForStatusWithoutTimeout(
runId,
(candidate) => !isActiveRunState(candidate.run.state),
);
if (status.run.state === "failed" || status.run.state === "aborted") {
await this.throwWorkflowFailureOrGeneric(
runId,
workflowHandle,
"Temporal approval signal failed during advancement",
);
}
await this.waitForTerminalReportIfNeeded(runId, status);
} else {
await this.waitForTerminalReportIfNeeded(
runId,
await this.waitForStatusWithoutTimeout(runId, (candidate) =>
isTerminalRunState(candidate.run.state),
),
);
}
}
}
async pauseRun(runId: string): Promise<void> {
const before = await this.getStatus(runId);
if (
isTerminalRunState(before.run.state) ||
!["planning", "executing", "awaiting_approval"].includes(before.run.state)
) {
return;
}
try {
await this.handle(runId).signal(pauseSignal, { runId, clientToken: randomUUID() });
} catch (error) {
const settled = await this.settleClosedWorkflowSignal(runId, error);
if (settled !== undefined) {
await this.throwControlNotApplied(runId, "pause", "paused", settled);
}
throw error;
}
if (this.awaitSignals) {
const status = await this.waitForStatusWithoutTimeout(
runId,
(status) => status.run.state === "paused" || isTerminalRunState(status.run.state),
);
if (status.run.state !== "paused") {
await this.throwControlNotApplied(runId, "pause", "paused", status);
}
}
}
async resumeRun(runId: string): Promise<void> {
const before = await this.getStatus(runId);
if (before.run.state !== "paused") {
return;
}
await this.controlValidator?.validateResumeSignalInput(runId);
try {
await this.handle(runId).signal(resumeSignal, { runId, clientToken: randomUUID() });
} catch (error) {
if ((await this.settleClosedWorkflowSignal(runId, error)) !== undefined) {
return;
}
throw error;
}
if (this.awaitSignals) {
const status = await this.waitForResumeSignalResult(runId);
if (status.run.state === "failed" || status.run.state === "aborted") {
throw new DevflowError("Temporal resume failed", {
class: "human_required",
code: "temporal_signal_failed",
runId,
recoveryHint: `run_state=${status.run.state}`,
});
}
await this.waitForTerminalReportIfNeeded(runId, status);
}
}
async abortRun(runId: string, reason: string): Promise<void> {
const before = await this.getStatus(runId);
if (isTerminalRunState(before.run.state)) {
return;
}
const payload: AbortSignalPayload = {
runId,
reason,
clientToken: randomUUID(),
};
try {
await this.handle(runId).signal(abortSignal, payload);
} catch (error) {
const settled = await this.settleClosedWorkflowSignal(runId, error);
if (settled !== undefined) {
if (settled.run.state === "aborted") {
return;
}
await this.throwControlNotApplied(runId, "abort", "aborted", settled);
}
throw error;
}
if (this.awaitSignals) {
const status = await this.waitForTerminalReportIfNeeded(
runId,
await this.waitForStatusWithoutTimeout(runId, (status) =>
isTerminalRunState(status.run.state),
),
);
if (status.run.state !== "aborted") {
await this.throwControlNotApplied(runId, "abort", "aborted", status);
}
}
}
getStatus(runId: string): Promise<RunStatus> {
return this.statusReader.getStatus(runId);
}
workflowId(runId: string): string {
return `${this.workflowIdPrefix}:${runId}`;
}
private handle(runId: string) {
return this.client.getHandle(this.workflowId(runId));
}
private async settleClosedWorkflowSignal(
runId: string,
error: unknown,
): Promise<RunStatus | undefined> {
if (!(error instanceof WorkflowNotFoundError)) {
return undefined;
}
const latest = await this.getStatus(runId);
if (!isTerminalRunState(latest.run.state)) {
return undefined;
}
return this.waitForTerminalReportIfNeeded(runId, latest);
}
private async settleClosedApprovalSignal(
runId: string,
approvalRequestId: string,
action: ApprovalDecisionAction,
clientToken: string,
error: unknown,
): Promise<boolean> {
if (!(error instanceof WorkflowNotFoundError)) {
return false;
}
await this.waitForApprovalSignalResult(runId, approvalRequestId, action, clientToken);
const latest = await this.getStatus(runId);
if (!isTerminalRunState(latest.run.state)) {
return false;
}
await this.approvalSignalReader?.replayAppliedApprovalSideEffects?.(runId, action);
await this.waitForTerminalReportIfNeeded(runId, latest);
return true;
}
private async waitForRunStart(
runId: string,
handle?: Pick<WorkflowHandle<typeof runWorkflow>, "result">,
): Promise<RunStatus> {
const workflowPromise = handle?.result().then(
() => this.statusReader.getStatus(runId),
(cause: unknown) => {
throw unwrapTemporalStartFailure(runId, cause);
},
);
const materializedStatus = await (workflowPromise === undefined
? this.waitForStatus(runId, () => true)
: Promise.race([this.waitForStatus(runId, () => true), workflowPromise]));
if (!isActiveRunState(materializedStatus.run.state)) {
await this.throwIfStartupFailed(runId, materializedStatus, handle);
return this.waitForTerminalReportIfNeeded(runId, materializedStatus);
}
const status = await (workflowPromise === undefined
? this.waitForStatusWithoutTimeout(
runId,
(candidate) => !isActiveRunState(candidate.run.state),
)
: Promise.race([
this.waitForStatusWithoutTimeout(
runId,
(candidate) => !isActiveRunState(candidate.run.state),
),
workflowPromise,
]));
await this.throwIfStartupFailed(runId, status, handle);
return this.waitForTerminalReportIfNeeded(runId, status);
}
private async throwIfStartupFailed(
runId: string,
status: RunStatus,
handle?: Pick<WorkflowHandle<typeof runWorkflow>, "result">,
): Promise<void> {
if (status.run.state === "failed" || status.run.state === "aborted") {
if (handle !== undefined) {
try {
await handle.result();
} catch (cause) {
throw unwrapTemporalStartFailure(runId, cause);
}
}
throw new DevflowError("Temporal run failed during startup", {
class: "human_required",
code: "temporal_start_failed",
runId,
recoveryHint: `run_state=${status.run.state}`,
});
}
}
private async validateAlreadyStartedReplay(
input: RunStartInput & { runId: string },
): Promise<RunStatus> {
if (this.startReplayValidator === undefined) {
throw new DevflowError("Temporal start replay validation is not configured", {
class: "fatal",
code: "internal_state_corruption",
runId: input.runId,
});
}
const status = await this.waitForStatus(input.runId, () => true);
await this.startReplayValidator.validateStartReplay(input);
return status;
}
private async waitForStatus(
runId: string,
isReady: (status: RunStatus) => boolean,
): Promise<RunStatus> {
const deadline = Date.now() + this.startRunWaitMs;
let lastError: unknown;
do {
try {
const status = await this.statusReader.getStatus(runId);
if (isReady(status)) {
return status;
}
} catch (error) {
lastError = error;
}
await sleep(this.startRunPollMs);
} while (Date.now() < deadline);
throw new DevflowError("Temporal run did not materialize before timeout", {
class: "human_required",
code: "temporal_start_timeout",
runId,
recoveryHint: "Check the Temporal worker process and task queue configuration.",
cause: lastError,
});
}
private async waitForStatusWithoutTimeout(
runId: string,
isReady: (status: RunStatus) => boolean,
): Promise<RunStatus> {
for (;;) {
const status = await this.statusReader.getStatus(runId);
if (isReady(status)) {
return status;
}
await sleep(this.startRunPollMs);
}
}
private async waitForResumeSignalResult(runId: string): Promise<RunStatus> {
for (;;) {
const status = await this.statusReader.getStatus(runId);
if (status.run.state !== "paused" && !isActiveRunState(status.run.state)) {
return status;
}
if (status.run.state === "paused") {
await this.controlValidator?.validateResumeSignalInput(runId);
}
await sleep(this.startRunPollMs);
}
}
private async waitForTerminalReportIfNeeded(
runId: string,
status: RunStatus,
): Promise<RunStatus> {
if (!isTerminalRunState(status.run.state) || status.run.finalReportPath !== null) {
return status;
}
const deadline = Date.now() + this.terminalReportWaitMs;
let latest = status;
do {
await sleep(this.startRunPollMs);
latest = await this.statusReader.getStatus(runId);
if (isTerminalRunState(latest.run.state) && latest.run.finalReportPath !== null) {
return latest;
}
if (!isTerminalRunState(latest.run.state)) {
return latest;
}
} while (Date.now() < deadline);
throw new DevflowError("Temporal terminal run report did not materialize before timeout", {
class: "human_required",
code: "final_report_timeout",
runId,
recoveryHint: `run_state=${latest.run.state}`,
});
}
private async throwControlNotApplied(
runId: string,
control: "abort" | "pause",
expectedState: "aborted" | "paused",
status: RunStatus,
): Promise<never> {
const latest = await this.waitForTerminalReportIfNeeded(runId, status);
throw new DevflowError(`Temporal ${control} signal was not applied`, {
class: "human_required",
code: "temporal_signal_failed",
runId,
recoveryHint: `expected_run_state=${expectedState};actual_run_state=${latest.run.state}`,
});
}
private async validateApprovalSignalInput(
runId: string,
approvalRequestId: string,
action: ApprovalDecisionAction,
clientToken: string,
): Promise<"pending" | "applied"> {
if (this.approvalSignalReader === undefined) {
return "pending";
}
return this.approvalSignalReader.validateApprovalSignalInput(
runId,
approvalRequestId,
action,
clientToken,
);
}
private async waitForApprovalSignalResult(
runId: string,
approvalRequestId: string,
action: ApprovalDecisionAction,
clientToken: string,
): Promise<void> {
const deadline = Date.now() + this.startRunWaitMs;
const reader = this.approvalSignalReader;
if (reader === undefined) {
throw new DevflowError("Temporal approval signal reader is not configured", {
class: "fatal",
code: "internal_state_corruption",
runId,
});
}
do {
const result = await reader.readApprovalSignalResult(
runId,
approvalRequestId,
action,
clientToken,
);
if (result === "applied") {
return;
}
await sleep(this.startRunPollMs);
} while (Date.now() < deadline);
throw new DevflowError("Temporal approval signal did not apply before timeout", {
class: "human_required",
code: "temporal_signal_timeout",
runId,
recoveryHint: "Check the Temporal worker process and approval request state.",
});
}
private async throwWorkflowFailureOrGeneric(
runId: string,
handle: Pick<WorkflowHandle<typeof runWorkflow>, "result">,
message: string,
): Promise<never> {
try {
await handle.result();
} catch (error) {
throw unwrapTemporalFailure(runId, error, "temporal_signal_failed");
}
throw new DevflowError(message, {
class: "human_required",
code: "temporal_signal_failed",
runId,
recoveryHint: "run_state=failed",
});
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}
function isActiveRunState(state: string): boolean {
return state === "created" || state === "bound" || state === "executing" || state === "planning";
}
function isTerminalRunState(state: string): boolean {
return state === "completed" || state === "failed" || state === "aborted";
}
function unwrapTemporalStartFailure(runId: string, cause: unknown): unknown {
return unwrapTemporalFailure(runId, cause, "temporal_start_failed");
}
function unwrapTemporalFailure(runId: string, cause: unknown, fallbackCode: string): unknown {
const maybeCause = nestedCause(cause);
if (maybeCause instanceof DevflowError) {
return maybeCause;
}
return new DevflowError("Temporal workflow failed", {
class: "human_required",
code: fallbackCode,
runId,
recoveryHint: "Inspect the Temporal workflow failure and run events.",
cause,
});
}
function nestedCause(error: unknown): unknown {
let current = error;
const seen = new Set<unknown>();
while (current !== null && typeof current === "object" && !seen.has(current)) {
seen.add(current);
if (current instanceof DevflowError) {
return current;
}
if (isApplicationFailureLike(current)) {
const devflowError = devflowErrorFromApplicationFailure(current);
if (devflowError !== undefined) {
return devflowError;
}
}
current = (current as { cause?: unknown }).cause;
}
return undefined;
}
function devflowErrorFromApplicationFailure(
error: ApplicationFailureLike,
): DevflowError | undefined {
if (error.type !== "DevflowError") {
return undefined;
}
const details = error.details?.[0];
if (!isSerializedDevflowError(details)) {
return undefined;
}
return new DevflowError(error.message ?? "Temporal activity failed with DevflowError", {
class: details.class,
code: details.code,
...(details.runId === undefined ? {} : { runId: details.runId }),
...(details.phaseId === undefined ? {} : { phaseId: details.phaseId }),
...(details.recoveryHint === undefined ? {} : { recoveryHint: details.recoveryHint }),
cause: error,
});
}
interface ApplicationFailureLike {
message?: string;
type?: string | null;
details?: unknown[] | null;
}
function isApplicationFailureLike(value: unknown): value is ApplicationFailureLike {
return (
value instanceof ApplicationFailure ||
(value !== null &&
typeof value === "object" &&
"type" in value &&
(value as { type?: unknown }).type === "DevflowError")
);
}
function isSerializedDevflowError(value: unknown): value is {
class: "recoverable" | "human_required" | "fatal";
code: string;
runId?: string;
phaseId?: string;
recoveryHint?: string;
} {
if (value === null || typeof value !== "object") {
return false;
}
const candidate = value as Record<string, unknown>;
return (
(candidate.class === "recoverable" ||
candidate.class === "human_required" ||
candidate.class === "fatal") &&
typeof candidate.code === "string" &&
(candidate.runId === undefined || typeof candidate.runId === "string") &&
(candidate.phaseId === undefined || typeof candidate.phaseId === "string") &&
(candidate.recoveryHint === undefined || typeof candidate.recoveryHint === "string")
);
}

View File

@@ -0,0 +1,24 @@
import type { ApprovalDecisionAction } from "@devflow/core";
export interface ApprovalSignalPayload {
runId: string;
approvalRequestId: string;
action: ApprovalDecisionAction;
clientToken: string;
comment?: string;
idempotencyKey?: string;
}
export interface RunSignalPayload {
runId: string;
clientToken?: string;
idempotencyKey?: string;
}
export interface AbortSignalPayload extends RunSignalPayload {
reason: string;
}
export interface RunWorkflowResult {
runId: string;
}

View File

@@ -0,0 +1,440 @@
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import type { RunStatus } from "@devflow/run-engine";
import { ApplicationFailure } from "@temporalio/activity";
import { TestWorkflowEnvironment } from "@temporalio/testing";
import { Worker } from "@temporalio/worker";
import { describe, expect, it } from "vitest";
import type { DevflowActivities } from "./activities.js";
import { TemporalRunEngine } from "./temporal-run-engine.js";
import { abortSignal, runWorkflow } from "./workflow.js";
describe("runWorkflow Temporal integration", () => {
it("orchestrates a fake M4-style run through a real Temporal worker", async () => {
const testEnv = await TestWorkflowEnvironment.createTimeSkipping();
try {
const runId = randomUUID();
const taskQueue = `devflow-workflow-test-${runId}`;
const workflowId = `devflow-run:${runId}`;
let status = runStatus(runId, "created", []);
let advanceCalls = 0;
let reportComposed = false;
const activities: DevflowActivities = {
async prepareRunActivity(input) {
const preparedRunId = input.runId ?? runId;
status = runStatus(preparedRunId, "created", []);
return { runId: preparedRunId };
},
async lockBindingsActivity(input) {
status = runStatus(input.runId ?? runId, "executing", []);
},
async failRunActivity(input) {
status = runStatus(input.runId, "failed", []);
},
async advanceRunActivity(input) {
advanceCalls += 1;
status = runStatus(input.runId, "completed", []);
return status;
},
async signalApprovalActivity() {
throw new Error("approval signal should not be needed for this workflow path");
},
async pauseRunActivity(payload) {
status = runStatus(payload.runId, "paused", []);
},
async resumeRunActivity(payload) {
status = runStatus(payload.runId, "executing", []);
},
async abortRunActivity(payload) {
status = runStatus(payload.runId, "aborted", []);
},
async getStatusActivity() {
return status;
},
async isRunTerminalActivity() {
return ["completed", "failed", "aborted"].includes(status.run.state);
},
async composeFinalReportActivity(runIdToReport) {
reportComposed = true;
status = runStatus(runIdToReport, "completed", [], {
finalReportPath: "/workspace/run/run.report.md",
});
},
};
const worker = await Worker.create({
activities,
connection: testEnv.nativeConnection,
...(testEnv.namespace === undefined ? {} : { namespace: testEnv.namespace }),
taskQueue,
workflowsPath: fileURLToPath(new URL("./workflow.ts", import.meta.url)),
});
await expect(
worker.runUntil(async () => {
const handle = await testEnv.client.workflow.start(runWorkflow, {
args: [
{
runId,
requirementsMd: "Run the workflow integration parity path.",
repoPath: "/repo",
baseBranch: "main",
},
],
taskQueue,
workflowId,
});
return handle.result();
}),
).resolves.toEqual({ runId });
expect(advanceCalls).toBe(1);
expect(reportComposed).toBe(true);
} finally {
await testEnv.teardown();
}
}, 120_000);
it("processes a queued abort signal before starting another advance activity", async () => {
const testEnv = await TestWorkflowEnvironment.createTimeSkipping();
try {
const runId = randomUUID();
const taskQueue = `devflow-workflow-test-${runId}`;
const workflowId = `devflow-run:${runId}`;
const lockStarted = deferred<void>();
const releaseLock = deferred<void>();
let status = runStatus(runId, "created", []);
let advanceCalls = 0;
let abortCalls = 0;
let reportComposed = false;
const activities: DevflowActivities = {
async prepareRunActivity(input) {
const preparedRunId = input.runId ?? runId;
status = runStatus(preparedRunId, "created", []);
return { runId: preparedRunId };
},
async lockBindingsActivity(input) {
status = runStatus(input.runId ?? runId, "executing", []);
lockStarted.resolve(undefined);
await releaseLock.promise;
},
async failRunActivity(input) {
status = runStatus(input.runId, "failed", []);
},
async advanceRunActivity(input) {
advanceCalls += 1;
status = runStatus(input.runId, "completed", []);
return status;
},
async signalApprovalActivity() {
throw new Error("approval signal should not be needed for this workflow path");
},
async pauseRunActivity(payload) {
status = runStatus(payload.runId, "paused", []);
},
async resumeRunActivity(payload) {
status = runStatus(payload.runId, "executing", []);
},
async abortRunActivity(payload) {
abortCalls += 1;
status = runStatus(payload.runId, "aborted", []);
},
async getStatusActivity() {
return status;
},
async isRunTerminalActivity() {
return ["completed", "failed", "aborted"].includes(status.run.state);
},
async composeFinalReportActivity(runIdToReport) {
reportComposed = true;
status = runStatus(runIdToReport, status.run.state, [], {
finalReportPath: "/workspace/run/run.report.md",
});
},
};
const worker = await Worker.create({
activities,
connection: testEnv.nativeConnection,
...(testEnv.namespace === undefined ? {} : { namespace: testEnv.namespace }),
taskQueue,
workflowsPath: fileURLToPath(new URL("./workflow.ts", import.meta.url)),
});
await expect(
worker.runUntil(async () => {
const handle = await testEnv.client.workflow.start(runWorkflow, {
args: [
{
runId,
requirementsMd: "Abort while a fake advancement is completing.",
repoPath: "/repo",
baseBranch: "main",
},
],
taskQueue,
workflowId,
});
await lockStarted.promise;
await handle.signal(abortSignal, {
runId,
reason: "user_requested_abort",
clientToken: "abort-token-1",
});
releaseLock.resolve(undefined);
return handle.result();
}),
).resolves.toEqual({ runId });
expect(advanceCalls).toBe(0);
expect(abortCalls).toBe(1);
expect(reportComposed).toBe(true);
expect(status.run.state).toBe("aborted");
} finally {
await testEnv.teardown();
}
}, 120_000);
it("applies abort before waiting for an interrupted advance to settle", async () => {
const testEnv = await TestWorkflowEnvironment.createTimeSkipping();
try {
const runId = randomUUID();
const taskQueue = `devflow-workflow-test-${runId}`;
const workflowId = `devflow-run:${runId}`;
const advanceStarted = deferred<void>();
const abortObserved = deferred<void>();
let status = runStatus(runId, "created", []);
let abortCalls = 0;
let reportComposed = false;
const activities: DevflowActivities = {
async prepareRunActivity(input) {
const preparedRunId = input.runId ?? runId;
status = runStatus(preparedRunId, "created", []);
return { runId: preparedRunId };
},
async lockBindingsActivity(input) {
status = runStatus(input.runId ?? runId, "executing", []);
},
async failRunActivity(input) {
status = runStatus(input.runId, "failed", []);
},
async advanceRunActivity(input) {
advanceStarted.resolve(undefined);
await abortObserved.promise;
throw ApplicationFailure.create({
message: "Run left active state before fake phase mutation",
type: "DevflowError",
nonRetryable: true,
details: [{ class: "human_required", code: "run_state_changed", runId: input.runId }],
});
},
async signalApprovalActivity() {
throw new Error("approval signal should not be needed for this workflow path");
},
async pauseRunActivity(payload) {
status = runStatus(payload.runId, "paused", []);
},
async resumeRunActivity(payload) {
status = runStatus(payload.runId, "executing", []);
},
async abortRunActivity(payload) {
abortCalls += 1;
status = runStatus(payload.runId, "aborted", []);
abortObserved.resolve(undefined);
},
async getStatusActivity() {
return status;
},
async isRunTerminalActivity() {
return ["completed", "failed", "aborted"].includes(status.run.state);
},
async composeFinalReportActivity(runIdToReport) {
reportComposed = true;
status = runStatus(runIdToReport, status.run.state, [], {
finalReportPath: "/workspace/run/run.report.md",
});
},
};
const worker = await Worker.create({
activities,
connection: testEnv.nativeConnection,
...(testEnv.namespace === undefined ? {} : { namespace: testEnv.namespace }),
taskQueue,
workflowsPath: fileURLToPath(new URL("./workflow.ts", import.meta.url)),
});
await expect(
worker.runUntil(async () => {
const handle = await testEnv.client.workflow.start(runWorkflow, {
args: [
{
runId,
requirementsMd: "Abort while advancement is already in flight.",
repoPath: "/repo",
baseBranch: "main",
},
],
taskQueue,
workflowId,
});
await advanceStarted.promise;
await handle.signal(abortSignal, {
runId,
reason: "user_requested_abort",
clientToken: "abort-token-1",
});
return handle.result();
}),
).resolves.toEqual({ runId });
expect(abortCalls).toBe(1);
expect(reportComposed).toBe(true);
expect(status.run.state).toBe("aborted");
} finally {
await testEnv.teardown();
}
}, 120_000);
it("preserves non-retryable DevflowError activity failures through TemporalRunEngine", async () => {
const testEnv = await TestWorkflowEnvironment.createLocal();
try {
const runId = randomUUID();
const taskQueue = `devflow-workflow-test-${runId}`;
let status = runStatus(runId, "created", []);
let lockAttempts = 0;
const activities: DevflowActivities = {
async prepareRunActivity(input) {
const preparedRunId = input.runId ?? runId;
status = runStatus(preparedRunId, "created", []);
return { runId: preparedRunId };
},
async lockBindingsActivity(input) {
lockAttempts += 1;
status = runStatus(input.runId ?? runId, "executing", []);
throw ApplicationFailure.create({
message: "No eligible persona",
type: "DevflowError",
nonRetryable: true,
details: [
{
class: "human_required",
code: "no_eligible_persona",
runId: input.runId ?? runId,
},
],
});
},
async failRunActivity(input) {
status = runStatus(input.runId, "failed", []);
},
async advanceRunActivity(input) {
status = runStatus(input.runId, "completed", []);
return status;
},
async signalApprovalActivity() {
throw new Error("approval signal should not be needed for this workflow path");
},
async pauseRunActivity(payload) {
status = runStatus(payload.runId, "paused", []);
},
async resumeRunActivity(payload) {
status = runStatus(payload.runId, "executing", []);
},
async abortRunActivity(payload) {
status = runStatus(payload.runId, "aborted", []);
},
async getStatusActivity() {
return status;
},
async isRunTerminalActivity() {
return ["completed", "failed", "aborted"].includes(status.run.state);
},
async composeFinalReportActivity(runIdToReport) {
status = runStatus(runIdToReport, status.run.state, [], {
finalReportPath: "/workspace/run/run.report.md",
});
},
};
const worker = await Worker.create({
activities,
connection: testEnv.nativeConnection,
...(testEnv.namespace === undefined ? {} : { namespace: testEnv.namespace }),
taskQueue,
workflowsPath: fileURLToPath(new URL("./workflow.ts", import.meta.url)),
});
const engine = new TemporalRunEngine({
client: testEnv.client.workflow,
startRunPollMs: 1,
statusReader: { getStatus: async () => status },
taskQueue,
});
await expect(
worker.runUntil(() =>
engine.startRun({
runId,
requirementsMd: "Propagate lock binding failure through Temporal.",
repoPath: "/repo",
baseBranch: "main",
}),
),
).rejects.toMatchObject({ code: "no_eligible_persona" });
expect(lockAttempts).toBe(1);
expect(status.run.state).toBe("failed");
} finally {
await testEnv.teardown();
}
}, 120_000);
});
function runStatus(
runId: string,
state: string,
approvals: RunStatus["approvals"],
overrides: Partial<RunStatus["run"]> = {},
): RunStatus {
return {
run: {
id: runId,
state,
repoPath: "/repo",
baseBranch: "main",
worktreeRoot: "/workspace/run/main",
currentPhaseId: null,
finalReportPath: null,
startedAt: null,
endedAt: null,
...overrides,
},
approvals,
eventsTail: [],
phases: [],
};
}
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T | PromiseLike<T>): void;
reject(reason?: unknown): void;
}
function deferred<T>(): Deferred<T> {
let resolve!: Deferred<T>["resolve"];
let reject!: Deferred<T>["reject"];
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}

View File

@@ -0,0 +1,59 @@
import { ActivityCancellationType } from "@temporalio/workflow";
import { describe, expect, it } from "vitest";
import {
type QueuedSignal,
advanceRunActivityCancellationType,
handleQueuedSignal,
settleInterruptedAdvance,
} from "./workflow.js";
describe("runWorkflow signal handling", () => {
it("waits for advance activity cancellation completion before handling control signals", () => {
expect(advanceRunActivityCancellationType).toBe(
ActivityCancellationType.WAIT_CANCELLATION_COMPLETED,
);
});
it("treats stale resume approval conflicts as idempotent no-op controls", async () => {
const calls: string[] = [];
const signal: QueuedSignal = {
type: "resume",
payload: { runId: "run-1", clientToken: "token-1" },
};
await expect(
handleQueuedSignal(signal, {
async abortRunActivity() {
calls.push("abort");
},
async pauseRunActivity() {
calls.push("pause");
},
async resumeRunActivity() {
calls.push("resume");
throw new Error("Approval decision conflicts with the current request state");
},
async signalApprovalActivity() {
calls.push("approve");
},
}),
).resolves.toBeUndefined();
expect(calls).toEqual(["resume"]);
});
it("discards a successful advance result after a control signal wins the race", async () => {
await expect(settleInterruptedAdvance(Promise.resolve({ state: "completed" }))).resolves.toBe(
undefined,
);
});
it("treats interrupted advance cancellation as a control signal handoff", async () => {
const cancellation = new Error("activity canceled");
await expect(
settleInterruptedAdvance(Promise.reject(cancellation), (error) => error === cancellation),
).resolves.toBe(undefined);
});
});

View File

@@ -0,0 +1,268 @@
import {
ActivityCancellationType,
ActivityFailure,
ApplicationFailure,
CancellationScope,
condition,
defineSignal,
isCancellation,
proxyActivities,
rootCause,
setHandler,
} from "@temporalio/workflow";
import type { RunStartInput, RunStatus } from "@devflow/run-engine";
import type { DevflowActivities } from "./activities.js";
import type {
AbortSignalPayload,
ApprovalSignalPayload,
RunSignalPayload,
RunWorkflowResult,
} from "./types.js";
export const approveSignal = defineSignal<[ApprovalSignalPayload]>("approve");
export const pauseSignal = defineSignal<[RunSignalPayload]>("pause");
export const resumeSignal = defineSignal<[RunSignalPayload]>("resume");
export const abortSignal = defineSignal<[AbortSignalPayload]>("abort");
export const unpauseSignal = defineSignal<[RunSignalPayload]>("unpause");
export type QueuedSignal =
| { type: "approve"; payload: ApprovalSignalPayload }
| { type: "pause"; payload: RunSignalPayload }
| { type: "resume"; payload: RunSignalPayload }
| { type: "abort"; payload: AbortSignalPayload }
| { type: "unpause"; payload: RunSignalPayload };
type ControlActivities = Pick<
DevflowActivities,
"abortRunActivity" | "pauseRunActivity" | "resumeRunActivity" | "signalApprovalActivity"
>;
const defaultActivities = proxyActivities<DevflowActivities>({
startToCloseTimeout: "10 minutes",
retry: {
maximumAttempts: 3,
initialInterval: "1 second",
maximumInterval: "30 seconds",
},
});
export const advanceRunActivityCancellationType =
ActivityCancellationType.WAIT_CANCELLATION_COMPLETED;
const interruptibleActivities = proxyActivities<Pick<DevflowActivities, "advanceRunActivity">>({
startToCloseTimeout: "10 minutes",
heartbeatTimeout: "5 seconds",
cancellationType: advanceRunActivityCancellationType,
retry: {
maximumAttempts: 3,
initialInterval: "1 second",
maximumInterval: "30 seconds",
},
});
const singleAttemptActivities = proxyActivities<
Pick<DevflowActivities, "composeFinalReportActivity">
>({
startToCloseTimeout: "1 minute",
retry: { maximumAttempts: 1 },
});
export async function runWorkflow(input: RunStartInput): Promise<RunWorkflowResult> {
const queue: QueuedSignal[] = [];
const enqueue = (signal: QueuedSignal) => {
queue.push(signal);
};
setHandler(approveSignal, (payload) => enqueue({ type: "approve", payload }));
setHandler(pauseSignal, (payload) => enqueue({ type: "pause", payload }));
setHandler(resumeSignal, (payload) => enqueue({ type: "resume", payload }));
setHandler(abortSignal, (payload) => enqueue({ type: "abort", payload }));
setHandler(unpauseSignal, (payload) => enqueue({ type: "unpause", payload }));
const result = await defaultActivities.prepareRunActivity(input);
const runInput = { ...input, runId: result.runId };
try {
await defaultActivities.lockBindingsActivity(runInput);
} catch (error) {
await defaultActivities.failRunActivity({
runId: result.runId,
reason: "lock_bindings_failed",
});
rethrowDevflowFailure(error);
}
let status: RunStatus | undefined;
try {
status = await advanceUntilBlockedOrSignal(result.runId, false, queue);
} catch (error) {
rethrowDevflowFailure(error);
}
if (status === undefined) {
if (queue.length > 0) {
await handleQueuedSignal(queue.shift());
}
status = await defaultActivities.getStatusActivity(result.runId);
}
while (!isTerminalRunState(status.run.state)) {
if (queue.length > 0) {
await handleQueuedSignal(queue.shift());
status = await defaultActivities.getStatusActivity(result.runId);
continue;
}
if (status.run.state === "executing" || status.run.state === "planning") {
let advanced: RunStatus | undefined;
try {
advanced = await advanceUntilBlockedOrSignal(result.runId, true, queue);
} catch (error) {
rethrowDevflowFailure(error);
}
if (advanced !== undefined) {
status = advanced;
continue;
}
if (queue.length > 0) {
await handleQueuedSignal(queue.shift());
status = await defaultActivities.getStatusActivity(result.runId);
continue;
}
status = await defaultActivities.getStatusActivity(result.runId);
continue;
}
await condition(() => queue.length > 0);
await handleQueuedSignal(queue.shift());
status = await defaultActivities.getStatusActivity(result.runId);
}
await singleAttemptActivities.composeFinalReportActivity(result.runId);
return result;
}
export async function handleQueuedSignal(
signal: QueuedSignal | undefined,
activities: ControlActivities = defaultActivities,
): Promise<void> {
if (signal === undefined) {
return;
}
if (signal.type === "approve") {
await ignoreControlConflict(activities.signalApprovalActivity(signal.payload));
} else if (signal.type === "pause") {
await ignoreControlConflict(activities.pauseRunActivity(signal.payload));
} else if (signal.type === "resume" || signal.type === "unpause") {
await ignoreControlConflict(activities.resumeRunActivity(signal.payload));
} else {
await ignoreControlConflict(activities.abortRunActivity(signal.payload));
}
}
async function ignoreControlConflict(operation: Promise<void>): Promise<void> {
try {
await operation;
} catch (error) {
if (rootCause(error) === "Approval decision conflicts with the current request state") {
return;
}
rethrowDevflowFailure(error);
}
}
async function advanceUntilBlockedOrSignal(
runId: string,
resumeActivePhase: boolean,
queue: QueuedSignal[],
): Promise<RunStatus | undefined> {
const scope = new CancellationScope({ cancellable: true });
const input = resumeActivePhase ? { runId, resumeActivePhase: true } : { runId };
const activityPromise = scope.run(() => interruptibleActivities.advanceRunActivity(input));
const signalPromise = condition(() => queue.length > 0).then(() => undefined);
const result = await Promise.race([activityPromise, signalPromise]);
if (result !== undefined) {
return result;
}
scope.cancel();
const interruptingSignal = queue[0];
if (interruptingSignal?.type === "abort" || interruptingSignal?.type === "pause") {
queue.shift();
await handleQueuedSignal(interruptingSignal);
await settleInterruptedAdvance(activityPromise, isCancellation, {
ignoreRunStateChanged: true,
});
return defaultActivities.getStatusActivity(runId);
}
return settleInterruptedAdvance(activityPromise);
}
export async function settleInterruptedAdvance<T>(
activityPromise: Promise<T>,
isCanceled: (error: unknown) => boolean = isCancellation,
options: { ignoreRunStateChanged?: boolean } = {},
): Promise<undefined> {
try {
await activityPromise;
return undefined;
} catch (error) {
if (isCanceled(error)) {
return undefined;
}
if (
options.ignoreRunStateChanged === true &&
isDevflowFailureCode(error, "run_state_changed")
) {
return undefined;
}
throw error;
}
}
function isTerminalRunState(state: string): boolean {
return state === "completed" || state === "failed" || state === "aborted";
}
function rethrowDevflowFailure(error: unknown): never {
const failure = devflowApplicationFailure(error);
if (failure !== undefined) {
throw ApplicationFailure.create({
message: failure.message,
type: failure.type ?? "DevflowError",
nonRetryable: true,
details: failure.details ?? [],
});
}
throw error;
}
function devflowApplicationFailure(error: unknown): ApplicationFailure | undefined {
let current = error;
const seen = new Set<unknown>();
while (current !== null && typeof current === "object" && !seen.has(current)) {
seen.add(current);
if (current instanceof ApplicationFailure && current.type === "DevflowError") {
return current;
}
if (current instanceof ActivityFailure) {
current = current.cause;
continue;
}
current = (current as { cause?: unknown }).cause;
}
return undefined;
}
function isDevflowFailureCode(error: unknown, code: string): boolean {
const failure = devflowApplicationFailure(error);
const details = (failure as { details?: unknown[] } | undefined)?.details;
return (
details?.some(
(detail) =>
typeof detail === "object" && detail !== null && "code" in detail && detail.code === code,
) ?? false
);
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": true,
"noEmit": false
},
"references": [],
"exclude": ["src/**/*.test.ts"]
}

View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node", "vitest"]
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../core" },
{ "path": "../db" },
{ "path": "../run-engine" },
{ "path": "../session" }
]
}