311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
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] });
|
|
}
|
|
}
|