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