feat: add temporal run engine integration
This commit is contained in:
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
@@ -84,6 +85,15 @@ class PausesAfterPromptAcceptedFakeAdapter extends FakeSessionAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
class DisposeCountingFakeAdapter extends FakeSessionAdapter {
|
||||
disposeCalls = 0;
|
||||
|
||||
override async dispose(handle: Parameters<FakeSessionAdapter["dispose"]>[0]): Promise<void> {
|
||||
this.disposeCalls += 1;
|
||||
await super.dispose(handle);
|
||||
}
|
||||
}
|
||||
|
||||
describe("DbRunEngine", () => {
|
||||
let client: DbClient | undefined;
|
||||
const runIds: string[] = [];
|
||||
@@ -129,6 +139,7 @@ describe("DbRunEngine", () => {
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
@@ -281,6 +292,7 @@ describe("DbRunEngine", () => {
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
@@ -357,6 +369,118 @@ describe("DbRunEngine", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("validates a prepared run replay without accepting changed start inputs", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
await seedDevelopmentRegistry(client.db);
|
||||
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-workspace-")));
|
||||
const repoPath = createGitRepo();
|
||||
tempRoots.push(workspaceRoot, repoPath);
|
||||
const engine = new DbRunEngine({
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
maxConcurrentRuns: 100,
|
||||
workspaceRoot,
|
||||
});
|
||||
const runId = randomUUID();
|
||||
const input = {
|
||||
runId,
|
||||
requirementsMd: "Validate replayed Temporal start input.",
|
||||
repoPath,
|
||||
baseBranch: "main",
|
||||
scenarios: { spec: "ok" },
|
||||
};
|
||||
|
||||
await engine.prepareRun(input);
|
||||
runIds.push(runId);
|
||||
await expect(engine.validatePreparedRunInput(input)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
engine.validatePreparedRunInput({
|
||||
...input,
|
||||
scenarios: { spec: "timeout" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "internal_state_corruption" });
|
||||
});
|
||||
|
||||
it("rejects prepared run replay when the persisted worktree path is only a partial directory", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
await seedDevelopmentRegistry(client.db);
|
||||
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-workspace-")));
|
||||
const repoPath = createGitRepo();
|
||||
tempRoots.push(workspaceRoot, repoPath);
|
||||
const engine = new DbRunEngine({
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
maxConcurrentRuns: 100,
|
||||
workspaceRoot,
|
||||
});
|
||||
const runId = randomUUID();
|
||||
const input = {
|
||||
runId,
|
||||
requirementsMd: "Reject partial worktree replay.",
|
||||
repoPath,
|
||||
baseBranch: "main",
|
||||
};
|
||||
|
||||
await engine.prepareRun(input);
|
||||
runIds.push(runId);
|
||||
const [run] = await client.db
|
||||
.select({ worktreeRoot: runs.worktreeRoot })
|
||||
.from(runs)
|
||||
.where(eq(runs.id, runId));
|
||||
expect(run).toBeDefined();
|
||||
if (run === undefined) {
|
||||
throw new Error("prepared run missing");
|
||||
}
|
||||
rmSync(run.worktreeRoot, { recursive: true, force: true });
|
||||
mkdirSync(run.worktreeRoot, { recursive: true });
|
||||
|
||||
await expect(engine.prepareRun(input)).rejects.toMatchObject({
|
||||
code: "workspace_permissions",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects prepared run replay when the persisted worktree belongs to another repo", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
await seedDevelopmentRegistry(client.db);
|
||||
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-workspace-")));
|
||||
const repoPath = createGitRepo();
|
||||
tempRoots.push(workspaceRoot, repoPath);
|
||||
const engine = new DbRunEngine({
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
maxConcurrentRuns: 100,
|
||||
workspaceRoot,
|
||||
});
|
||||
const runId = randomUUID();
|
||||
const input = {
|
||||
runId,
|
||||
requirementsMd: "Reject a replayed worktree that belongs to a different repo.",
|
||||
repoPath,
|
||||
baseBranch: "main",
|
||||
};
|
||||
|
||||
await engine.prepareRun(input);
|
||||
runIds.push(runId);
|
||||
const [run] = await client.db
|
||||
.select({ worktreeRoot: runs.worktreeRoot })
|
||||
.from(runs)
|
||||
.where(eq(runs.id, runId));
|
||||
expect(run).toBeDefined();
|
||||
if (run === undefined) {
|
||||
throw new Error("prepared run missing");
|
||||
}
|
||||
rmSync(run.worktreeRoot, { recursive: true, force: true });
|
||||
mkdirSync(run.worktreeRoot, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", `devflow/${runId}/main`], {
|
||||
cwd: run.worktreeRoot,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
await expect(engine.prepareRun(input)).rejects.toMatchObject({
|
||||
code: "workspace_permissions",
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces the configured maximum concurrent active runs", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
await seedDevelopmentRegistry(client.db);
|
||||
@@ -418,6 +542,7 @@ describe("DbRunEngine", () => {
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
@@ -456,7 +581,7 @@ describe("DbRunEngine", () => {
|
||||
expect((await engine.getStatus(runId)).run.state).toBe("awaiting_approval");
|
||||
});
|
||||
|
||||
it("resumes an active phase that observed a manual pause mid-mutation", async () => {
|
||||
it("repairs an active phase that paused after prompt acceptance but before prompt proof", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
await seedDevelopmentRegistry(client.db);
|
||||
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-workspace-")));
|
||||
@@ -466,6 +591,7 @@ describe("DbRunEngine", () => {
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new PausesAfterPromptAcceptedFakeAdapter(client.db)),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
@@ -488,7 +614,7 @@ describe("DbRunEngine", () => {
|
||||
const resumed = await engine.getStatus(runId);
|
||||
expect(resumed.run.state).toBe("awaiting_approval");
|
||||
expect(resumed.phases.find((phase) => phase.phaseKey === "spec")).toMatchObject({
|
||||
attempts: 1,
|
||||
attempts: 2,
|
||||
state: "awaiting_approval",
|
||||
});
|
||||
expect(pendingApproval(resumed, "spec_approved")).toBeDefined();
|
||||
@@ -504,6 +630,7 @@ describe("DbRunEngine", () => {
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
@@ -567,6 +694,7 @@ describe("DbRunEngine", () => {
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
@@ -614,6 +742,7 @@ describe("DbRunEngine", () => {
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
@@ -650,6 +779,7 @@ describe("DbRunEngine", () => {
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
@@ -686,6 +816,7 @@ describe("DbRunEngine", () => {
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
@@ -736,6 +867,7 @@ describe("DbRunEngine", () => {
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
@@ -815,6 +947,7 @@ describe("DbRunEngine", () => {
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
@@ -871,6 +1004,7 @@ describe("DbRunEngine", () => {
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
@@ -937,6 +1071,7 @@ describe("DbRunEngine", () => {
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
@@ -983,6 +1118,7 @@ describe("DbRunEngine", () => {
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
@@ -1025,6 +1161,7 @@ describe("DbRunEngine", () => {
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
@@ -1051,6 +1188,127 @@ describe("DbRunEngine", () => {
|
||||
code: "approval_conflict",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat a client token suffix as an approval replay", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
await seedDevelopmentRegistry(client.db);
|
||||
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-workspace-")));
|
||||
const repoPath = createGitRepo();
|
||||
tempRoots.push(workspaceRoot, repoPath);
|
||||
const engine = new DbRunEngine({
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
const { runId } = await engine.startRun({
|
||||
requirementsMd: "Check approval token suffix handling.",
|
||||
repoPath,
|
||||
baseBranch: "main",
|
||||
});
|
||||
runIds.push(runId);
|
||||
const [request] = await client.db
|
||||
.select({ id: approvalRequests.id })
|
||||
.from(approvalRequests)
|
||||
.where(and(eq(approvalRequests.runId, runId), eq(approvalRequests.state, "pending")));
|
||||
expect(request).toBeDefined();
|
||||
if (request === undefined) {
|
||||
throw new Error("approval request missing");
|
||||
}
|
||||
|
||||
await engine.signalApproval(runId, request.id, "approve", "prefix:shared-token");
|
||||
await expect(
|
||||
engine.signalApproval(runId, request.id, "approve", "shared-token"),
|
||||
).rejects.toMatchObject({
|
||||
code: "approval_conflict",
|
||||
});
|
||||
});
|
||||
|
||||
it("replays terminal approval disposal side effects for duplicate decisions", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
await seedDevelopmentRegistry(client.db);
|
||||
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-workspace-")));
|
||||
const repoPath = createGitRepo();
|
||||
tempRoots.push(workspaceRoot, repoPath);
|
||||
const adapter = new DisposeCountingFakeAdapter({ writeDelayMs: 0 });
|
||||
const engine = new DbRunEngine({
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, adapter),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
|
||||
});
|
||||
|
||||
const { runId } = await engine.startRun({
|
||||
requirementsMd: "Reject and replay disposal.",
|
||||
repoPath,
|
||||
baseBranch: "main",
|
||||
});
|
||||
runIds.push(runId);
|
||||
const request = pendingApproval(await engine.getStatus(runId), "spec_approved");
|
||||
const clientToken = randomUUID();
|
||||
|
||||
await engine.signalApproval(runId, request.id, "reject", clientToken);
|
||||
expect(adapter.disposeCalls).toBe(1);
|
||||
await engine.signalApproval(runId, request.id, "reject", clientToken);
|
||||
expect(adapter.disposeCalls).toBe(2);
|
||||
await engine.replayAppliedApprovalSideEffects(runId, "reject");
|
||||
expect(adapter.disposeCalls).toBe(3);
|
||||
});
|
||||
|
||||
it("repairs missing aborted final reports during applied approval replay", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
await seedDevelopmentRegistry(client.db);
|
||||
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-workspace-")));
|
||||
const repoPath = createGitRepo();
|
||||
const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-worktree-")));
|
||||
tempRoots.push(workspaceRoot, repoPath, worktreeRoot);
|
||||
const [template] = await client.db
|
||||
.select({ hash: workflowTemplates.hash, id: workflowTemplates.id })
|
||||
.from(workflowTemplates)
|
||||
.where(eq(workflowTemplates.name, "development"))
|
||||
.limit(1);
|
||||
if (template === undefined) {
|
||||
throw new Error("development template missing");
|
||||
}
|
||||
const runId = randomUUID();
|
||||
runIds.push(runId);
|
||||
await client.db.insert(runs).values({
|
||||
id: runId,
|
||||
templateId: template.id,
|
||||
templateHash: template.hash,
|
||||
state: "aborted",
|
||||
repoPath,
|
||||
baseBranch: "main",
|
||||
worktreeRoot,
|
||||
endedAt: new Date(),
|
||||
finalReportPath: null,
|
||||
});
|
||||
const engine = new DbRunEngine({
|
||||
db: client.db,
|
||||
sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })),
|
||||
workspaceRoot,
|
||||
maxConcurrentRuns: 100,
|
||||
});
|
||||
|
||||
await engine.replayAppliedApprovalSideEffects(runId, "approve");
|
||||
|
||||
const [run] = await client.db
|
||||
.select({ finalReportPath: runs.finalReportPath })
|
||||
.from(runs)
|
||||
.where(eq(runs.id, runId));
|
||||
expect(run?.finalReportPath).toMatch(/\.report\.md$/);
|
||||
if (run?.finalReportPath === null || run?.finalReportPath === undefined) {
|
||||
throw new Error("final report was not repaired");
|
||||
}
|
||||
expect(
|
||||
JSON.parse(
|
||||
readFileSync(run.finalReportPath.replace(/\.report\.md$/, ".report.json"), "utf8"),
|
||||
),
|
||||
).toMatchObject({ runId, status: "aborted" });
|
||||
});
|
||||
});
|
||||
|
||||
function pendingApproval(status: Awaited<ReturnType<DbRunEngine["getStatus"]>>, gateKey: string) {
|
||||
|
||||
Reference in New Issue
Block a user