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,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] });
}
}