feat: add temporal run engine integration
This commit is contained in:
310
packages/workflows/src/activities.test.ts
Normal file
310
packages/workflows/src/activities.test.ts
Normal 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] });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user