FN-6582: enforce workflow gate artifact verdicts
Enforce custom workflow terminal gates for required artifacts and malformed pre-merge verdicts. - Add runtime validation that declared workflow artifacts exist before reporting workflow success. - Treat malformed pre-merge gate output as a blocking failure instead of a skipped success. - Cover required-artifact and malformed-verdict gate behavior with focused engine tests. - Document the required-artifact gate and add a patch changeset for the published CLI package. Files changed: .changeset/fn-6582-workflow-gates.md | 5 + docs/workflow-steps.md | 5 +- .../workflow-malformed-verdict-gate.test.ts | 114 +++++++++++++++++++ .../workflow-required-artifact-gate.test.ts | 123 +++++++++++++++++++++ packages/engine/src/executor.ts | 63 +++++++---- packages/engine/src/workflow-task-runtime.ts | 41 ++++++- 6 files changed, 326 insertions(+), 25 deletions(-) Fusion-Task-Id: FN-6582 Fusion-Task-Lineage: 6f59fc33-dd35-4e28-bf6b-85b709c77453
This commit is contained in:
5
.changeset/fn-6582-workflow-gates.md
Normal file
5
.changeset/fn-6582-workflow-gates.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Prevent custom workflows from reaching terminal success when declared task-document artifacts are missing, and keep malformed blocking gate verdicts from being treated as successful workflow-step passes.
|
||||||
@@ -375,7 +375,7 @@ For new workflow step prompts, prefer the structured JSON contract.
|
|||||||
|
|
||||||
#### Malformed Output
|
#### Malformed Output
|
||||||
|
|
||||||
If output matches neither structured JSON nor known prose fallback patterns, Fusion records the step output as `malformed`. Operationally, this means no workflow verdict could be inferred from that response.
|
If output matches neither structured JSON nor known prose fallback patterns, Fusion records the step output as `malformed`. Operationally, this means no workflow verdict could be inferred from that response. A malformed `gateMode: "gate"` prompt step is a blocking failure rather than an approval; a malformed `gateMode: "advisory"` step is recorded as `advisory_failure` and does not block completion.
|
||||||
|
|
||||||
### Behavior
|
### Behavior
|
||||||
|
|
||||||
@@ -531,7 +531,7 @@ Prompt-mode workflow agents should emit a trailing JSON object:
|
|||||||
- `verdict` and `notes` are persisted on `WorkflowStepResult` when present.
|
- `verdict` and `notes` are persisted on `WorkflowStepResult` when present.
|
||||||
- Script-mode steps do not populate these fields.
|
- Script-mode steps do not populate these fields.
|
||||||
- Backward compatibility remains for legacy prose-only responses via heuristic fallback (`REQUEST REVISION` and approval keywords).
|
- Backward compatibility remains for legacy prose-only responses via heuristic fallback (`REQUEST REVISION` and approval keywords).
|
||||||
- If neither structured JSON nor fallback prose can be interpreted, output is recorded as `malformed` (no inferable verdict) instead of hard-failing the task.
|
- If neither structured JSON nor fallback prose can be interpreted, output is recorded as `malformed` (no inferable verdict). Malformed blocking gates fail closed; advisory gates record `advisory_failure` without blocking.
|
||||||
|
|
||||||
## Workflow Graph Executor
|
## Workflow Graph Executor
|
||||||
|
|
||||||
@@ -548,6 +548,7 @@ Traversal semantics:
|
|||||||
- `outcome:<value>` routes when the node result value matches exactly
|
- `outcome:<value>` routes when the node result value matches exactly
|
||||||
- unsupported conditions throw `WorkflowIrError`
|
- unsupported conditions throw `WorkflowIrError`
|
||||||
- per-node retries are bounded and deterministic
|
- per-node retries are bounded and deterministic
|
||||||
|
- terminal success requires every workflow-declared task-document artifact key (`ir.artifacts[].key`) to exist. No-artifact workflows keep the implicit `PROMPT.md` parse-step default and do not require a task document.
|
||||||
|
|
||||||
Coverage includes lifecycle ordering, primitive invocation, merge/file-scope failure routing, and downstream halt behavior for hard-cancel/recovery style failures.
|
Coverage includes lifecycle ordering, primitive invocation, merge/file-scope failure routing, and downstream halt behavior for hard-cancel/recovery style failures.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { TaskDetail, WorkflowIrNode } from "@fusion/core";
|
||||||
|
|
||||||
|
import { parseWorkflowStepOutput } from "../executor.js";
|
||||||
|
import { createDefaultNodeHandlers } from "../workflow-node-handlers.js";
|
||||||
|
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowGates 2026-06-17-18:27:
|
||||||
|
FN-6582 requires malformed workflow-step verdicts to remain explicit failures for blocking gates while advisory gates may record a non-blocking advisory failure. These tests pin the shared imperative parser seam and the graph handler path so malformed output cannot be mistaken for APPROVE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const task = { id: "FN-6582" } as TaskDetail;
|
||||||
|
|
||||||
|
const noopSeams = () => ({
|
||||||
|
planning: vi.fn(async () => ({ outcome: "success" as const })),
|
||||||
|
execute: vi.fn(async () => ({ outcome: "success" as const })),
|
||||||
|
workflowStep: vi.fn(async () => ({ outcome: "success" as const })),
|
||||||
|
review: vi.fn(async () => ({ outcome: "success" as const })),
|
||||||
|
merge: vi.fn(async () => ({ outcome: "success" as const })),
|
||||||
|
schedule: vi.fn(async () => ({ outcome: "success" as const })),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("workflow malformed-verdict gate", () => {
|
||||||
|
it("parses structured, fenced, prose, and malformed verdict shapes at the imperative seam", () => {
|
||||||
|
expect(parseWorkflowStepOutput('{"verdict":"APPROVE","notes":"ok"}')).toEqual({
|
||||||
|
output: "ok",
|
||||||
|
verdict: "APPROVE",
|
||||||
|
notes: "ok",
|
||||||
|
});
|
||||||
|
expect(parseWorkflowStepOutput('```json\n{"verdict":"APPROVE_WITH_NOTES","notes":"ship it"}\n```')).toEqual({
|
||||||
|
output: "ship it",
|
||||||
|
verdict: "APPROVE_WITH_NOTES",
|
||||||
|
notes: "ship it",
|
||||||
|
});
|
||||||
|
expect(parseWorkflowStepOutput("REQUEST REVISION\nfix the gate")).toEqual({
|
||||||
|
output: "fix the gate",
|
||||||
|
verdict: "REVISE",
|
||||||
|
notes: "fix the gate",
|
||||||
|
});
|
||||||
|
expect(parseWorkflowStepOutput("looks good to me")).toEqual({
|
||||||
|
output: "looks good to me",
|
||||||
|
verdict: "APPROVE",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
expect(parseWorkflowStepOutput("lorem ipsum")).toEqual({ output: "lorem ipsum", malformed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a malformed blocking graph gate from producing a passing outcome", async () => {
|
||||||
|
const malformed = parseWorkflowStepOutput("lorem ipsum");
|
||||||
|
const runCustomNode = vi.fn(async () => ({
|
||||||
|
outcome: malformed.malformed ? "failure" as const : "success" as const,
|
||||||
|
value: malformed.malformed ? "malformed" : malformed.verdict,
|
||||||
|
contextPatch: malformed.malformed ? { "workflow:gate:malformed": true } : undefined,
|
||||||
|
}));
|
||||||
|
const handlers = createDefaultNodeHandlers(noopSeams(), runCustomNode);
|
||||||
|
|
||||||
|
const result = await handlers.gate(
|
||||||
|
{ id: "quality-gate", kind: "gate", config: { prompt: "Return APPROVE or REVISE", gateMode: "gate" } },
|
||||||
|
{ task, settings: undefined, context: {} },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("failure");
|
||||||
|
expect(result.value).toBe("malformed");
|
||||||
|
expect(result.contextPatch).toEqual({ "workflow:gate:malformed": true });
|
||||||
|
expect(runCustomNode).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows advisory malformed gates to record advisory_failure without blocking the graph", async () => {
|
||||||
|
const malformed = parseWorkflowStepOutput("lorem ipsum");
|
||||||
|
const handlers = createDefaultNodeHandlers(noopSeams(), async (node: WorkflowIrNode) => ({
|
||||||
|
outcome: "success",
|
||||||
|
value: node.config?.gateMode === "advisory" && malformed.malformed ? "advisory_failure" : "passed",
|
||||||
|
contextPatch: { "workflow:gate:malformed": malformed.malformed, "workflow:gate:advisory": true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await handlers.gate(
|
||||||
|
{ id: "advisory-gate", kind: "gate", config: { prompt: "Return APPROVE or REVISE", gateMode: "advisory" } },
|
||||||
|
{ task, settings: undefined, context: {} },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("success");
|
||||||
|
expect(result.value).toBe("advisory_failure");
|
||||||
|
expect(result.contextPatch).toEqual({ "workflow:gate:malformed": true, "workflow:gate:advisory": true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terminates a graph run as failed when a malformed gate routes to failure", async () => {
|
||||||
|
const malformed = parseWorkflowStepOutput("lorem ipsum");
|
||||||
|
const executor = new WorkflowGraphExecutor({
|
||||||
|
handlers: createDefaultNodeHandlers(noopSeams(), async () => ({
|
||||||
|
outcome: malformed.malformed ? "failure" : "success",
|
||||||
|
value: malformed.malformed ? "malformed" : "APPROVE",
|
||||||
|
})),
|
||||||
|
runCustomNode: async () => ({ outcome: "success" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executor.run(task, { experimentalFeatures: { workflowGraphExecutor: true } }, {
|
||||||
|
version: "v1",
|
||||||
|
name: "malformed-gate",
|
||||||
|
nodes: [
|
||||||
|
{ id: "start", kind: "start" },
|
||||||
|
{ id: "gate", kind: "gate", config: { prompt: "Return APPROVE or REVISE", gateMode: "gate" } },
|
||||||
|
{ id: "zend", kind: "end" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: "start", to: "gate", condition: "success" },
|
||||||
|
{ from: "gate", to: "zend", condition: "success" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("failure");
|
||||||
|
expect(result.visitedNodeIds).toEqual(["start", "gate"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Settings, TaskDetail, WorkflowIr } from "@fusion/core";
|
||||||
|
|
||||||
|
import { WorkflowTaskRuntime } from "../workflow-task-runtime.js";
|
||||||
|
import type { WorkflowRuntimePrimitives } from "../runtime-primitives.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowGates 2026-06-17-18:24:
|
||||||
|
FN-6582 requires terminal workflow success to depend on declared task-document artifact key existence, not only graph node success. Missing declared keys keep the run incomplete/failed; empty document content still counts as present because the MVP artifact contract currently requires existence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const task = { id: "FN-6582" } as TaskDetail;
|
||||||
|
const settings = { experimentalFeatures: {} } as unknown as Pick<Settings, "experimentalFeatures">;
|
||||||
|
|
||||||
|
function trivialIr(artifacts?: WorkflowIr["artifacts"]): WorkflowIr {
|
||||||
|
return {
|
||||||
|
version: "v1",
|
||||||
|
name: "required-artifact-gate",
|
||||||
|
artifacts,
|
||||||
|
nodes: [
|
||||||
|
{ id: "start", kind: "start" },
|
||||||
|
{ id: "check", kind: "prompt", config: { prompt: "approve" } },
|
||||||
|
{ id: "zend", kind: "end" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: "start", to: "check", condition: "success" },
|
||||||
|
{ from: "check", to: "zend", condition: "success" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function primitives(): WorkflowRuntimePrimitives {
|
||||||
|
return {
|
||||||
|
prepareWorktree: async () => ({ outcome: "success", data: { worktreePath: "/tmp/fusion-worktree" } }),
|
||||||
|
readArtifact: async () => undefined,
|
||||||
|
writeArtifact: async (_ctx, _task, key) => ({ outcome: "success", data: { key } }),
|
||||||
|
runPlanningSession: async () => ({ outcome: "success", data: { approved: true, artifactKeys: [] } }),
|
||||||
|
runCodingSession: async () => ({ outcome: "success", data: { taskDone: true, modifiedFiles: [] } }),
|
||||||
|
runTaskStep: async () => ({ outcome: "success" }),
|
||||||
|
resetTaskStep: async () => ({ ok: true }),
|
||||||
|
runReview: async () => ({ outcome: "success", data: { verdict: "APPROVE" } }),
|
||||||
|
runVerification: async () => ({ outcome: "success", data: { verdict: "skipped" } }),
|
||||||
|
runWorkflowStep: async () => ({ outcome: "success", data: { allPassed: true } }),
|
||||||
|
updateSteps: async (_ctx, _task, steps) => ({ outcome: "success", data: { count: steps.length } }),
|
||||||
|
transitionTask: async () => ({ outcome: "success" }),
|
||||||
|
requestMerge: async () => ({ outcome: "success", data: { status: "merged" } }),
|
||||||
|
abortRun: async () => ({ outcome: "success" }),
|
||||||
|
audit: () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeFor(ir: WorkflowIr, docs: Map<string, string>) {
|
||||||
|
const getTaskDocument = vi.fn(async (_taskId: string, key: string) => {
|
||||||
|
if (!docs.has(key)) return null;
|
||||||
|
return { taskId: task.id, key, content: docs.get(key), revision: 1 };
|
||||||
|
});
|
||||||
|
const runtime = new WorkflowTaskRuntime({
|
||||||
|
store: {
|
||||||
|
getTaskWorkflowSelection: () => ({ workflowId: "WF-6582", stepIds: [] }),
|
||||||
|
getWorkflowDefinition: async () => ({ ir }),
|
||||||
|
getTaskDocument,
|
||||||
|
},
|
||||||
|
primitives: primitives(),
|
||||||
|
runCustomNode: async () => ({ outcome: "success" }),
|
||||||
|
});
|
||||||
|
return { runtime, getTaskDocument };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("workflow required-artifact terminal gate", () => {
|
||||||
|
it("fails terminal success when a declared task-document artifact key is absent", async () => {
|
||||||
|
const { runtime, getTaskDocument } = runtimeFor(trivialIr([{ key: "plan", role: "context" }]), new Map());
|
||||||
|
|
||||||
|
const result = await runtime.run(task, settings);
|
||||||
|
|
||||||
|
expect(result.disposition).toBe("failed");
|
||||||
|
expect(result.outcome).toBe("failure");
|
||||||
|
expect(result.reason).toBe("workflow-required-artifacts-missing:plan");
|
||||||
|
expect(result.context["workflow:required-artifacts:missing"]).toEqual(["plan"]);
|
||||||
|
expect(getTaskDocument).toHaveBeenCalledWith(task.id, "plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes when every declared task-document artifact key exists, including whitespace content", async () => {
|
||||||
|
const ir = trivialIr([
|
||||||
|
{ key: "plan", role: "step-source" },
|
||||||
|
{ key: "evidence", role: "context" },
|
||||||
|
]);
|
||||||
|
const { runtime } = runtimeFor(ir, new Map([
|
||||||
|
["plan", " \n"],
|
||||||
|
["evidence", "coverage summary"],
|
||||||
|
]));
|
||||||
|
|
||||||
|
const result = await runtime.run(task, settings);
|
||||||
|
|
||||||
|
expect(result.disposition).toBe("completed");
|
||||||
|
expect(result.outcome).toBe("success");
|
||||||
|
expect(result.context["workflow:required-artifacts:missing"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports all missing keys for multi-artifact workflows", async () => {
|
||||||
|
const ir = trivialIr([
|
||||||
|
{ key: "plan", role: "step-source" },
|
||||||
|
{ key: "evidence", role: "context" },
|
||||||
|
{ key: "release-notes", role: "context" },
|
||||||
|
]);
|
||||||
|
const { runtime } = runtimeFor(ir, new Map([["evidence", "present"]]));
|
||||||
|
|
||||||
|
const result = await runtime.run(task, settings);
|
||||||
|
|
||||||
|
expect(result.disposition).toBe("failed");
|
||||||
|
expect(result.reason).toBe("workflow-required-artifacts-missing:plan,release-notes");
|
||||||
|
expect(result.context["workflow:required-artifacts:missing"]).toEqual(["plan", "release-notes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not require the implicit PROMPT.md artifact when no artifacts are declared", async () => {
|
||||||
|
const { runtime, getTaskDocument } = runtimeFor(trivialIr(undefined), new Map());
|
||||||
|
|
||||||
|
const result = await runtime.run(task, settings);
|
||||||
|
|
||||||
|
expect(result.disposition).toBe("completed");
|
||||||
|
expect(result.outcome).toBe("success");
|
||||||
|
expect(getTaskDocument).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1060,6 +1060,38 @@ export function inferWorkflowStepVerdictFromProse(rawOutput: string): { verdict:
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:WorkflowGates 2026-06-17-18:22:
|
||||||
|
* Gate-class workflow steps must emit a parseable JSON or prose verdict before they can approve pre-merge completion. A fully malformed response is surfaced explicitly so blocking gates fail while advisory gates can record a non-blocking advisory failure.
|
||||||
|
*/
|
||||||
|
export function parseWorkflowStepOutput(rawOutput: string): {
|
||||||
|
output: string;
|
||||||
|
verdict?: "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE";
|
||||||
|
notes?: string;
|
||||||
|
malformed?: boolean;
|
||||||
|
} {
|
||||||
|
const trimmed = rawOutput.trim();
|
||||||
|
const parsed = parseWorkflowStepVerdict(trimmed);
|
||||||
|
if (parsed) {
|
||||||
|
return {
|
||||||
|
output: parsed.notes || "",
|
||||||
|
verdict: parsed.verdict,
|
||||||
|
notes: parsed.notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferred = inferWorkflowStepVerdictFromProse(trimmed);
|
||||||
|
if (inferred) {
|
||||||
|
return {
|
||||||
|
output: inferred.notes || trimmed,
|
||||||
|
verdict: inferred.verdict,
|
||||||
|
notes: inferred.notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { output: trimmed, malformed: true };
|
||||||
|
}
|
||||||
|
|
||||||
const reviewStepParams = Type.Object({
|
const reviewStepParams = Type.Object({
|
||||||
step: Type.Number({ description: "Step number to review" }),
|
step: Type.Number({ description: "Step number to review" }),
|
||||||
type: Type.Union(
|
type: Type.Union(
|
||||||
@@ -12007,26 +12039,7 @@ ${failureFeedback}
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
malformed?: boolean;
|
malformed?: boolean;
|
||||||
} {
|
} {
|
||||||
const trimmed = rawOutput.trim();
|
return parseWorkflowStepOutput(rawOutput);
|
||||||
const parsed = parseWorkflowStepVerdict(trimmed);
|
|
||||||
if (parsed) {
|
|
||||||
return {
|
|
||||||
output: parsed.notes || "",
|
|
||||||
verdict: parsed.verdict,
|
|
||||||
notes: parsed.notes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const inferred = inferWorkflowStepVerdictFromProse(trimmed);
|
|
||||||
if (inferred) {
|
|
||||||
return {
|
|
||||||
output: inferred.notes || trimmed,
|
|
||||||
verdict: inferred.verdict,
|
|
||||||
notes: inferred.notes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { output: trimmed, malformed: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12297,9 +12310,15 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
|
|||||||
if (parsed.malformed) {
|
if (parsed.malformed) {
|
||||||
await this.store.logEntry(
|
await this.store.logEntry(
|
||||||
task.id,
|
task.id,
|
||||||
`[pre-merge] Workflow step '${workflowStep.name}' produced malformed output — treating as skipped`,
|
`[pre-merge] Workflow step '${workflowStep.name}' produced malformed output — blocking gate success`,
|
||||||
);
|
);
|
||||||
return { success: true, output: parsed.output, notes: undefined, malformed: true };
|
return {
|
||||||
|
success: false,
|
||||||
|
output: parsed.output,
|
||||||
|
error: "malformed output — no verdict extracted",
|
||||||
|
notes: undefined,
|
||||||
|
malformed: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, output: parsed.output };
|
return { success: true, output: parsed.output };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Settings, TaskDetail, WorkflowIr, WorkflowIrNode, WorkflowWorkItem, WorkflowWorkItemState } from "@fusion/core";
|
import type { Settings, TaskDetail, WorkflowIr, WorkflowIrArtifact, WorkflowIrNode, WorkflowWorkItem, WorkflowWorkItemState } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
BUILTIN_CODING_WORKFLOW_IR,
|
BUILTIN_CODING_WORKFLOW_IR,
|
||||||
getBuiltinWorkflow,
|
getBuiltinWorkflow,
|
||||||
@@ -35,6 +35,7 @@ export interface WorkflowTaskRuntimeResult {
|
|||||||
export interface WorkflowTaskRuntimeDeps extends Omit<WorkflowGraphExecutorDeps, "seams" | "runCustomNode"> {
|
export interface WorkflowTaskRuntimeDeps extends Omit<WorkflowGraphExecutorDeps, "seams" | "runCustomNode"> {
|
||||||
store: WorkflowIrResolverStore & {
|
store: WorkflowIrResolverStore & {
|
||||||
getTask?: (taskId: string) => Promise<TaskDetail>;
|
getTask?: (taskId: string) => Promise<TaskDetail>;
|
||||||
|
getTaskDocument?: (taskId: string, key: string) => Promise<unknown | null>;
|
||||||
transitionWorkflowWorkItem?: (
|
transitionWorkflowWorkItem?: (
|
||||||
id: string,
|
id: string,
|
||||||
state: WorkflowWorkItemState,
|
state: WorkflowWorkItemState,
|
||||||
@@ -113,6 +114,25 @@ export class WorkflowTaskRuntime {
|
|||||||
reason,
|
reason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (result.outcome === "success") {
|
||||||
|
const missingArtifactKeys = await this.findMissingRequiredArtifacts(task.id, target.ir);
|
||||||
|
if (missingArtifactKeys.length > 0) {
|
||||||
|
const reason = `workflow-required-artifacts-missing:${missingArtifactKeys.join(",")}`;
|
||||||
|
const context = {
|
||||||
|
...result.context,
|
||||||
|
"workflow:required-artifacts:missing": missingArtifactKeys,
|
||||||
|
};
|
||||||
|
this.emit("terminal", task.id, `failed:${reason}`);
|
||||||
|
return {
|
||||||
|
disposition: "failed",
|
||||||
|
outcome: "failure",
|
||||||
|
visitedNodeIds: result.visitedNodeIds,
|
||||||
|
context,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const disposition: WorkflowTaskRuntimeDisposition = result.outcome === "success" ? "completed" : "failed";
|
const disposition: WorkflowTaskRuntimeDisposition = result.outcome === "success" ? "completed" : "failed";
|
||||||
this.emit("terminal", task.id, disposition);
|
this.emit("terminal", task.id, disposition);
|
||||||
return {
|
return {
|
||||||
@@ -232,6 +252,25 @@ export class WorkflowTaskRuntime {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:WorkflowGates 2026-06-17-18:20:
|
||||||
|
* Custom workflow success criteria require every declared task-document artifact key to exist before terminal success. Evaluate this at the runtime terminal seam so graph paths cannot falsely complete after nodes pass while required deliverables are absent. Empty document content still satisfies the requirement because the IR contract currently requires key existence, not non-empty content.
|
||||||
|
*/
|
||||||
|
private async findMissingRequiredArtifacts(taskId: string, ir: WorkflowIr): Promise<string[]> {
|
||||||
|
const declaredArtifacts: WorkflowIrArtifact[] = "artifacts" in ir && Array.isArray(ir.artifacts) ? ir.artifacts : [];
|
||||||
|
if (declaredArtifacts.length === 0) return [];
|
||||||
|
if (!this.deps.store.getTaskDocument) {
|
||||||
|
return declaredArtifacts.map((artifact) => artifact.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const artifact of declaredArtifacts) {
|
||||||
|
const document = await this.deps.store.getTaskDocument(taskId, artifact.key);
|
||||||
|
if (!document) missing.push(artifact.key);
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveRuntimeTarget(taskId: string): Promise<WorkflowRuntimeTarget> {
|
private async resolveRuntimeTarget(taskId: string): Promise<WorkflowRuntimeTarget> {
|
||||||
let workflowId: string | undefined;
|
let workflowId: string | undefined;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user