feat(pr): pr-create/pr-respond/pr-merge node kinds + handlers (U3)
Adds the three first-class PR workflow node kinds and their handlers via createPrNodeHandlers(deps), registered in createDefaultNodeHandlers (fail-closed pr-nodes-unwired when absent). GitHub ops are injected as callbacks (PrNodeGithubOps) at all three CLI sites (daemon/serve/dashboard) so the engine never imports the dashboard client (FN-3049). pr-create routes open/failed as outcomes; pr-merge passes expectedHeadOid and never writes 'merged' (reconcile corroborates); pr-respond delegates to an injected respond callback (U5 fills the body). 10 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ import {
|
||||
processPullRequestMergeTask,
|
||||
createGroupPrCallback,
|
||||
syncGroupPrCallback,
|
||||
createPrNodeGithubOps,
|
||||
} from "./task-lifecycle.js";
|
||||
import { promptForPort } from "./port-prompt.js";
|
||||
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
||||
@@ -338,6 +339,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool),
|
||||
createGroupPr: createGroupPrCallback(githubClient),
|
||||
syncGroupPr: syncGroupPrCallback(githubClient),
|
||||
prNodeGithubOps: createPrNodeGithubOps(githubClient),
|
||||
getTaskMergeBlocker,
|
||||
onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult),
|
||||
});
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
processPullRequestMergeTask,
|
||||
createGroupPrCallback,
|
||||
syncGroupPrCallback,
|
||||
createPrNodeGithubOps,
|
||||
} from "./task-lifecycle.js";
|
||||
import { promptForPort } from "./port-prompt.js";
|
||||
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
|
||||
@@ -1614,6 +1615,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool),
|
||||
createGroupPr: createGroupPrCallback(githubClient),
|
||||
syncGroupPr: syncGroupPrCallback(githubClient),
|
||||
prNodeGithubOps: createPrNodeGithubOps(githubClient),
|
||||
getTaskMergeBlocker,
|
||||
});
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
processPullRequestMergeTask,
|
||||
createGroupPrCallback,
|
||||
syncGroupPrCallback,
|
||||
createPrNodeGithubOps,
|
||||
} from "./task-lifecycle.js";
|
||||
import { promptForPort } from "./port-prompt.js";
|
||||
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
||||
@@ -364,6 +365,7 @@ export async function runServe(
|
||||
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool),
|
||||
createGroupPr: createGroupPrCallback(githubClient),
|
||||
syncGroupPr: syncGroupPrCallback(githubClient),
|
||||
prNodeGithubOps: createPrNodeGithubOps(githubClient),
|
||||
getTaskMergeBlocker,
|
||||
onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult),
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ import type { TaskStore } from "@fusion/core";
|
||||
import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded } from "@fusion/core";
|
||||
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";
|
||||
import { activeSessionRegistry, resolveIntegrationBranch } from "@fusion/engine";
|
||||
import type { CreateGroupPrFn, SyncGroupPrFn, WorktreePool } from "@fusion/engine";
|
||||
import type { CreateGroupPrFn, SyncGroupPrFn, WorktreePool, PrNodeGithubOps } from "@fusion/engine";
|
||||
|
||||
/**
|
||||
* Minimal interface for GitHub operations needed by the PR merge workflow.
|
||||
@@ -43,7 +43,7 @@ interface GitHubOperations {
|
||||
mergeReady: boolean;
|
||||
blockingReasons: string[];
|
||||
}>;
|
||||
mergePr(params: { number: number; method?: "merge" | "squash" | "rebase" }): Promise<PrInfo>;
|
||||
mergePr(params: { number: number; method?: "merge" | "squash" | "rebase"; expectedHeadOid?: string }): Promise<PrInfo>;
|
||||
getPrStatus(owner: string, repo: string, number: number): Promise<PrInfo>;
|
||||
updatePr(params: { owner?: string; repo?: string; number: number; title?: string; body?: string }): Promise<PrInfo>;
|
||||
closePr(params: { number: number }): Promise<PrInfo>;
|
||||
@@ -303,6 +303,90 @@ export function syncGroupPrCallback(
|
||||
};
|
||||
}
|
||||
|
||||
/** Best-effort resolve the head commit OID for a branch (so `pr-merge` can pass
|
||||
* `expectedHeadOid`). Returns undefined on any failure — the merge then runs
|
||||
* without the stale-head guard, which the reconcile still corroborates. */
|
||||
async function resolveBranchHeadOid(cwd: string, branch: string): Promise<string | undefined> {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-parse "${branch}"`, { cwd, timeout: 30_000 });
|
||||
const oid = stdout.trim();
|
||||
return oid.length > 0 ? oid : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Structural detection of the dashboard `PrStaleHeadError` without importing the
|
||||
* class (task-lifecycle.ts deliberately has no @fusion/dashboard dependency). */
|
||||
function isStaleHeadError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code?: unknown }).code === "stale-head"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `prNodeGithubOps` engine callbacks (U3) backing the `pr-create` /
|
||||
* `pr-respond` / `pr-merge` workflow nodes. Closes over a GitHub client so the
|
||||
* engine never imports the dashboard `GitHubClient` (FN-3049). Mirrors
|
||||
* `createGroupPrCallback` / `syncGroupPrCallback`.
|
||||
*
|
||||
* - resolvePrSource: derives the single-task PR source identity (repo from the
|
||||
* per-process repo, head branch from the task branch-naming convention).
|
||||
* - createPr: pushes the task branch to origin, opens the PR, resolves the head OID.
|
||||
* - mergePr: merges with `expectedHeadOid`; a `PrStaleHeadError` (detected
|
||||
* structurally) maps to `{ status: "stale-head" }` so the node routes the race.
|
||||
* - respond: omitted in U3 (U5 wires the real review-response run); the node then
|
||||
* falls back to its inert `disagreed-only` default.
|
||||
*/
|
||||
export function createPrNodeGithubOps(
|
||||
github: Pick<GitHubOperations, "createPr" | "mergePr">,
|
||||
): PrNodeGithubOps {
|
||||
return {
|
||||
resolvePrSource: (task) => {
|
||||
const repo = getCurrentRepo();
|
||||
const repoSlug = repo ? `${repo.owner}/${repo.repo}` : "";
|
||||
return {
|
||||
sourceType: "task",
|
||||
sourceId: task.id,
|
||||
repo: repoSlug,
|
||||
headBranch: getTaskBranchName(task.id),
|
||||
};
|
||||
},
|
||||
createPr: async ({ task, entity }) => {
|
||||
const cwd = process.cwd();
|
||||
const headBranch = entity.headBranch || getTaskBranchName(task.id);
|
||||
await pushTaskBranchToOrigin(cwd, headBranch);
|
||||
const created = await github.createPr({
|
||||
title: task.title ?? `Task ${task.id}`,
|
||||
body: task.description ?? "",
|
||||
head: headBranch,
|
||||
base: entity.baseBranch,
|
||||
});
|
||||
const headOid = await resolveBranchHeadOid(cwd, headBranch);
|
||||
return { prNumber: created.number, prUrl: created.url, headOid };
|
||||
},
|
||||
mergePr: async ({ entity }) => {
|
||||
if (entity.prNumber == null) {
|
||||
throw new Error(`pr-merge: entity ${entity.id} has no persisted prNumber`);
|
||||
}
|
||||
try {
|
||||
await github.mergePr({
|
||||
number: entity.prNumber,
|
||||
method: "squash",
|
||||
expectedHeadOid: entity.headOid,
|
||||
});
|
||||
return { status: "merged-requested" };
|
||||
} catch (err) {
|
||||
if (isStaleHeadError(err)) return { status: "stale-head" };
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function hasCommitsRelativeToBranch(cwd: string, branch: string, baseBranch: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-list --count "${baseBranch}..${branch}"`, { cwd, timeout: 30_000 });
|
||||
|
||||
@@ -522,6 +522,23 @@ export {
|
||||
isBranchGroupMemberLanded,
|
||||
isBranchGroupComplete,
|
||||
} from "./branch-group-completion.js";
|
||||
export type {
|
||||
PrEntity,
|
||||
PrEntityCreateInput,
|
||||
PrEntityUpdate,
|
||||
PrEntityState,
|
||||
PrEntitySourceType,
|
||||
PrReviewDecision,
|
||||
PrChecksRollup,
|
||||
PrThreadState,
|
||||
PrThreadOutcome,
|
||||
} from "./types.js";
|
||||
export {
|
||||
isPrEntityActive,
|
||||
isPrBacked,
|
||||
isPrEntityActionable,
|
||||
isPrEntityAutoMergeReady,
|
||||
} from "./pr-entity.js";
|
||||
export {
|
||||
findVitestProcessIds,
|
||||
type FindVitestProcessIdsOptions,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/** Node kinds. v1 kinds (start/prompt/script/gate/end) plus the v2 additions:
|
||||
* `hold` (passive dwell column states), `split`/`join` (parallel fan-out), and
|
||||
* the step-inversion additions (FN step-inversion, KTD-3/4/12/15):
|
||||
* `foreach` (runtime-expanding per-step template region), `step-review`
|
||||
* (per-step review verdicts as outcome edges), `parse-steps` (graph-native
|
||||
* step-list parsing), and `code` (sandboxed TypeScript). */
|
||||
* `hold` (passive dwell column states), `split`/`join` (parallel fan-out), the
|
||||
* step-inversion additions (FN step-inversion, KTD-3/4/12/15): `foreach`
|
||||
* (runtime-expanding per-step template region), `step-review` (per-step review
|
||||
* verdicts as outcome edges), `parse-steps` (graph-native step-list parsing),
|
||||
* and `code` (sandboxed TypeScript); and the unified PR-entity additions (U3):
|
||||
* `pr-create` (open/reuse the PR + write the entity), `pr-respond` (the
|
||||
* review-response run), and `pr-merge` (tool-side merge with expectedHeadOid). */
|
||||
export type WorkflowIrNodeKind =
|
||||
| "start"
|
||||
| "prompt"
|
||||
@@ -16,7 +18,10 @@ export type WorkflowIrNodeKind =
|
||||
| "foreach"
|
||||
| "step-review"
|
||||
| "parse-steps"
|
||||
| "code";
|
||||
| "code"
|
||||
| "pr-create"
|
||||
| "pr-respond"
|
||||
| "pr-merge";
|
||||
|
||||
export interface WorkflowIrNode {
|
||||
id: string;
|
||||
|
||||
212
packages/engine/src/__tests__/pr-nodes.test.ts
Normal file
212
packages/engine/src/__tests__/pr-nodes.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* U3 — PR node handlers (pr-create / pr-respond / pr-merge).
|
||||
*
|
||||
* Covers: pr-create success→open, pr-create failure→failed (routable, never
|
||||
* throws), create idempotent re-entry, pr-merge stale-head→value:"stale-head"
|
||||
* with no `merged` write, pr-merge does-not-write-merged on success, unverified
|
||||
* entity not actioned, and unwired deps fail closed (value:"pr-nodes-unwired").
|
||||
*
|
||||
* The handlers run against a real in-memory TaskStore (U1 store CRUD) and fakes
|
||||
* for the injected GitHub callbacks — the engine never touches a real client.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import type { TaskDetail, WorkflowIrNode } from "@fusion/core";
|
||||
|
||||
import {
|
||||
createPrNodeHandlers,
|
||||
type PrMergeCallResult,
|
||||
type PrNodeDeps,
|
||||
type PrSourceDescriptor,
|
||||
} from "../pr-nodes.js";
|
||||
import { createDefaultNodeHandlers, createNoopLegacySeams } from "../workflow-node-handlers.js";
|
||||
import type { WorkflowNodeExecutionContext } from "../workflow-graph-executor.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "fusion-pr-nodes-test-"));
|
||||
}
|
||||
|
||||
const SOURCE: PrSourceDescriptor = {
|
||||
sourceType: "task",
|
||||
sourceId: "T-1",
|
||||
repo: "owner/repo",
|
||||
headBranch: "fusion/t-1",
|
||||
};
|
||||
|
||||
function ctx(taskId = "T-1"): WorkflowNodeExecutionContext {
|
||||
return {
|
||||
task: { id: taskId } as unknown as TaskDetail,
|
||||
settings: undefined,
|
||||
context: {},
|
||||
};
|
||||
}
|
||||
|
||||
const NODE = { id: "n", kind: "pr-create" } as WorkflowIrNode;
|
||||
|
||||
describe("PR node handlers (U3)", () => {
|
||||
let rootDir: string;
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = makeTmpDir();
|
||||
store = new TaskStore(rootDir, join(rootDir, ".fusion-global"));
|
||||
await store.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
store.close();
|
||||
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
function deps(overrides: Partial<PrNodeDeps> = {}): PrNodeDeps {
|
||||
return {
|
||||
getStore: () => store,
|
||||
resolvePrSource: () => SOURCE,
|
||||
createPr: async () => ({ prNumber: 42, prUrl: "https://github.com/owner/repo/pull/42", headOid: "abc123" }),
|
||||
mergePr: async () => ({ status: "merged-requested" }) as PrMergeCallResult,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("pr-create success → entity open with persisted PR fields, value:open", async () => {
|
||||
const handlers = createPrNodeHandlers(deps());
|
||||
const result = await handlers["pr-create"](NODE, ctx());
|
||||
expect(result).toEqual({ outcome: "success", value: "open" });
|
||||
|
||||
const entity = store.getActivePrEntityBySource("task", "T-1");
|
||||
expect(entity?.state).toBe("open");
|
||||
expect(entity?.prNumber).toBe(42);
|
||||
expect(entity?.prUrl).toBe("https://github.com/owner/repo/pull/42");
|
||||
expect(entity?.headOid).toBe("abc123");
|
||||
});
|
||||
|
||||
it("pr-create failure → entity failed + failureReason, value:failed (routable, never throws)", async () => {
|
||||
// Pre-create the entity so we hold its id (the failed row leaves the active set).
|
||||
const seeded = store.ensurePrEntityForSource(SOURCE);
|
||||
const handlers = createPrNodeHandlers(
|
||||
deps({
|
||||
createPr: async () => {
|
||||
throw new Error("boom-create");
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = await handlers["pr-create"](NODE, ctx());
|
||||
// Failure is a ROUTABLE success-outcome with value:"failed", not a throw.
|
||||
expect(result).toEqual({ outcome: "success", value: "failed" });
|
||||
|
||||
// `failed` is terminal, so the entity is no longer "active" — but it exists.
|
||||
expect(store.getActivePrEntityBySource("task", "T-1")).toBeNull();
|
||||
const failed = store.getPrEntity(seeded.id);
|
||||
expect(failed?.state).toBe("failed");
|
||||
expect(failed?.failureReason).toContain("boom-create");
|
||||
expect(failed?.prNumber).toBeUndefined();
|
||||
});
|
||||
|
||||
it("pr-create idempotent re-entry on an already-open entity is a no-op", async () => {
|
||||
const createPr = vi.fn(async () => ({ prNumber: 7, prUrl: "u", headOid: "h" }));
|
||||
const handlers = createPrNodeHandlers(deps({ createPr }));
|
||||
|
||||
const first = await handlers["pr-create"](NODE, ctx());
|
||||
expect(first.value).toBe("open");
|
||||
expect(createPr).toHaveBeenCalledTimes(1);
|
||||
|
||||
const second = await handlers["pr-create"](NODE, ctx());
|
||||
expect(second).toEqual({ outcome: "success", value: "open" });
|
||||
// Re-entry must NOT call GitHub again, and must NOT mint a second entity.
|
||||
expect(createPr).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("pr-merge stale head → value:stale-head, entity stays open, no merged write", async () => {
|
||||
// Seed an open, verified entity.
|
||||
const created = store.ensurePrEntityForSource({ ...SOURCE, state: "open", prNumber: 9 });
|
||||
store.updatePrEntity(created.id, { headOid: "stale" });
|
||||
|
||||
const handlers = createPrNodeHandlers(
|
||||
deps({ mergePr: async () => ({ status: "stale-head" }) as PrMergeCallResult }),
|
||||
);
|
||||
const result = await handlers["pr-merge"]({ id: "m", kind: "pr-merge" } as WorkflowIrNode, ctx());
|
||||
expect(result).toEqual({ outcome: "success", value: "stale-head" });
|
||||
|
||||
const entity = store.getPrEntity(created.id);
|
||||
expect(entity?.state).toBe("open"); // never advanced to merged
|
||||
});
|
||||
|
||||
it("pr-merge success emits merged-requested and does NOT write merged (reconcile corroborates)", async () => {
|
||||
const created = store.ensurePrEntityForSource({ ...SOURCE, state: "open", prNumber: 9 });
|
||||
store.updatePrEntity(created.id, { headOid: "tip" });
|
||||
|
||||
const mergePr = vi.fn(async () => ({ status: "merged-requested" }) as PrMergeCallResult);
|
||||
const handlers = createPrNodeHandlers(deps({ mergePr }));
|
||||
const result = await handlers["pr-merge"]({ id: "m", kind: "pr-merge" } as WorkflowIrNode, ctx());
|
||||
expect(result).toEqual({ outcome: "success", value: "merged-requested" });
|
||||
// expectedHeadOid is passed from the entity's headOid.
|
||||
expect(mergePr).toHaveBeenCalledWith(expect.objectContaining({ expectedHeadOid: "tip" }));
|
||||
|
||||
const entity = store.getPrEntity(created.id);
|
||||
expect(entity?.state).toBe("open"); // node never writes merged
|
||||
});
|
||||
|
||||
it("unverified entity is not merged or responded to — emits a benign outcome", async () => {
|
||||
const created = store.ensurePrEntityForSource({
|
||||
...SOURCE,
|
||||
state: "open",
|
||||
prNumber: 9,
|
||||
unverified: true,
|
||||
});
|
||||
|
||||
const mergePr = vi.fn(async () => ({ status: "merged-requested" }) as PrMergeCallResult);
|
||||
const respond = vi.fn(async () => ({ value: "fixed" as const }));
|
||||
const handlers = createPrNodeHandlers(deps({ mergePr, respond }));
|
||||
|
||||
const merge = await handlers["pr-merge"]({ id: "m", kind: "pr-merge" } as WorkflowIrNode, ctx());
|
||||
expect(merge).toEqual({ outcome: "success", value: "not-actionable" });
|
||||
expect(mergePr).not.toHaveBeenCalled();
|
||||
|
||||
const resp = await handlers["pr-respond"]({ id: "r", kind: "pr-respond" } as WorkflowIrNode, ctx());
|
||||
expect(resp).toEqual({ outcome: "success", value: "not-actionable" });
|
||||
expect(respond).not.toHaveBeenCalled();
|
||||
|
||||
const entity = store.getPrEntity(created.id);
|
||||
expect(entity?.state).toBe("open");
|
||||
});
|
||||
|
||||
it("pr-respond default (no respond dep) is inert: value:disagreed-only + bumps responseRounds", async () => {
|
||||
const created = store.ensurePrEntityForSource({ ...SOURCE, state: "open", prNumber: 9 });
|
||||
expect(store.getPrEntity(created.id)?.responseRounds).toBe(0);
|
||||
|
||||
const handlers = createPrNodeHandlers(deps()); // no respond
|
||||
const result = await handlers["pr-respond"]({ id: "r", kind: "pr-respond" } as WorkflowIrNode, ctx());
|
||||
expect(result).toEqual({ outcome: "success", value: "disagreed-only" });
|
||||
|
||||
expect(store.getPrEntity(created.id)?.responseRounds).toBe(1);
|
||||
});
|
||||
|
||||
it("pr-respond delegates to the injected respond callback", async () => {
|
||||
store.ensurePrEntityForSource({ ...SOURCE, state: "open", prNumber: 9 });
|
||||
const respond = vi.fn(async () => ({ value: "fixed" as const, contextPatch: { k: "v" } }));
|
||||
const handlers = createPrNodeHandlers(deps({ respond }));
|
||||
|
||||
const result = await handlers["pr-respond"]({ id: "r", kind: "pr-respond" } as WorkflowIrNode, ctx());
|
||||
expect(result).toEqual({ outcome: "success", value: "fixed", contextPatch: { k: "v" } });
|
||||
expect(respond).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("unwired pr-* deps fail closed (value:pr-nodes-unwired)", async () => {
|
||||
// createDefaultNodeHandlers with no prNodes dep → the three kinds fail closed.
|
||||
const handlers = createDefaultNodeHandlers(createNoopLegacySeams(), undefined, {});
|
||||
for (const kind of ["pr-create", "pr-respond", "pr-merge"] as const) {
|
||||
const result = await handlers[kind]({ id: kind, kind } as WorkflowIrNode, ctx());
|
||||
expect(result).toEqual({ outcome: "failure", value: "pr-nodes-unwired" });
|
||||
}
|
||||
});
|
||||
|
||||
it("createDefaultNodeHandlers wires real pr-* handlers when prNodes is supplied", async () => {
|
||||
const handlers = createDefaultNodeHandlers(createNoopLegacySeams(), undefined, { prNodes: deps() });
|
||||
const result = await handlers["pr-create"](NODE, ctx());
|
||||
expect(result).toEqual({ outcome: "success", value: "open" });
|
||||
});
|
||||
});
|
||||
@@ -1116,6 +1116,11 @@ export interface TaskExecutorOptions {
|
||||
onAgentText?: (taskId: string, delta: string) => void;
|
||||
onAgentTool?: (taskId: string, toolName: string) => void;
|
||||
autoRecoveryDispatcher?: AutoRecoveryDispatcher;
|
||||
/** PR-entity node deps (U3): assembled `PrNodeDeps` (store + injected GitHub
|
||||
* callbacks) for the `pr-create`/`pr-respond`/`pr-merge` workflow nodes. The
|
||||
* runtime binds the store and threads the CLI-injected ops. Absent → the pr-*
|
||||
* node kinds fail closed. */
|
||||
prNodes?: import("./pr-nodes.js").PrNodeDeps;
|
||||
}
|
||||
|
||||
export class TaskExecutor {
|
||||
@@ -3346,6 +3351,9 @@ export class TaskExecutor {
|
||||
// Step-inversion (KTD-15, U14): code node runner — esbuild compile +
|
||||
// child-process execution with the harness contract.
|
||||
runCode: this.buildCodeNodeRunner(),
|
||||
// PR-entity nodes (U3): pr-create/pr-respond/pr-merge handler deps —
|
||||
// engine-owned store + CLI-injected GitHub callbacks. Absent → fail closed.
|
||||
prNodes: this.options.prNodes,
|
||||
// Step-inversion (KTD-11, U10): worktree isolation + ordered integration +
|
||||
// parallel scheduling. Per-instance worktrees branched off the task's main
|
||||
// branch tip; integration rebases each branch in step order; the projection
|
||||
|
||||
@@ -46,6 +46,20 @@ export {
|
||||
type CodeNodeRunner,
|
||||
type DefaultNodeHandlerDeps,
|
||||
} from "./workflow-node-handlers.js";
|
||||
export {
|
||||
createPrNodeHandlers,
|
||||
buildPrNodeDeps,
|
||||
type PrNodeDeps,
|
||||
type PrNodeGithubOps,
|
||||
type PrNodeStore,
|
||||
type PrSourceDescriptor,
|
||||
type PrCreateCallInput,
|
||||
type PrCreateCallResult,
|
||||
type PrMergeCallInput,
|
||||
type PrMergeCallResult,
|
||||
type PrRespondCallInput,
|
||||
type PrRespondCallResult,
|
||||
} from "./pr-nodes.js";
|
||||
export {
|
||||
WorkflowGraphTaskRunner,
|
||||
type WorkflowGraphRunDisposition,
|
||||
|
||||
326
packages/engine/src/pr-nodes.ts
Normal file
326
packages/engine/src/pr-nodes.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
// PR node handlers for the unified PR-entity review loop (U3).
|
||||
//
|
||||
// Three first-class node kinds whose handlers own the PR side effects and emit
|
||||
// outcomes the graph routes on:
|
||||
// - pr-create : open (or reuse) the PR and write the entity to `open` /`failed`
|
||||
// - pr-respond : run the review-response loop body (U5 fills the real body;
|
||||
// U3 delegates to an injected callback defaulting to a no-op)
|
||||
// - pr-merge : tool-side merge with `expectedHeadOid` (reconcile, U4,
|
||||
// corroborates the terminal `merged` write — the node never does)
|
||||
//
|
||||
// All handlers are idempotent, fast (no indefinite waits — those are holds, U4),
|
||||
// and fail-closed. The engine NEVER imports the dashboard GitHubClient: every
|
||||
// GitHub side effect is an injected callback wired from the CLI composition layer
|
||||
// (mirroring how `createGroupPr`/`syncGroupPr` are wired). That keeps the engine
|
||||
// free of the dashboard dependency (FN-3049: static imports only, no dashboard
|
||||
// client) and unit-testable with fakes.
|
||||
|
||||
import {
|
||||
isPrEntityActionable,
|
||||
type PrEntity,
|
||||
type PrEntityCreateInput,
|
||||
type PrEntityUpdate,
|
||||
type TaskDetail,
|
||||
type WorkflowIrNode,
|
||||
} from "@fusion/core";
|
||||
|
||||
import type { WorkflowNodeHandler } from "./workflow-graph-executor.js";
|
||||
|
||||
/**
|
||||
* The narrow slice of the store the PR node handlers need. Declared structurally
|
||||
* (not as the full `TaskStore`) so the engine stays decoupled from the concrete
|
||||
* store and the handlers stay trivially fakeable in tests.
|
||||
*/
|
||||
export interface PrNodeStore {
|
||||
/** Create-or-reuse the single non-terminal entity for a source (AE6 idempotency). */
|
||||
ensurePrEntityForSource(input: PrEntityCreateInput): PrEntity;
|
||||
getPrEntity(id: string): PrEntity | null;
|
||||
getActivePrEntityBySource(sourceType: PrEntity["sourceType"], sourceId: string): PrEntity | null;
|
||||
updatePrEntity(id: string, patch: PrEntityUpdate): PrEntity;
|
||||
}
|
||||
|
||||
/** Identity of the PR an entity is created for, resolved from the task + node. */
|
||||
export interface PrSourceDescriptor extends PrEntityCreateInput {}
|
||||
|
||||
/** Input for the injected `createPr` callback (the dashboard GitHubClient wrapper). */
|
||||
export interface PrCreateCallInput {
|
||||
task: TaskDetail;
|
||||
node: WorkflowIrNode;
|
||||
entity: PrEntity;
|
||||
}
|
||||
|
||||
/** Result of a successful PR creation — the GitHub-mirror fields the node persists. */
|
||||
export interface PrCreateCallResult {
|
||||
prNumber: number;
|
||||
prUrl: string;
|
||||
/** Resolved head commit OID, persisted so `pr-merge` can pass `expectedHeadOid`. */
|
||||
headOid?: string;
|
||||
}
|
||||
|
||||
/** Input for the injected `mergePr` callback. */
|
||||
export interface PrMergeCallInput {
|
||||
task: TaskDetail;
|
||||
node: WorkflowIrNode;
|
||||
entity: PrEntity;
|
||||
/** The head OID the merge is gated on (defeats the push/merge race, U2/U6). */
|
||||
expectedHeadOid?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated result of the injected `mergePr` callback. The callback wraps the
|
||||
* dashboard `mergePr`, which throws `PrStaleHeadError` on a head-moved race; the
|
||||
* wrapper catches it and returns `{ status: "stale-head" }` so the engine never
|
||||
* imports the dashboard error class. Any other failure should be thrown so the
|
||||
* handler classifies it as a benign retryable outcome.
|
||||
*/
|
||||
export type PrMergeCallResult =
|
||||
| { status: "merged-requested" }
|
||||
| { status: "stale-head" };
|
||||
|
||||
/** Input for the injected `respond` callback (U5 implements the real body). */
|
||||
export interface PrRespondCallInput {
|
||||
task: TaskDetail;
|
||||
node: WorkflowIrNode;
|
||||
entity: PrEntity;
|
||||
context: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the injected `respond` callback. `outcome` is the routing value the
|
||||
* `pr-respond` node emits (drives the bounded-rework edge back to await-review):
|
||||
* - "fixed" : a fix was pushed; loop back to await-review
|
||||
* - "disagreed-only" : nothing actionable / all threads disagreed; leave open
|
||||
*/
|
||||
export interface PrRespondCallResult {
|
||||
value: "fixed" | "disagreed-only";
|
||||
contextPatch?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependencies the PR node handlers close over. All injected from the CLI
|
||||
* composition layer where importing the dashboard GitHubClient IS allowed; the
|
||||
* engine receives only plain callbacks + a structural store accessor.
|
||||
*/
|
||||
export interface PrNodeDeps {
|
||||
/** Structural store accessor (the engine already owns the store instance). */
|
||||
getStore(): PrNodeStore;
|
||||
/**
|
||||
* Resolve the PR source identity for a `pr-create` node from the task + node.
|
||||
* The CLI wiring derives sourceType/sourceId (task id or branch-group id),
|
||||
* repo, and head/base branch from the task's branch-naming + tracking config.
|
||||
*/
|
||||
resolvePrSource(task: TaskDetail, node: WorkflowIrNode): Promise<PrSourceDescriptor> | PrSourceDescriptor;
|
||||
/** Open the PR on GitHub. Throws on failure (the node records `failed`). */
|
||||
createPr(input: PrCreateCallInput): Promise<PrCreateCallResult>;
|
||||
/** Merge the PR tool-side with `expectedHeadOid`. Returns a discriminated result. */
|
||||
mergePr(input: PrMergeCallInput): Promise<PrMergeCallResult>;
|
||||
/**
|
||||
* Run the review-response body (U5). Defaults to a no-op returning
|
||||
* `disagreed-only` when omitted, so U3 ships a routable-but-inert pr-respond.
|
||||
*/
|
||||
respond?: (input: PrRespondCallInput) => Promise<PrRespondCallResult>;
|
||||
/** Optional audit sink, called with a stable reason on every routable failure. */
|
||||
audit?: (reason: string, detail: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The CLI-injected slice of {@link PrNodeDeps}: only the GitHub side-effect
|
||||
* callbacks (which close over the dashboard `GitHubClient`) plus the source
|
||||
* resolver and audit sink. The engine binds `getStore` itself (it owns the store
|
||||
* instance) via {@link buildPrNodeDeps}, so the CLI layer never needs a store
|
||||
* reference. Mirrors how `createGroupPr`/`syncGroupPr` are injected as plain
|
||||
* callbacks from the CLI composition layer.
|
||||
*/
|
||||
export interface PrNodeGithubOps {
|
||||
resolvePrSource: PrNodeDeps["resolvePrSource"];
|
||||
createPr: PrNodeDeps["createPr"];
|
||||
mergePr: PrNodeDeps["mergePr"];
|
||||
respond?: PrNodeDeps["respond"];
|
||||
audit?: PrNodeDeps["audit"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble full {@link PrNodeDeps} from the engine-owned store + the CLI-injected
|
||||
* GitHub ops. Used by the runtime/executor wiring so the CLI layer stays free of
|
||||
* any store reference and the engine never imports the dashboard client.
|
||||
*/
|
||||
export function buildPrNodeDeps(getStore: () => PrNodeStore, ops: PrNodeGithubOps): PrNodeDeps {
|
||||
return {
|
||||
getStore,
|
||||
resolvePrSource: ops.resolvePrSource,
|
||||
createPr: ops.createPr,
|
||||
mergePr: ops.mergePr,
|
||||
respond: ops.respond,
|
||||
audit: ops.audit,
|
||||
};
|
||||
}
|
||||
|
||||
function classifyError(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the three PR node handlers from injected deps. Mirrors the seam-injection
|
||||
* pattern (`createStepReviewHandler` / `createParseStepsHandler`): the engine
|
||||
* graph layer stays engine-agnostic and unit-testable with fakes.
|
||||
*/
|
||||
export function createPrNodeHandlers(deps: PrNodeDeps): Record<
|
||||
"pr-create" | "pr-respond" | "pr-merge",
|
||||
WorkflowNodeHandler
|
||||
> {
|
||||
const audit = (reason: string, detail: string): void => {
|
||||
try {
|
||||
deps.audit?.(reason, detail);
|
||||
} catch {
|
||||
// Audit must never affect the run.
|
||||
}
|
||||
};
|
||||
|
||||
// ── pr-create ──────────────────────────────────────────────────────────────
|
||||
// Ensure the entity in `creating`, call GitHub, flip to `open` on success or
|
||||
// `failed` (routable, NOT a thrown error) on failure. Re-entry on an already
|
||||
// open entity is a no-op emitting value:"open" (AE6 create-or-reuse idempotency).
|
||||
const prCreate: WorkflowNodeHandler = async (node, ctx) => {
|
||||
const store = deps.getStore();
|
||||
|
||||
let source: PrSourceDescriptor;
|
||||
try {
|
||||
source = await deps.resolvePrSource(ctx.task, node);
|
||||
} catch (err) {
|
||||
const detail = `pr-create node '${node.id}' could not resolve PR source: ${classifyError(err)}`;
|
||||
audit("pr-create-source-error", detail);
|
||||
// No entity yet → fail closed with a routable outcome.
|
||||
return { outcome: "failure", value: "source-error" };
|
||||
}
|
||||
|
||||
// Create-or-reuse the single live entity (the store enforces the partial
|
||||
// unique index, so re-entry never mints a second entity).
|
||||
const entity = store.ensurePrEntityForSource({
|
||||
...source,
|
||||
state: source.state ?? "creating",
|
||||
});
|
||||
|
||||
// Idempotent re-entry: an already-open entity with a persisted PR is a no-op.
|
||||
if (entity.state === "open" && entity.prNumber != null) {
|
||||
return { outcome: "success", value: "open" };
|
||||
}
|
||||
|
||||
// Ensure the row is in `creating` before the side effect (so a crash mid-flight
|
||||
// leaves a recoverable state, not a stale `failed`).
|
||||
const creating = entity.state === "creating" ? entity : store.updatePrEntity(entity.id, { state: "creating" });
|
||||
|
||||
let created: PrCreateCallResult;
|
||||
try {
|
||||
created = await deps.createPr({ task: ctx.task, node, entity: creating });
|
||||
} catch (err) {
|
||||
const reason = classifyError(err);
|
||||
audit("pr-create-failed", `pr-create node '${node.id}' creation failed: ${reason}`);
|
||||
// Failure is a ROUTABLE outcome — the graph routes on value:"failed". Record
|
||||
// the classified reason and the failed state; never throw.
|
||||
store.updatePrEntity(creating.id, { state: "failed", failureReason: reason });
|
||||
return { outcome: "success", value: "failed" };
|
||||
}
|
||||
|
||||
store.updatePrEntity(creating.id, {
|
||||
state: "open",
|
||||
prNumber: created.prNumber,
|
||||
prUrl: created.prUrl,
|
||||
headOid: created.headOid ?? null,
|
||||
});
|
||||
return { outcome: "success", value: "open" };
|
||||
};
|
||||
|
||||
// ── pr-merge ───────────────────────────────────────────────────────────────
|
||||
// Merge tool-side with `expectedHeadOid` from the entity. Does NOT write the
|
||||
// terminal `merged` state — the reconcile (U4) corroborates that from GitHub.
|
||||
// A stale-head race emits value:"stale-head" leaving the entity open; a clean
|
||||
// merge request emits value:"merged-requested".
|
||||
const prMerge: WorkflowNodeHandler = async (node, ctx) => {
|
||||
const store = deps.getStore();
|
||||
const entity = store.getActivePrEntityBySource("task", ctx.task.id)
|
||||
?? store.getActivePrEntityBySource("branch-group", ctx.task.id);
|
||||
|
||||
if (!entity) {
|
||||
audit("pr-merge-no-entity", `pr-merge node '${node.id}' found no live PR entity for task ${ctx.task.id}`);
|
||||
return { outcome: "failure", value: "no-entity" };
|
||||
}
|
||||
|
||||
// Unverified entities (imported legacy state GitHub has not corroborated) are
|
||||
// a hard gate (R19): never merge on stale state — emit a benign outcome.
|
||||
if (!isPrEntityActionable(entity)) {
|
||||
audit("pr-merge-not-actionable", `pr-merge node '${node.id}' entity ${entity.id} not actionable (unverified/terminal)`);
|
||||
return { outcome: "success", value: "not-actionable" };
|
||||
}
|
||||
|
||||
let result: PrMergeCallResult;
|
||||
try {
|
||||
result = await deps.mergePr({
|
||||
task: ctx.task,
|
||||
node,
|
||||
entity,
|
||||
expectedHeadOid: entity.headOid,
|
||||
});
|
||||
} catch (err) {
|
||||
// A non-stale merge error is benign/retryable — never throw out of the
|
||||
// handler, and never write `merged`. Route a routable failure value.
|
||||
const reason = classifyError(err);
|
||||
audit("pr-merge-error", `pr-merge node '${node.id}' merge failed: ${reason}`);
|
||||
return { outcome: "failure", value: "merge-error" };
|
||||
}
|
||||
|
||||
if (result.status === "stale-head") {
|
||||
// The head moved since we read `expectedHeadOid`; leave the entity open so a
|
||||
// re-evaluation merges against the new head. Never write `merged`.
|
||||
return { outcome: "success", value: "stale-head" };
|
||||
}
|
||||
|
||||
// Merge requested cleanly. Do NOT write `merged` here — reconcile corroborates.
|
||||
return { outcome: "success", value: "merged-requested" };
|
||||
};
|
||||
|
||||
// ── pr-respond ─────────────────────────────────────────────────────────────
|
||||
// Delegate to the injected `respond` callback (U5 implements the real body).
|
||||
// Defaults to a no-op returning value:"disagreed-only". Increments the entity's
|
||||
// responseRounds (the R8 iteration-cap counter, survives restart).
|
||||
const prRespond: WorkflowNodeHandler = async (node, ctx) => {
|
||||
const store = deps.getStore();
|
||||
const entity = store.getActivePrEntityBySource("task", ctx.task.id)
|
||||
?? store.getActivePrEntityBySource("branch-group", ctx.task.id);
|
||||
|
||||
if (!entity) {
|
||||
audit("pr-respond-no-entity", `pr-respond node '${node.id}' found no live PR entity for task ${ctx.task.id}`);
|
||||
return { outcome: "failure", value: "no-entity" };
|
||||
}
|
||||
|
||||
// Unverified/terminal entities are not responded to (R19 hard gate).
|
||||
if (!isPrEntityActionable(entity)) {
|
||||
audit("pr-respond-not-actionable", `pr-respond node '${node.id}' entity ${entity.id} not actionable (unverified/terminal)`);
|
||||
return { outcome: "success", value: "not-actionable" };
|
||||
}
|
||||
|
||||
// Bump the rework-cycle counter (R8 cap backing; persisted).
|
||||
store.updatePrEntity(entity.id, { responseRounds: entity.responseRounds + 1 });
|
||||
|
||||
if (!deps.respond) {
|
||||
// U3 default: inert but routable. U5 wires the real review-response run.
|
||||
return { outcome: "success", value: "disagreed-only" };
|
||||
}
|
||||
|
||||
let result: PrRespondCallResult;
|
||||
try {
|
||||
result = await deps.respond({ task: ctx.task, node, entity, context: ctx.context });
|
||||
} catch (err) {
|
||||
const reason = classifyError(err);
|
||||
audit("pr-respond-error", `pr-respond node '${node.id}' response run failed: ${reason}`);
|
||||
return { outcome: "failure", value: "respond-error" };
|
||||
}
|
||||
|
||||
return { outcome: "success", value: result.value, contextPatch: result.contextPatch };
|
||||
};
|
||||
|
||||
return {
|
||||
"pr-create": prCreate,
|
||||
"pr-respond": prRespond,
|
||||
"pr-merge": prMerge,
|
||||
};
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export interface EngineManagerOptions {
|
||||
processPullRequestMerge?: ProjectEngineOptions["processPullRequestMerge"];
|
||||
createGroupPr?: ProjectEngineOptions["createGroupPr"];
|
||||
syncGroupPr?: ProjectEngineOptions["syncGroupPr"];
|
||||
prNodeGithubOps?: ProjectEngineOptions["prNodeGithubOps"];
|
||||
getTaskMergeBlocker?: ProjectEngineOptions["getTaskMergeBlocker"];
|
||||
onInsightRunProcessed?: ProjectEngineOptions["onInsightRunProcessed"];
|
||||
}
|
||||
@@ -485,6 +486,7 @@ export class ProjectEngineManager {
|
||||
processPullRequestMerge: this.options.processPullRequestMerge,
|
||||
createGroupPr: this.options.createGroupPr,
|
||||
syncGroupPr: this.options.syncGroupPr,
|
||||
prNodeGithubOps: this.options.prNodeGithubOps,
|
||||
getTaskMergeBlocker: this.options.getTaskMergeBlocker,
|
||||
onInsightRunProcessed: this.options.onInsightRunProcessed,
|
||||
...overrides,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
|
||||
import type { WorktreePool } from "./worktree-pool.js";
|
||||
import type { ProjectRuntimeConfig } from "./project-runtime.js";
|
||||
import { PrMonitor } from "./pr-monitor.js";
|
||||
import type { PrNodeGithubOps } from "./pr-nodes.js";
|
||||
import { PrCommentHandler } from "./pr-comment-handler.js";
|
||||
import { NtfyNotifier } from "./notifier.js";
|
||||
import { NotificationService, OAuthAlertStateStore, OAuthExpiryMonitor, OAuthValidityLogger } from "./notification/index.js";
|
||||
@@ -220,6 +221,15 @@ export interface ProjectEngineOptions {
|
||||
* the PR body.
|
||||
*/
|
||||
syncGroupPr?: SyncGroupPrFn;
|
||||
/**
|
||||
* PR-entity node GitHub ops (U3): the injected `createPr`/`mergePr`/`respond`
|
||||
* callbacks (+ source resolver + audit) that back the `pr-create`/`pr-respond`/
|
||||
* `pr-merge` workflow nodes. Injected from the CLI layer because they close
|
||||
* over the dashboard `GitHubClient`; the engine must not statically import it
|
||||
* (FN-3049). Mirrors `createGroupPr`/`syncGroupPr`. When absent, the pr-* node
|
||||
* kinds fail closed (value:"pr-nodes-unwired").
|
||||
*/
|
||||
prNodeGithubOps?: PrNodeGithubOps;
|
||||
/**
|
||||
* Returns the merge blocker reason for a task, or null/undefined if
|
||||
* the task is eligible for merge. Imported from @fusion/core.
|
||||
@@ -364,10 +374,14 @@ export class ProjectEngine {
|
||||
centralCore: CentralCore,
|
||||
private options: ProjectEngineOptions = {},
|
||||
) {
|
||||
// Pass through externalTaskStore to the runtime config if provided
|
||||
const runtimeConfig: ProjectRuntimeConfig = options.externalTaskStore
|
||||
? { ...config, externalTaskStore: options.externalTaskStore }
|
||||
: config;
|
||||
// Pass through externalTaskStore + PR node GitHub ops (U3) to the runtime
|
||||
// config. The runtime binds the engine-owned store and hands the assembled
|
||||
// PrNodeDeps to the executor's workflow-graph runner.
|
||||
const runtimeConfig: ProjectRuntimeConfig = {
|
||||
...config,
|
||||
...(options.externalTaskStore ? { externalTaskStore: options.externalTaskStore } : {}),
|
||||
...(options.prNodeGithubOps ? { prNodeGithubOps: options.prNodeGithubOps } : {}),
|
||||
};
|
||||
this.runtime = new InProcessRuntime(runtimeConfig, centralCore);
|
||||
// Let the runtime's SelfHealingManager re-enqueue tasks directly into our
|
||||
// auto-merge queue when it clears a stale `merging` status, instead of
|
||||
|
||||
@@ -55,6 +55,14 @@ export interface ProjectRuntimeConfig {
|
||||
* Useful when the caller (e.g. dashboard.ts) owns and watches the store.
|
||||
*/
|
||||
externalTaskStore?: TaskStore;
|
||||
/**
|
||||
* PR-entity node GitHub ops (U3): the injected `createPr`/`mergePr`/`respond`
|
||||
* callbacks (+ source resolver + audit) for the `pr-create`/`pr-respond`/
|
||||
* `pr-merge` workflow nodes. Threaded from the CLI layer; the runtime binds the
|
||||
* engine-owned store and hands the assembled deps to the executor. Absent → the
|
||||
* pr-* node kinds fail closed.
|
||||
*/
|
||||
prNodeGithubOps?: import("./pr-nodes.js").PrNodeGithubOps;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Scheduler } from "../scheduler.js";
|
||||
import type { PrMonitor, PrComment } from "../pr-monitor.js";
|
||||
import type { PrInfo } from "@fusion/core";
|
||||
import { TaskExecutor, type TaskExecutorOptions } from "../executor.js";
|
||||
import { buildPrNodeDeps } from "../pr-nodes.js";
|
||||
import { WorktreePool, isGitRepository, type PoolInvariantViolation } from "../worktree-pool.js";
|
||||
import { AgentSemaphore } from "../concurrency.js";
|
||||
import { HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "../agent-heartbeat.js";
|
||||
@@ -442,6 +443,7 @@ export class InProcessRuntime
|
||||
}
|
||||
}
|
||||
|
||||
const prNodeGithubOps = this.config.prNodeGithubOps;
|
||||
const executorOptions: TaskExecutorOptions = {
|
||||
semaphore: this.globalSemaphore,
|
||||
pool: this.worktreePool,
|
||||
@@ -451,6 +453,13 @@ export class InProcessRuntime
|
||||
messageStore: this.messageStore,
|
||||
missionStore,
|
||||
reflectionService,
|
||||
// PR-entity nodes (U3): assemble the handler deps from the CLI-injected
|
||||
// GitHub ops (createPr/mergePr/respond) + the engine-owned store. The CLI
|
||||
// layer never holds a store reference; the engine binds it here. Absent
|
||||
// ops → undefined → the pr-* node kinds fail closed.
|
||||
prNodes: prNodeGithubOps
|
||||
? buildPrNodeDeps(() => this.taskStore, prNodeGithubOps)
|
||||
: undefined,
|
||||
onSliceComplete: (slice) => {
|
||||
void this.scheduler.onSliceComplete(slice);
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type WorkflowCustomNodeRunner,
|
||||
type WorkflowLegacySeams,
|
||||
} from "./workflow-node-handlers.js";
|
||||
import type { PrNodeDeps } from "./pr-nodes.js";
|
||||
import {
|
||||
runSplitJoin,
|
||||
type BranchEnvironment,
|
||||
@@ -56,6 +57,9 @@ export interface WorkflowGraphExecutorDeps {
|
||||
/** Step-inversion (U14, KTD-15): runner for the `code` node (esbuild compile +
|
||||
* child-process execution). Absent → a code node fails cleanly. */
|
||||
runCode?: CodeNodeRunner;
|
||||
/** PR-entity nodes (U3): deps for `pr-create`/`pr-respond`/`pr-merge` (injected
|
||||
* GitHub callbacks + store accessor). Absent → the pr-* kinds fail cleanly. */
|
||||
prNodes?: PrNodeDeps;
|
||||
maxRetriesPerNode?: number;
|
||||
/** Per-branch run-state persistence (U13). Optional — fully in-memory without it. */
|
||||
branchPersistence?: WorkflowBranchPersistence;
|
||||
@@ -145,6 +149,7 @@ export class WorkflowGraphExecutor {
|
||||
...createDefaultNodeHandlers(deps.seams ?? createNoopLegacySeams(), deps.runCustomNode, {
|
||||
parseSteps: deps.parseStepsDeps,
|
||||
runCode: deps.runCode,
|
||||
prNodes: deps.prNodes,
|
||||
}),
|
||||
...(deps.handlers ?? {}),
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
WorkflowBranchSemaphore,
|
||||
} from "./workflow-graph-branches.js";
|
||||
import type { ForeachEnvironment, WorkflowStepInstancePersistence } from "./workflow-graph-foreach.js";
|
||||
import type { PrNodeDeps } from "./pr-nodes.js";
|
||||
// (Both types are also used as values in the side-effect tracking wrappers below.)
|
||||
|
||||
/**
|
||||
@@ -69,6 +70,9 @@ export interface WorkflowGraphTaskRunnerDeps {
|
||||
/** Step-inversion (U14, KTD-15): `code` node runner. Additive; a workflow with
|
||||
* no code node never invokes it. */
|
||||
runCode?: CodeNodeRunner;
|
||||
/** PR-entity nodes (U3): deps for `pr-create`/`pr-respond`/`pr-merge`. Additive;
|
||||
* a workflow with no pr-* node never invokes them; absent → they fail closed. */
|
||||
prNodes?: PrNodeDeps;
|
||||
/** Step-inversion (KTD-11, U10): worktree-isolation + parallel-scheduling deps.
|
||||
* Additive; a shared-isolation foreach never invokes them. */
|
||||
allocateInstanceWorktree?: ForeachEnvironment["allocateInstanceWorktree"];
|
||||
@@ -192,6 +196,7 @@ export class WorkflowGraphTaskRunner {
|
||||
onReworkReset: this.deps.onReworkReset,
|
||||
parseStepsDeps: this.deps.parseStepsDeps,
|
||||
runCode: this.deps.runCode,
|
||||
prNodes: this.deps.prNodes,
|
||||
// Step-inversion (KTD-11, U10): worktree isolation + parallel scheduling.
|
||||
allocateInstanceWorktree: this.deps.allocateInstanceWorktree,
|
||||
resolveIntegrationBase: this.deps.resolveIntegrationBase,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { WorkflowIrError, getStepParser } from "@fusion/core";
|
||||
import type { TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core";
|
||||
|
||||
import type { WorkflowNodeHandler, WorkflowNodeResult } from "./workflow-graph-executor.js";
|
||||
import { createPrNodeHandlers, type PrNodeDeps } from "./pr-nodes.js";
|
||||
|
||||
export type WorkflowSeamName = "planning" | "execute" | "review" | "merge" | "schedule" | "step-execute";
|
||||
|
||||
@@ -517,6 +518,8 @@ export interface DefaultNodeHandlerDeps {
|
||||
parseSteps?: ParseStepsHandlerDeps;
|
||||
/** code node runner (U14). When absent, a code node fails cleanly. */
|
||||
runCode?: CodeNodeRunner;
|
||||
/** PR node deps (U3). When absent, the three pr-* kinds fail cleanly. */
|
||||
prNodes?: PrNodeDeps;
|
||||
}
|
||||
|
||||
export function createDefaultNodeHandlers(
|
||||
@@ -524,7 +527,15 @@ export function createDefaultNodeHandlers(
|
||||
runCustomNode?: WorkflowCustomNodeRunner,
|
||||
deps?: DefaultNodeHandlerDeps,
|
||||
): Record<
|
||||
"prompt" | "script" | "gate" | "step-review" | "parse-steps" | "code",
|
||||
| "prompt"
|
||||
| "script"
|
||||
| "gate"
|
||||
| "step-review"
|
||||
| "parse-steps"
|
||||
| "code"
|
||||
| "pr-create"
|
||||
| "pr-respond"
|
||||
| "pr-merge",
|
||||
WorkflowNodeHandler
|
||||
> {
|
||||
const promptLike = createPromptLikeHandler(seams, runCustomNode);
|
||||
@@ -533,6 +544,16 @@ export function createDefaultNodeHandlers(
|
||||
const parseSteps: WorkflowNodeHandler = deps?.parseSteps
|
||||
? createParseStepsHandler(deps.parseSteps)
|
||||
: async () => ({ outcome: "failure", value: "parse-steps-unwired" });
|
||||
// PR nodes without deps fail closed (mirrors parse-steps): a pr-* node reached
|
||||
// without GitHub wiring must NOT silently succeed — it would route an
|
||||
// unverified PR side effect forward.
|
||||
const prNodes: Record<"pr-create" | "pr-respond" | "pr-merge", WorkflowNodeHandler> = deps?.prNodes
|
||||
? createPrNodeHandlers(deps.prNodes)
|
||||
: {
|
||||
"pr-create": async () => ({ outcome: "failure", value: "pr-nodes-unwired" }),
|
||||
"pr-respond": async () => ({ outcome: "failure", value: "pr-nodes-unwired" }),
|
||||
"pr-merge": async () => ({ outcome: "failure", value: "pr-nodes-unwired" }),
|
||||
};
|
||||
return {
|
||||
prompt: promptLike,
|
||||
script: promptLike,
|
||||
@@ -540,6 +561,7 @@ export function createDefaultNodeHandlers(
|
||||
"step-review": createStepReviewHandler(seams),
|
||||
"parse-steps": parseSteps,
|
||||
code: createCodeNodeHandler(deps?.runCode),
|
||||
...prNodes,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user