Merge pull request #1433 from Runfusion/gsxdsm/improve-node-editor

feat: workflow editor upgrade and consolidation — primary surface, templates, import/export, AI design
This commit is contained in:
gsxdsm
2026-06-05 15:06:32 -07:00
committed by GitHub
109 changed files with 10637 additions and 4185 deletions

View File

@@ -0,0 +1,146 @@
import { describe, it, expect } from "vitest";
import {
createWorkflowAuthoringTools,
createWorkflowListTool,
createWorkflowGetTool,
createWorkflowSelectTool,
createWorkflowCreateTool,
createWorkflowUpdateTool,
createWorkflowDeleteTool,
} from "../index.js";
import type { TaskStore } from "@fusion/core";
/**
* U11 / R12 drift guard (engine half): the workflow-authoring tool surface that
* chat, planning, and the task executor all share must always expose the six
* `fn_workflow_*` tools. The lanes assemble their toolset from
* `createWorkflowAuthoringTools` (chat/planning) and the executor mirrors the
* same factories — so asserting factory completeness here guards every lane's
* source of truth. Lane-wiring (that chat/planning actually pass these to
* createFnAgent) is asserted in packages/dashboard's exposure test.
*
* We invoke the REAL factories with a fake store — never mock the factories
* themselves — so a renamed/removed tool name is caught.
*/
const REQUIRED_WORKFLOW_TOOLS = [
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_list",
"fn_workflow_get",
"fn_workflow_select",
] as const;
// Minimal stand-in; the factories only capture the store reference at build
// time, so no methods are exercised by name-membership assertions.
const fakeStore = {} as unknown as TaskStore;
describe("workflow tool exposure (engine factories)", () => {
it("createWorkflowAuthoringTools exposes all six fn_workflow_* tools plus fn_trait_list", () => {
const names = createWorkflowAuthoringTools(fakeStore, "FN-1").map((t) => t.name);
for (const required of REQUIRED_WORKFLOW_TOOLS) {
expect(names).toContain(required);
}
expect(names).toContain("fn_trait_list");
});
it("each fn_workflow_* factory produces a tool with the expected name", () => {
expect(createWorkflowListTool(fakeStore).name).toBe("fn_workflow_list");
expect(createWorkflowGetTool(fakeStore).name).toBe("fn_workflow_get");
expect(createWorkflowSelectTool(fakeStore, "FN-1").name).toBe("fn_workflow_select");
expect(createWorkflowCreateTool(fakeStore).name).toBe("fn_workflow_create");
expect(createWorkflowUpdateTool(fakeStore).name).toBe("fn_workflow_update");
expect(createWorkflowDeleteTool(fakeStore).name).toBe("fn_workflow_delete");
});
});
/**
* P0 security: the chat/planning lanes pass {stripApprovalFlags:true} so the
* create/update tools cannot persist a CLI-approval-bypass smuggled through a
* prompt-injectable agent lane. The executor lane omits the option (project-
* owner escape hatch) and the flags pass through unchanged.
*/
describe("workflow authoring tools approval-flag stripping", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function captureStore(): { store: TaskStore; captured: { ir?: any } } {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const captured: { ir?: any } = {};
const store = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createWorkflowDefinition: async (input: any) => {
captured.ir = input.ir;
return { id: "wf-1", name: input.name };
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateWorkflowDefinition: async (_id: string, input: any) => {
captured.ir = input.ir;
return { id: "wf-1", name: input.name ?? "wf" };
},
} as unknown as TaskStore;
return { store, captured };
}
// The ToolDefinition.execute signature requires (id, params, signal, onUpdate,
// ctx); tests only need id+params, so pass undefined for the rest and cast the
// result to read the optional `isError`/`content` shape these tools return.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const run = (tool: { execute: (...a: any[]) => Promise<any> }, params: unknown) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tool.execute("call-1", params, undefined, undefined, undefined) as Promise<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isError?: boolean; content: { type: string; text?: string }[];
}>;
const irWithFlags = () => ({
version: "v1" as const,
name: "wf",
nodes: [
{ id: "n1", kind: "prompt", config: { cliSkipApproval: true, name: "x" } },
{
id: "fe",
kind: "foreach",
config: {
template: {
nodes: [
{ id: "inner", kind: "step-execute", config: { autoApprove: true } },
],
edges: [],
},
},
},
],
edges: [],
});
it("strips both flags (incl. foreach template) on create with stripApprovalFlags:true", async () => {
const { store, captured } = captureStore();
const tool = createWorkflowCreateTool(store, { stripApprovalFlags: true });
const res = await run(tool, { name: "wf", ir: irWithFlags() });
expect(res.isError).toBeFalsy();
expect(captured.ir.nodes[0].config.cliSkipApproval).toBeUndefined();
expect(captured.ir.nodes[1].config.template.nodes[0].config.autoApprove).toBeUndefined();
const text = res.content.map((c) => c.text ?? "").join("");
expect(text).toContain("approval-bypass flags removed");
});
it("strips flags on update with stripApprovalFlags:true", async () => {
const { store, captured } = captureStore();
const tool = createWorkflowUpdateTool(store, { stripApprovalFlags: true });
const res = await run(tool, { workflow_id: "wf-1", ir: irWithFlags() });
expect(res.isError).toBeFalsy();
expect(captured.ir.nodes[0].config.cliSkipApproval).toBeUndefined();
expect(captured.ir.nodes[1].config.template.nodes[0].config.autoApprove).toBeUndefined();
});
it("executor lane (no option) passes both flags through unchanged", async () => {
const { store, captured } = captureStore();
const tool = createWorkflowCreateTool(store);
const res = await run(tool, { name: "wf", ir: irWithFlags() });
expect(res.isError).toBeFalsy();
expect(captured.ir.nodes[0].config.cliSkipApproval).toBe(true);
expect(captured.ir.nodes[1].config.template.nodes[0].config.autoApprove).toBe(true);
const text = res.content.map((c) => c.text ?? "").join("");
expect(text).not.toContain("approval-bypass flags removed");
});
});

View File

@@ -44,6 +44,7 @@ function definition(ir: WorkflowIr): WorkflowDefinition {
id: "WF-001",
name: "Full lifecycle",
description: "",
kind: "workflow",
ir,
layout: {},
createdAt: "2026-06-03T00:00:00.000Z",

View File

@@ -12,7 +12,7 @@ import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative, resolve } from "node:path";
import type { AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore } from "@fusion/core";
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError } from "@fusion/core";
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags } from "@fusion/core";
import { promoteHeldTask } from "./hold-release.js";
import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, resolveTitleSummarizerSettingsModel, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
import { ResearchOrchestrator } from "./research-orchestrator.js";
@@ -1259,7 +1259,10 @@ function columnAgentBindingErrorResult(err: ColumnAgentBindingError) {
* Create a `fn_workflow_create` tool — a thin wrapper over the store's workflow
* definition create. The IR is validated server-side; a malformed graph rejects.
*/
export function createWorkflowCreateTool(store: TaskStore): ToolDefinition {
export function createWorkflowCreateTool(
store: TaskStore,
opts?: WorkflowAuthoringToolOptions,
): ToolDefinition {
return {
name: "fn_workflow_create",
label: "Create Workflow",
@@ -1288,17 +1291,24 @@ export function createWorkflowCreateTool(store: TaskStore): ToolDefinition {
parameters: workflowCreateParams,
execute: async (_id: string, params: Static<typeof workflowCreateParams>) => {
try {
await assertWorkflowColumnAgentBindings(store, params.ir, params.confirm_policy_escalation === true);
let approvalNote = "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ir = params.ir as any;
if (opts?.stripApprovalFlags) {
const result = stripApprovalBypassFlags(ir);
ir = result.ir;
if (result.stripped) approvalNote = " (approval-bypass flags removed)";
}
await assertWorkflowColumnAgentBindings(store, ir, params.confirm_policy_escalation === true);
const created = await store.createWorkflowDefinition({
name: params.name,
description: params.description,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ir: params.ir as any,
ir,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
layout: params.layout as any,
});
return {
content: [{ type: "text" as const, text: `Created workflow ${created.id} (${created.name}).` }],
content: [{ type: "text" as const, text: `Created workflow ${created.id} (${created.name}).${approvalNote}` }],
details: { workflowId: created.id, name: created.name },
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1322,7 +1332,10 @@ export function createWorkflowCreateTool(store: TaskStore): ToolDefinition {
* throws an OccupiedColumnsError; we surface it as a structured response carrying
* the per-column occupant counts so the agent can retry with `rehome_to`.
*/
export function createWorkflowUpdateTool(store: TaskStore): ToolDefinition {
export function createWorkflowUpdateTool(
store: TaskStore,
opts?: WorkflowAuthoringToolOptions,
): ToolDefinition {
return {
name: "fn_workflow_update",
label: "Update Workflow",
@@ -1340,20 +1353,27 @@ export function createWorkflowUpdateTool(store: TaskStore): ToolDefinition {
parameters: workflowUpdateParams,
execute: async (_id: string, params: Static<typeof workflowUpdateParams>) => {
try {
if (params.ir !== undefined) {
await assertWorkflowColumnAgentBindings(store, params.ir, params.confirm_policy_escalation === true);
let approvalNote = "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ir = params.ir as any;
if (ir !== undefined && ir !== null) {
if (opts?.stripApprovalFlags) {
const result = stripApprovalBypassFlags(ir);
ir = result.ir;
if (result.stripped) approvalNote = " (approval-bypass flags removed)";
}
await assertWorkflowColumnAgentBindings(store, ir, params.confirm_policy_escalation === true);
}
const updated = await store.updateWorkflowDefinition(params.workflow_id, {
name: params.name,
description: params.description,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ir: params.ir as any,
ir,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
layout: params.layout as any,
rehomeTo: params.rehome_to,
});
return {
content: [{ type: "text" as const, text: `Updated workflow ${updated.id} (${updated.name}).` }],
content: [{ type: "text" as const, text: `Updated workflow ${updated.id} (${updated.name}).${approvalNote}` }],
details: { workflowId: updated.id, name: updated.name },
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1476,6 +1496,45 @@ export function createTraitListTool(): ToolDefinition {
};
}
/**
* Assemble the full workflow-authoring tool surface for a single store-scoped
* lane (chat / planning / executor): the six `fn_workflow_*` tools plus
* `fn_trait_list` (trait vocabulary needed to author/update workflow IR).
*
* Centralizing the list keeps the chat and planning lanes from drifting away
* from the executor's set as new workflow tools are added. `currentTaskId` is
* the default task for `fn_workflow_select`; lanes with no ambient task pass a
* placeholder (an agent can still target any task via the `task_id` param).
*/
/**
* Options for the workflow authoring tool set.
*
* `stripApprovalFlags` removes the `cliSkipApproval`/`autoApprove` approval-
* bypass flags from every node config (incl. nested foreach templates) before
* the create/update tools persist the IR. Chat and planning lanes are prompt-
* injectable, so they pass `true`; the executor lane (project-owner escape
* hatch) omits it so the dashboard editor can deliberately set those flags.
*/
export interface WorkflowAuthoringToolOptions {
stripApprovalFlags?: boolean;
}
export function createWorkflowAuthoringTools(
store: TaskStore,
currentTaskId: string,
opts?: WorkflowAuthoringToolOptions,
): ToolDefinition[] {
return [
createWorkflowListTool(store),
createWorkflowGetTool(store),
createWorkflowSelectTool(store, currentTaskId),
createWorkflowCreateTool(store, opts),
createWorkflowUpdateTool(store, opts),
createWorkflowDeleteTool(store),
createTraitListTool(),
];
}
export function createMemorySearchTool(rootDir: string, settings?: MemoryToolSettings, options?: MemoryToolOptions): ToolDefinition {
return {
name: "fn_memory_search",

View File

@@ -5200,9 +5200,15 @@ export class TaskExecutor {
//
// SECURITY: both flags are intentional project-owner-only escape hatches.
// They are only reachable by someone who can author/edit a workflow
// definition for this project — the same trust boundary that already
// lets them add named scripts. They are NOT untrusted-input surfaces,
// and neither is enforced at the IR-validation layer.
// definition for this project through the trusted dashboard editor /
// executor lane — the same trust boundary that already lets them add
// named scripts. They are NOT enforced at the IR-validation layer.
// Prompt-injectable surfaces strip these flags at the write boundary
// before persisting: the import / AI-design routes (stripApprovalFlags
// in register-workflow-routes.ts) and the chat/planning workflow
// authoring tools (createWorkflowAuthoringTools(..., {stripApprovalFlags:
// true}) in chat.ts / planning.ts) — all via stripApprovalBypassFlags in
// @fusion/core. Only the executor lane keeps these flags intact.
const skipApproval = cfg.cliSkipApproval === true || cfg.autoApprove === true;
if (!skipApproval && !(await this.store.isWorkflowCliCommandApproved(rawCommand))) {
return this.pauseForCliApproval(node, live, rawCommand);

View File

@@ -10,6 +10,11 @@ export {
createWorkflowListTool,
createWorkflowGetTool,
createWorkflowSelectTool,
createWorkflowCreateTool,
createWorkflowUpdateTool,
createWorkflowDeleteTool,
createTraitListTool,
createWorkflowAuthoringTools,
taskCreateParams,
taskDocumentReadParams,
taskDocumentWriteParams,