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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -10,6 +10,11 @@ export {
|
||||
createWorkflowListTool,
|
||||
createWorkflowGetTool,
|
||||
createWorkflowSelectTool,
|
||||
createWorkflowCreateTool,
|
||||
createWorkflowUpdateTool,
|
||||
createWorkflowDeleteTool,
|
||||
createTraitListTool,
|
||||
createWorkflowAuthoringTools,
|
||||
taskCreateParams,
|
||||
taskDocumentReadParams,
|
||||
taskDocumentWriteParams,
|
||||
|
||||
Reference in New Issue
Block a user