FN-8795: persist structured workflow review findings
Persist normalized actionable findings from review workflow nodes. - Normalize bounded finding IDs, text, locations, and severities in workflow results. - Surface individual findings for Review-tab selection and same-task revision. - Preserve findings through workflow retries and document the advisory contract. Files changed: .changeset/fn-8795-structured-review-findings.md | 7 +++ docs/dashboard-guide.md | 2 +- docs/workflow-steps.md | 4 +- .../src/__tests__/workflow-step-results.test.ts | 32 +++++++++++++- packages/core/src/index.gate.ts | 4 ++ packages/core/src/index.ts | 6 ++- packages/core/src/types.ts | 4 ++ packages/core/src/types/task/task-review.ts | 5 +++ packages/core/src/types/workflow/workflow-steps.ts | 20 +++++++++ .../core/src/workflows/workflow-step-results.ts | 50 +++++++++++++++++++++- .../dashboard/app/components/TaskReviewTab.tsx | 12 +++++- .../src/routes/register-task-workflow-routes.ts | 26 ++++++++++- .../workflow-malformed-verdict-gate.test.ts | 12 ++++++ packages/engine/src/executor.ts | 46 +++++++++++++++++--- .../src/workflows/workflow-graph-executor.ts | 11 +++++ 15 files changed, 227 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-8795 Fusion-Task-Lineage: 09003b01-3f9a-4387-b6a7-f29066ce52f6 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8795-structured-review-findings.md
Normal file
7
.changeset/fn-8795-structured-review-findings.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Select individual structured workflow review findings for same-task revision.
|
||||
category: feature
|
||||
dev: Review-kind results persist normalized finding IDs, location, and severity in existing JSONB history.
|
||||
@@ -2407,4 +2407,4 @@ Todo Lists is an optional first-party plugin. Enable `fusion-plugin-todos` for a
|
||||
|
||||
## Workflow direct-review items
|
||||
|
||||
The Review tab shows a custom workflow result only when its selected workflow declares the exact top-level node and result source, the result explicitly snapshots `reviewKind: "plan"` or `"code"`, it is current, has `passed` or `failed` status, is not bypassed or superseded, and contains nonblank output or notes. Pending, skipped, historical prior attempts, blank results, and records without that declared top-level identity (including template instances) are not selectable or addressable. Node-ID punctuation alone does not identify a template instance. Existing historical `plan-review` and `code-review` results retain narrow compatibility; Fusion does not infer or backfill custom review meaning from names, verdicts, prose, or gate settings.
|
||||
The Review tab shows a custom workflow result only when its selected workflow declares the exact top-level node and result source, the result explicitly snapshots `reviewKind: "plan"` or `"code"`, and it is current, terminal, and not bypassed or superseded. Each persisted structured finding becomes one independently selectable reviewer-agent item with its server-owned identity, optional location, and severity. Selecting a subset sends only those canonical items for revision; client-supplied text and metadata are ignored. A current result without findings retains one prose/notes fallback item. Pending, skipped, historical prior attempts, blank results, and records without that declared top-level identity (including template instances) are not selectable or addressable. Node-ID punctuation alone does not identify a template instance. Existing historical `plan-review` and `code-review` results retain narrow compatibility; Fusion does not infer or backfill custom review meaning from names, verdicts, prose, or gate settings.
|
||||
|
||||
@@ -957,4 +957,6 @@ A top-level `prompt`, `gate`, `script`, or `optional-group` node may set `config
|
||||
{ "id": "architecture-review", "kind": "prompt", "config": { "name": "Architecture review", "reviewKind": "code", "prompt": "Review the proposed architecture." } }
|
||||
```
|
||||
|
||||
When a marked supported node runs, its pending and terminal workflow-step result snapshots the declared value. Omission means the node is **not** a direct review, regardless of its ID, label, verdict, output prose, phase, or gate mode. Markers are rejected on foreach and loop templates and optional-group source/template nodes: those executions do not yet have an instance-safe current-result or Review-tab address contract.
|
||||
When a marked supported node runs, its pending and terminal workflow-step result snapshots the declared value. Review-kind prompt and script output may end with one JSON object containing `verdict`, `notes`, and `findings`. Each finding has a stable `id`, actionable `title` and `body`, plus optional `filePath`, positive `line`, and `low`/`medium`/`high`/`critical` severity. Fusion trims and bounds strings, drops malformed entries, and suffixes duplicate IDs; it never splits Markdown prose into findings.
|
||||
|
||||
Findings persist through both ordinary-node and optional-group result writers in the existing JSONB result. A retry moves the replaced result (including its findings) into bounded single-level `priorAttempts`; only current findings are actionable. Findings are advisory metadata: they do not alter verdict parsing, gate status, merge blocking, recovery, or retry routing. A row without findings keeps its one prose/notes fallback item. Omission means the node is **not** a direct review, regardless of its ID, label, verdict, output prose, phase, or gate mode. Markers are rejected on foreach and loop templates and optional-group source/template nodes: those executions do not yet have an instance-safe current-result or Review-tab address contract.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { upsertWorkflowStepResult, MAX_WORKFLOW_STEP_PRIOR_ATTEMPTS } from "../workflows/workflow-step-results.js";
|
||||
import { normalizeWorkflowReviewFindings, upsertWorkflowStepResult, MAX_WORKFLOW_STEP_PRIOR_ATTEMPTS } from "../workflows/workflow-step-results.js";
|
||||
import type { WorkflowStepResult } from "../types.js";
|
||||
|
||||
function makeResult(overrides: Partial<WorkflowStepResult> = {}): WorkflowStepResult {
|
||||
@@ -11,6 +11,28 @@ function makeResult(overrides: Partial<WorkflowStepResult> = {}): WorkflowStepRe
|
||||
};
|
||||
}
|
||||
|
||||
describe("normalizeWorkflowReviewFindings", () => {
|
||||
it("normalizes bounded populated findings with stable collision-free ids", () => {
|
||||
expect(normalizeWorkflowReviewFindings([
|
||||
{ id: " issue ", title: " Title ", body: " Body ", filePath: " src/a.ts ", line: 4.8, severity: "high" },
|
||||
{ id: "issue", title: "Second", body: "Action", line: -1, severity: "unknown" },
|
||||
])).toEqual([
|
||||
{ id: "issue", title: "Title", body: "Body", filePath: "src/a.ts", line: 4, severity: "high" },
|
||||
{ id: "issue-2", title: "Second", body: "Action" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops malformed, empty, and oversized entries without fabricating findings", () => {
|
||||
expect(normalizeWorkflowReviewFindings([
|
||||
null,
|
||||
{ title: "", body: "body" },
|
||||
{ title: "title", body: "" },
|
||||
{ title: "x".repeat(241), body: "body" },
|
||||
{ title: "title", body: "x".repeat(4001) },
|
||||
])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertWorkflowStepResult", () => {
|
||||
it("appends when the step id is absent", () => {
|
||||
const result = makeResult({ startedAt: "T1" });
|
||||
@@ -43,6 +65,14 @@ describe("upsertWorkflowStepResult", () => {
|
||||
expect(next[0].priorAttempts?.[0].startedAt).toBe("T1");
|
||||
});
|
||||
|
||||
it("keeps replaced findings in read-only history while new findings remain current", () => {
|
||||
const attempt1 = makeResult({ startedAt: "T1", findings: [{ id: "old", title: "Old", body: "Old body" }] });
|
||||
const attempt2 = makeResult({ startedAt: "T2", findings: [{ id: "new", title: "New", body: "New body" }] });
|
||||
const next = upsertWorkflowStepResult([attempt1], attempt2);
|
||||
expect(next[0].findings?.map((finding) => finding.id)).toEqual(["new"]);
|
||||
expect(next[0].priorAttempts?.[0].findings?.map((finding) => finding.id)).toEqual(["old"]);
|
||||
});
|
||||
|
||||
it("snapshots a replaced advisory_failure entry", () => {
|
||||
const attempt1 = makeResult({ startedAt: "T1", status: "advisory_failure", output: "advisory-1" });
|
||||
const attempt2 = makeResult({ startedAt: "T2", status: "passed", output: "attempt-2" });
|
||||
|
||||
@@ -2247,6 +2247,10 @@ Keep this gate-safe barrel's workflow-step-results re-exports in SYNC with the m
|
||||
*/
|
||||
export {
|
||||
upsertWorkflowStepResult,
|
||||
normalizeWorkflowReviewFindings,
|
||||
isWorkflowReviewFindingSeverity,
|
||||
MAX_WORKFLOW_REVIEW_FINDINGS,
|
||||
WORKFLOW_REVIEW_FINDING_SEVERITIES,
|
||||
MAX_WORKFLOW_STEP_PRIOR_ATTEMPTS,
|
||||
PLAN_REVIEW_LEASE_STALENESS_MS,
|
||||
classifyReviewLease,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -264,6 +264,8 @@ import type {
|
||||
WorkflowStepGateMode,
|
||||
WorkflowStepPhase,
|
||||
WorkflowReviewKind,
|
||||
WorkflowReviewFindingSeverity,
|
||||
WorkflowReviewFinding,
|
||||
WorkflowStep,
|
||||
NtfyNotificationEvent,
|
||||
NotificationEvent,
|
||||
@@ -284,6 +286,8 @@ export type {
|
||||
WorkflowStepGateMode,
|
||||
WorkflowStepPhase,
|
||||
WorkflowReviewKind,
|
||||
WorkflowReviewFindingSeverity,
|
||||
WorkflowReviewFinding,
|
||||
WorkflowStep,
|
||||
NtfyNotificationEvent,
|
||||
NotificationEvent,
|
||||
|
||||
@@ -9,6 +9,7 @@ export type TaskReviewDecision = "approved" | "changes-requested" | "commented"
|
||||
export type TaskReviewVerdict = "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE" | "RETHINK" | "UNAVAILABLE";
|
||||
export type TaskReviewerType = "plan" | "code";
|
||||
export type TaskReviewItemStatus = "queued" | "in-progress" | "addressed" | "failed";
|
||||
export type TaskReviewFindingSeverity = "low" | "medium" | "high" | "critical";
|
||||
|
||||
export interface LegacyTaskReviewItem {
|
||||
id: string;
|
||||
@@ -79,6 +80,7 @@ export interface TaskReviewStateItem {
|
||||
threadId?: string;
|
||||
githubCommentId?: number;
|
||||
path?: string;
|
||||
line?: number;
|
||||
diffSide?: string;
|
||||
body: string;
|
||||
author: TaskReviewAuthor;
|
||||
@@ -92,6 +94,7 @@ export interface TaskReviewStateItem {
|
||||
verdict?: TaskReviewVerdict;
|
||||
step?: number;
|
||||
summary?: string;
|
||||
severity?: TaskReviewFindingSeverity;
|
||||
}
|
||||
|
||||
export type ReviewAddressingStatus = "queued" | "in-progress" | "addressed" | "failed";
|
||||
@@ -105,6 +108,7 @@ export interface ReviewAddressingSnapshot {
|
||||
authorLogin?: string;
|
||||
filePath?: string;
|
||||
lineNumber?: number;
|
||||
severity?: TaskReviewFindingSeverity;
|
||||
threadId?: string;
|
||||
url?: string;
|
||||
}
|
||||
@@ -161,6 +165,7 @@ export interface TaskReviewDataItem {
|
||||
url?: string;
|
||||
filePath?: string;
|
||||
line?: number;
|
||||
severity?: TaskReviewFindingSeverity;
|
||||
threadId?: string;
|
||||
reviewState?: string | null;
|
||||
/** Machine-readable reviewer verdict when the source supplied one. */
|
||||
|
||||
@@ -31,6 +31,24 @@ export type WorkflowStepMode = "prompt" | "script";
|
||||
export type WorkflowStepToolMode = "readonly" | "coding";
|
||||
export type WorkflowStepGateMode = "gate" | "advisory";
|
||||
|
||||
/** Closed severity vocabulary shared by persisted workflow findings and Review-tab items. */
|
||||
export type WorkflowReviewFindingSeverity = "low" | "medium" | "high" | "critical";
|
||||
|
||||
/**
|
||||
* FNXC:WorkflowReviewFindings 2026-08-05-06:29:
|
||||
* Review-kind nodes persist independently actionable feedback in the existing JSONB result so
|
||||
* Review-tab selection never depends on model prose or client-provided metadata. Finding identity
|
||||
* is normalized before persistence; historical prose-only rows intentionally omit this field.
|
||||
*/
|
||||
export interface WorkflowReviewFinding {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
filePath?: string;
|
||||
line?: number;
|
||||
severity?: WorkflowReviewFindingSeverity;
|
||||
}
|
||||
|
||||
/** Lifecycle phase for workflow step execution. */
|
||||
export type WorkflowStepPhase = "pre-merge" | "post-merge";
|
||||
|
||||
@@ -257,6 +275,8 @@ export interface WorkflowStepResult {
|
||||
reviewKind?: WorkflowReviewKind;
|
||||
/** Output from the workflow step agent (findings, errors, etc.) */
|
||||
output?: string;
|
||||
/** Normalized structured advisory findings from an explicitly classified review node. */
|
||||
findings?: WorkflowReviewFinding[];
|
||||
/**
|
||||
* Machine-readable verdict from prompt-mode structured output.
|
||||
* Absent for script-mode steps and legacy prose-only prompt outputs.
|
||||
|
||||
@@ -1,4 +1,52 @@
|
||||
import type { WorkflowStepResult } from "../types.js";
|
||||
import type { WorkflowReviewFinding, WorkflowReviewFindingSeverity, WorkflowStepResult } from "../types.js";
|
||||
|
||||
export const WORKFLOW_REVIEW_FINDING_SEVERITIES = ["low", "medium", "high", "critical"] as const;
|
||||
export const MAX_WORKFLOW_REVIEW_FINDINGS = 20;
|
||||
const MAX_FINDING_ID_LENGTH = 128;
|
||||
const MAX_FINDING_TITLE_LENGTH = 240;
|
||||
const MAX_FINDING_BODY_LENGTH = 4_000;
|
||||
const MAX_FINDING_PATH_LENGTH = 1_000;
|
||||
|
||||
/**
|
||||
* FNXC:WorkflowReviewFindings 2026-08-05-06:29:
|
||||
* Model findings are untrusted JSON. Normalize them once at the core persistence boundary so every
|
||||
* writer stores collision-free IDs and bounded operator-facing text; malformed entries are dropped
|
||||
* rather than becoming selectable feedback.
|
||||
*/
|
||||
export function normalizeWorkflowReviewFindings(raw: unknown): WorkflowReviewFinding[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const normalized: WorkflowReviewFinding[] = [];
|
||||
const usedIds = new Set<string>();
|
||||
for (const candidate of raw) {
|
||||
if (normalized.length >= MAX_WORKFLOW_REVIEW_FINDINGS || !candidate || typeof candidate !== "object") continue;
|
||||
const value = candidate as Record<string, unknown>;
|
||||
const title = boundedTrimmedString(value.title, MAX_FINDING_TITLE_LENGTH);
|
||||
const body = boundedTrimmedString(value.body, MAX_FINDING_BODY_LENGTH);
|
||||
if (!title || !body) continue;
|
||||
const baseId = boundedTrimmedString(value.id, MAX_FINDING_ID_LENGTH) || `finding-${normalized.length + 1}`;
|
||||
let id = baseId;
|
||||
let suffix = 2;
|
||||
while (usedIds.has(id)) id = `${baseId.slice(0, Math.max(1, MAX_FINDING_ID_LENGTH - String(suffix).length - 1))}-${suffix++}`;
|
||||
usedIds.add(id);
|
||||
const filePath = boundedTrimmedString(value.filePath, MAX_FINDING_PATH_LENGTH);
|
||||
const line = typeof value.line === "number" && Number.isFinite(value.line) && value.line > 0
|
||||
? Math.floor(value.line)
|
||||
: undefined;
|
||||
const severity = isWorkflowReviewFindingSeverity(value.severity) ? value.severity : undefined;
|
||||
normalized.push({ id, title, body, ...(filePath ? { filePath } : {}), ...(line ? { line } : {}), ...(severity ? { severity } : {}) });
|
||||
}
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function boundedTrimmedString(value: unknown, maxLength: number): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed && trimmed.length <= maxLength ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function isWorkflowReviewFindingSeverity(value: unknown): value is WorkflowReviewFindingSeverity {
|
||||
return typeof value === "string" && (WORKFLOW_REVIEW_FINDING_SEVERITIES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowStepResults 2026-07-09-00:20:
|
||||
|
||||
@@ -54,6 +54,8 @@ type DisplayReviewItem = {
|
||||
body: string;
|
||||
author?: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
severity?: "low" | "medium" | "high" | "critical";
|
||||
createdAt?: string;
|
||||
status: "queued" | "in-progress" | "addressed" | "failed";
|
||||
addressing?: AddressingRecord;
|
||||
@@ -131,6 +133,8 @@ function getDisplayReviewItems(review: ReviewState): DisplayReviewItem[] {
|
||||
body: item.body,
|
||||
author: item.author?.login,
|
||||
path: item.path,
|
||||
line: item.line,
|
||||
severity: item.severity,
|
||||
createdAt: item.createdAt,
|
||||
status: addressing?.status ?? "queued",
|
||||
addressing,
|
||||
@@ -147,6 +151,8 @@ function getDisplayReviewItems(review: ReviewState): DisplayReviewItem[] {
|
||||
body: record.snapshot?.body ?? record.snapshot?.summary ?? record.itemId,
|
||||
author: record.snapshot?.authorLogin,
|
||||
path: record.snapshot?.filePath,
|
||||
line: record.snapshot?.lineNumber,
|
||||
severity: record.snapshot?.severity,
|
||||
createdAt: record.selectedAt,
|
||||
status: record.status,
|
||||
addressing: record,
|
||||
@@ -615,7 +621,11 @@ export function TaskReviewTab({
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="task-review-tab__meta">{formatTimestamp(item.createdAt, t)}</div>
|
||||
<div className="task-review-tab__meta">
|
||||
{formatTimestamp(item.createdAt, t)}
|
||||
{item.path ? ` · ${item.path}${item.line ? `:${item.line}` : ""}` : ""}
|
||||
{item.severity ? ` · ${item.severity}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{item.addressing ? (
|
||||
<div className="task-review-tab__meta">
|
||||
|
||||
@@ -899,7 +899,7 @@ function getWorkflowReviewKind(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildWorkflowReviewItemId(task: Task, result: WorkflowStepResult): string {
|
||||
function buildWorkflowReviewItemId(task: Task, result: WorkflowStepResult, findingId?: string): string {
|
||||
const identity = JSON.stringify({
|
||||
taskId: task.id,
|
||||
workflowStepId: result.workflowStepId,
|
||||
@@ -912,6 +912,7 @@ function buildWorkflowReviewItemId(task: Task, result: WorkflowStepResult): stri
|
||||
startedAt: result.startedAt,
|
||||
output: result.output,
|
||||
notes: result.notes,
|
||||
findingId,
|
||||
});
|
||||
return `workflow-review-${createHash("sha256").update(identity).digest("hex").slice(0, 24)}`;
|
||||
}
|
||||
@@ -922,8 +923,26 @@ async function buildWorkflowReviewItems(task: Task, store: TaskStore): Promise<T
|
||||
.flatMap((result): TaskReviewItem[] => {
|
||||
const reviewType = getWorkflowReviewKind(result, declaredTopLevelReviewResultSources);
|
||||
if (!reviewType) return [];
|
||||
const body = result.output?.trim() || result.notes?.trim() || "No written feedback was provided by this review step.";
|
||||
const timestamp = result.completedAt ?? result.startedAt ?? task.updatedAt ?? task.createdAt;
|
||||
if (result.findings?.length) {
|
||||
return result.findings.map((finding) => ({
|
||||
itemId: buildWorkflowReviewItemId(task, result, finding.id),
|
||||
sourceMode: "reviewer-agent" as const,
|
||||
title: finding.title,
|
||||
body: finding.body,
|
||||
author: "reviewer-agent",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
...(finding.filePath ? { filePath: finding.filePath } : {}),
|
||||
...(finding.line ? { line: finding.line } : {}),
|
||||
...(finding.severity ? { severity: finding.severity } : {}),
|
||||
reviewState: result.verdict,
|
||||
verdict: result.verdict,
|
||||
reviewType,
|
||||
progressStatus: null,
|
||||
}));
|
||||
}
|
||||
const body = result.output?.trim() || result.notes?.trim() || "No written feedback was provided by this review step.";
|
||||
return [{
|
||||
itemId: buildWorkflowReviewItemId(task, result),
|
||||
sourceMode: "reviewer-agent",
|
||||
@@ -6264,6 +6283,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
updatedAt: item.updatedAt ?? undefined,
|
||||
path: item.filePath,
|
||||
line: item.line,
|
||||
severity: item.severity,
|
||||
threadId: item.threadId,
|
||||
htmlUrl: item.url,
|
||||
state: item.reviewState ?? undefined,
|
||||
@@ -6312,6 +6332,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
author: item.author.login,
|
||||
filePath: item.path,
|
||||
lineNumber: item.line,
|
||||
severity: item.severity,
|
||||
threadId: item.threadId,
|
||||
url: item.htmlUrl,
|
||||
};
|
||||
@@ -6349,6 +6370,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
authorLogin: item.author,
|
||||
filePath: item.filePath,
|
||||
lineNumber: item.lineNumber,
|
||||
severity: item.severity,
|
||||
threadId: item.threadId,
|
||||
url: item.url,
|
||||
},
|
||||
|
||||
@@ -50,6 +50,18 @@ describe("workflow malformed-verdict gate", () => {
|
||||
expect(parseWorkflowStepOutput("native skill output", { requireVerdict: false })).toEqual({ output: "native skill output" });
|
||||
});
|
||||
|
||||
it("extracts only validated findings from the selected trailing verdict JSON", () => {
|
||||
expect(parseWorkflowStepOutput('prose {"verdict":"REVISE","notes":"old"}\n{"verdict":"REVISE","notes":"new","findings":[{"id":"a","title":"Issue","body":"Fix it","line":3,"severity":"high"},{"id":"a","title":"Second","body":"Also fix"},{"title":"bad","body":""}]}')).toMatchObject({
|
||||
verdict: "REVISE",
|
||||
notes: "new",
|
||||
findings: [
|
||||
{ id: "a", title: "Issue", body: "Fix it", line: 3, severity: "high" },
|
||||
{ id: "a-2", title: "Second", body: "Also fix" },
|
||||
],
|
||||
});
|
||||
expect(parseWorkflowStepOutput("REQUEST REVISION\n1. prose only").findings).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a blocking graph gate with a genuine REVISE verdict from passing", async () => {
|
||||
// A PARSED non-pass verdict still blocks (only unparseable/malformed output
|
||||
// was relaxed to a non-blocking advisory — see the ReviewLeniency note above).
|
||||
|
||||
@@ -12,11 +12,11 @@ const WORKFLOW_THINKING_LEVEL_SET: ReadonlySet<string> = new Set(THINKING_LEVELS
|
||||
import { basename, delimiter, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
|
||||
import { existsSync, lstatSync, realpathSync } from "node:fs";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { DEFAULT_PROVIDER_INSTANCE_ID, type ProviderInstanceRef, type TaskStore, type Task, type TaskDetail, type TaskTokenUsage, type StepStatus, type Settings, type WorkflowStep, type MissionStore, type AsyncMissionStore, type Slice, type AgentState, type AgentCapability, type RunMutationContext, type AgentHeartbeatConfig, type Agent, type AgentMemoryInclusionMode, type ProjectSettings, type MergeResult, type WorkflowIrNode, type WorkflowIrNodeKind, type WorkflowStepResult as CoreWorkflowStepResult, type ThinkingLevel } from "@fusion/core";
|
||||
import { DEFAULT_PROVIDER_INSTANCE_ID, type ProviderInstanceRef, type TaskStore, type Task, type TaskDetail, type TaskTokenUsage, type StepStatus, type Settings, type WorkflowStep, type MissionStore, type AsyncMissionStore, type Slice, type AgentState, type AgentCapability, type RunMutationContext, type AgentHeartbeatConfig, type Agent, type AgentMemoryInclusionMode, type ProjectSettings, type MergeResult, type WorkflowIrNode, type WorkflowIrNodeKind, type WorkflowStepResult as CoreWorkflowStepResult, type WorkflowReviewFinding, type ThinkingLevel } from "@fusion/core";
|
||||
import { getUnmetSchedulingDependencies } from "./scheduler.js";
|
||||
import type { ImplementationExit, ImplementationExitReporter } from "./executor/implementation-exit.js";
|
||||
import { emitWorkflowLifecycleEvent } from "@fusion/core";
|
||||
import { resolveTaskLifecycleColumns, resolveProjectColumnsForRoles, resolveWipTargetForTask, resolveTerminalColumns, RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, columnsWithFlag, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, PLAN_REVIEW_GROUP_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, resolveExecutorFallbackModel, resolveValidatorFallbackModel, parseExplicitDuplicateMarker, nonExecutableDuplicateRedirectReason } from "@fusion/core";
|
||||
import { resolveTaskLifecycleColumns, resolveProjectColumnsForRoles, resolveWipTargetForTask, resolveTerminalColumns, RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, columnsWithFlag, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, PLAN_REVIEW_GROUP_ID, upsertWorkflowStepResult, normalizeWorkflowReviewFindings, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, resolveExecutorFallbackModel, resolveValidatorFallbackModel, parseExplicitDuplicateMarker, nonExecutableDuplicateRedirectReason } from "@fusion/core";
|
||||
import {
|
||||
BLOCKED_THRASH_LIMIT,
|
||||
buildExternalBlockMetadataPatch,
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
WORKFLOW_DRIFT_PARK_CONTEXT_KEY,
|
||||
WORKFLOW_NODE_ENGINE_PAUSE_ABORT_KIND,
|
||||
WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY,
|
||||
WORKFLOW_REVIEW_KIND_CONTEXT_KEY,
|
||||
} from "./workflows/workflow-graph-executor.js";
|
||||
import type { WorkflowNodePreparationRequirement, WorkflowNodeResult } from "./workflows/workflow-graph-executor.js";
|
||||
import { workflowNodeRequiresWorktree } from "./workflows/workflow-node-execution-needs.js";
|
||||
@@ -1277,6 +1278,8 @@ export interface WorkflowStepOutcome {
|
||||
verdict?: "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE";
|
||||
/** Notes extracted from structured JSON output (distinct from raw output). */
|
||||
notes?: string;
|
||||
/** Normalized independently actionable feedback from a review-kind node. */
|
||||
findings?: WorkflowReviewFinding[];
|
||||
/** Set when the call exceeded `settings.workflowStepTimeoutMs`. Signals the
|
||||
* caller to escalate to the fallback model rather than treat the failure
|
||||
* as a generic revision request. */
|
||||
@@ -1297,7 +1300,7 @@ export type WorkflowStepResult =
|
||||
| { allPassed: false; revisionRequested: false; feedback: string; stepName: string }
|
||||
| { allPassed: false; revisionRequested: true; feedback: string; stepName: string };
|
||||
|
||||
export function parseWorkflowStepVerdict(rawOutput: string): { verdict: "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE"; notes: string } | null {
|
||||
export function parseWorkflowStepVerdict(rawOutput: string): { verdict: "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE"; notes: string; findings?: WorkflowReviewFinding[] } | null {
|
||||
const trimmed = rawOutput.trim();
|
||||
const candidates: string[] = [];
|
||||
const fencedMatches = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)];
|
||||
@@ -1312,7 +1315,7 @@ export function parseWorkflowStepVerdict(rawOutput: string): { verdict: "APPROVE
|
||||
|
||||
for (let i = candidates.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidates[i]) as { verdict?: unknown; notes?: unknown };
|
||||
const parsed = JSON.parse(candidates[i]) as { verdict?: unknown; notes?: unknown; findings?: unknown };
|
||||
if (!parsed || typeof parsed.verdict !== "string") continue;
|
||||
/*
|
||||
FNXC:ReviewLeniency 2026-07-01-23:30:
|
||||
@@ -1326,9 +1329,11 @@ export function parseWorkflowStepVerdict(rawOutput: string): { verdict: "APPROVE
|
||||
verdict = "REVISE";
|
||||
}
|
||||
if (!verdict) continue;
|
||||
const findings = normalizeWorkflowReviewFindings(parsed.findings);
|
||||
return {
|
||||
verdict,
|
||||
notes: typeof parsed.notes === "string" ? parsed.notes : "",
|
||||
...(findings ? { findings } : {}),
|
||||
};
|
||||
} catch {
|
||||
// continue
|
||||
@@ -1376,18 +1381,21 @@ export function parseWorkflowStepOutput(rawOutput: string): {
|
||||
output: string;
|
||||
verdict?: "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE";
|
||||
notes?: string;
|
||||
findings?: WorkflowReviewFinding[];
|
||||
malformed?: boolean;
|
||||
};
|
||||
export function parseWorkflowStepOutput(rawOutput: string, options: { requireVerdict: false }): {
|
||||
output: string;
|
||||
verdict?: "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE";
|
||||
notes?: string;
|
||||
findings?: WorkflowReviewFinding[];
|
||||
malformed?: boolean;
|
||||
};
|
||||
export function parseWorkflowStepOutput(rawOutput: string, options: { requireVerdict?: boolean } = {}): {
|
||||
output: string;
|
||||
verdict?: "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE";
|
||||
notes?: string;
|
||||
findings?: WorkflowReviewFinding[];
|
||||
malformed?: boolean;
|
||||
} {
|
||||
const trimmed = rawOutput.trim();
|
||||
@@ -1397,6 +1405,7 @@ export function parseWorkflowStepOutput(rawOutput: string, options: { requireVer
|
||||
output: parsed.notes || "",
|
||||
verdict: parsed.verdict,
|
||||
notes: parsed.notes,
|
||||
...(parsed.findings ? { findings: parsed.findings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9660,6 +9669,11 @@ export class TaskExecutor {
|
||||
const optionalGroupId = typeof graphContext?.[WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY] === "string"
|
||||
? graphContext[WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY]
|
||||
: undefined;
|
||||
const declaredReviewKind = cfg.reviewKind === "plan" || cfg.reviewKind === "code"
|
||||
? cfg.reviewKind
|
||||
: graphContext?.[WORKFLOW_REVIEW_KIND_CONTEXT_KEY] === "plan" || graphContext?.[WORKFLOW_REVIEW_KIND_CONTEXT_KEY] === "code"
|
||||
? graphContext[WORKFLOW_REVIEW_KIND_CONTEXT_KEY]
|
||||
: undefined;
|
||||
/*
|
||||
FNXC:FastOptionalSteps 2026-06-30-09:14:
|
||||
Fast skips top-level custom prompt/script/gate review bodies by default, but an enabled optional-group template is explicit operator intent. The graph marks those template nodes so Browser Verification and custom optional groups still run under fast mode.
|
||||
@@ -9931,6 +9945,9 @@ export class TaskExecutor {
|
||||
if (optionalGroupId) {
|
||||
(step as WorkflowStep & { optionalGroupId?: string }).optionalGroupId = optionalGroupId;
|
||||
}
|
||||
if (declaredReviewKind) {
|
||||
(step as WorkflowStep & { reviewKind?: "plan" | "code" }).reviewKind = declaredReviewKind;
|
||||
}
|
||||
if (cfg.reviewCanFixInline === true) {
|
||||
(step as WorkflowStep & { reviewCanFixInline?: boolean }).reviewCanFixInline = true;
|
||||
}
|
||||
@@ -9957,9 +9974,19 @@ export class TaskExecutor {
|
||||
// sets FUSION_HEADLESS=1 only when this is explicitly true.
|
||||
const unattended = this.graphUnattendedRuns.has(live.id);
|
||||
|
||||
const outcome = mode === "script"
|
||||
let outcome: WorkflowStepOutcome = mode === "script"
|
||||
? await this.executeScriptWorkflowStep(live, step, worktreePath, settings, nodeEnv)
|
||||
: await this.executeWorkflowStep(live, step, worktreePath, settings, nodeEnv, { unattended });
|
||||
/*
|
||||
* FNXC:WorkflowReviewFindings 2026-08-05-06:29:
|
||||
* Script nodes retain their exit-code verdict semantics, but an explicitly classified review
|
||||
* script may attach the same trailing JSON findings as prompt nodes. Unmarked scripts never
|
||||
* gain review metadata merely because their output happens to contain a findings key.
|
||||
*/
|
||||
if (declaredReviewKind && typeof outcome.output === "string") {
|
||||
const parsedReviewOutput = parseWorkflowStepOutput(outcome.output, { requireVerdict: false });
|
||||
if (parsedReviewOutput.findings?.length) outcome = { ...outcome, findings: parsedReviewOutput.findings };
|
||||
}
|
||||
|
||||
// Skill-emitted await-input (U6): if the skill asked the user a blocking
|
||||
// question via the ===FUSION_AWAIT_INPUT=== sentinel, park the task
|
||||
@@ -9998,6 +10025,8 @@ export class TaskExecutor {
|
||||
const contextPatch: Record<string, unknown> = {};
|
||||
if (typeof stepOutput === "string") contextPatch.output = stepOutput;
|
||||
if (typeof stepNotes === "string" && stepNotes) contextPatch.notes = stepNotes;
|
||||
const stepFindings = (outcome as WorkflowStepOutcome).findings;
|
||||
if (stepFindings?.length) contextPatch.findings = stepFindings;
|
||||
if (cfg.summaryTarget === "task" && typeof stepOutput === "string" && stepOutput.trim()) {
|
||||
/*
|
||||
* FNXC:WorkflowCompletion 2026-06-29-11:09:
|
||||
@@ -18875,6 +18904,7 @@ ${scopeGuard}
|
||||
const unattended = stepOptions?.unattended === true;
|
||||
const workflowStepMetadata = workflowStep as WorkflowStep & {
|
||||
optionalGroupId?: string;
|
||||
reviewKind?: "plan" | "code";
|
||||
reviewCanFixInline?: boolean;
|
||||
requireExternalIntegrationEvidence?: boolean;
|
||||
};
|
||||
@@ -19060,6 +19090,7 @@ CRITICAL SCOPING RULES — read before doing anything else:
|
||||
const isSkillStep = typeof workflowStep.skillName === "string" && workflowStep.skillName.trim().length > 0;
|
||||
const isSummaryProjectionStep = (workflowStep as WorkflowStep & { summaryTarget?: string }).summaryTarget === "task";
|
||||
const requireVerdict = !isSummaryProjectionStep && (workflowStep.gateMode === "gate" || !isSkillStep);
|
||||
const reviewFindingsContract = workflowStepMetadata.reviewKind === "plan" || workflowStepMetadata.reviewKind === "code";
|
||||
const verdictBlock = requireVerdict
|
||||
? `
|
||||
|
||||
@@ -19067,7 +19098,9 @@ CRITICAL SCOPING RULES — read before doing anything else:
|
||||
|
||||
When your review is complete, your final line MUST be a single JSON object (no markdown fences):
|
||||
|
||||
{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."}
|
||||
${reviewFindingsContract
|
||||
? "{\"verdict\":\"APPROVE|APPROVE_WITH_NOTES|REVISE\",\"notes\":\"...\",\"findings\":[{\"id\":\"stable-id\",\"title\":\"concise issue\",\"body\":\"actionable detail\",\"filePath\":\"optional/path\",\"line\":1,\"severity\":\"low|medium|high|critical\"}]}"
|
||||
: "{\"verdict\":\"APPROVE|APPROVE_WITH_NOTES|REVISE\",\"notes\":\"...\"}"}
|
||||
|
||||
Rules:
|
||||
- Output exactly one trailing JSON object and stop.
|
||||
@@ -19499,6 +19532,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
|
||||
output: parsed.output,
|
||||
verdict: parsed.verdict,
|
||||
notes: parsed.notes,
|
||||
...(parsed.findings ? { findings: parsed.findings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,8 @@ export type WorkflowNodeAbortKind = "engine-pause";
|
||||
export const WORKFLOW_INTERRUPTED_NODE_ID_CONTEXT_KEY = "workflow:interruptedNodeId";
|
||||
export const WORKFLOW_INTERRUPTED_NODE_ABORT_KIND_CONTEXT_KEY = "workflow:interruptedNodeAbortKind";
|
||||
export const WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY = "workflow:optionalGroupActive";
|
||||
/** Explicit parent marker for template execution; never inferred from template labels or output. */
|
||||
export const WORKFLOW_REVIEW_KIND_CONTEXT_KEY = "workflow:reviewKind";
|
||||
export const WORKFLOW_NODE_ENGINE_PAUSE_ABORT_KIND: WorkflowNodeAbortKind = "engine-pause";
|
||||
|
||||
export interface WorkflowNodeResult {
|
||||
@@ -917,6 +919,7 @@ export class WorkflowGraphExecutor {
|
||||
const optionalGroupContext = {
|
||||
...(contextOverride ?? context),
|
||||
[WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY]: node.id,
|
||||
...(this.workflowReviewKind(node) ? { [WORKFLOW_REVIEW_KIND_CONTEXT_KEY]: this.workflowReviewKind(node) } : {}),
|
||||
};
|
||||
return this.executeNodeWithRetries(tNode, task, settings, optionalGroupContext, ir, sig, false);
|
||||
},
|
||||
@@ -943,6 +946,9 @@ export class WorkflowGraphExecutor {
|
||||
const exitContextPatch = exitResult?.contextPatch;
|
||||
let stepOutput = typeof exitContextPatch?.output === "string" ? exitContextPatch.output : undefined;
|
||||
const stepNotes = typeof exitContextPatch?.notes === "string" ? exitContextPatch.notes : undefined;
|
||||
const stepFindings = this.workflowReviewKind(node) && Array.isArray(exitContextPatch?.findings)
|
||||
? exitContextPatch.findings as WorkflowStepResult["findings"]
|
||||
: undefined;
|
||||
/*
|
||||
* FNXC:WorkflowStepResults 2026-07-07-00:00:
|
||||
* A non-verdict `stepStatus === "failed"` (dispatch/infra exception, not a
|
||||
@@ -977,6 +983,7 @@ export class WorkflowGraphExecutor {
|
||||
...(verdict ? { verdict } : {}),
|
||||
...(stepOutput !== undefined ? { output: stepOutput } : {}),
|
||||
...(stepNotes !== undefined ? { notes: stepNotes } : {}),
|
||||
...(stepFindings?.length ? { findings: stepFindings } : {}),
|
||||
startedAt: stepStartedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
@@ -1720,6 +1727,9 @@ export class WorkflowGraphExecutor {
|
||||
const contextPatch = nodeResult.contextPatch ?? {};
|
||||
let output = typeof contextPatch.output === "string" ? contextPatch.output : undefined;
|
||||
const notes = typeof contextPatch.notes === "string" ? contextPatch.notes : undefined;
|
||||
const findings = this.workflowReviewKind(node) && Array.isArray(contextPatch.findings)
|
||||
? contextPatch.findings as WorkflowStepResult["findings"]
|
||||
: undefined;
|
||||
/*
|
||||
* FNXC:WorkflowStepResults 2026-07-07-00:00:
|
||||
* CE `source:"node"` skill-gate failures share the same `(no feedback
|
||||
@@ -1744,6 +1754,7 @@ export class WorkflowGraphExecutor {
|
||||
...(this.workflowReviewKind(node) ? { reviewKind: this.workflowReviewKind(node) } : {}),
|
||||
...(output !== undefined ? { output } : {}),
|
||||
...(notes !== undefined ? { notes } : {}),
|
||||
...(findings?.length ? { findings } : {}),
|
||||
startedAt: started?.startedAt ?? new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user