diff --git a/.changeset/fn-7911-workflow-validate.md b/.changeset/fn-7911-workflow-validate.md new file mode 100644 index 0000000000..bb3dec6f69 --- /dev/null +++ b/.changeset/fn-7911-workflow-validate.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add `fn workflow validate` to dry-run a custom workflow IR without creating or mutating it. +category: feature +dev: Adds the `fn_workflow_validate` agent tool, `POST /api/workflows/validate`, and the `fn workflow validate | --file ` CLI command. Reuses the same parseWorkflowIr/trait/code-node/column-agent validation as create/update; performs no persistence. diff --git a/docs/agents.md b/docs/agents.md index d93c78c52f..90270382fa 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -10,6 +10,9 @@ Agent-facing docs must preserve the workflow movement boundary: agents can assig FNXC:WorkflowRouting 2026-06-30-09:20: Permanent agents and the published extension now have governed workflow authoring, settings, trait-inspection, and selection tools; docs must describe the broad tool surface while preserving the narrow routing permission boundary. + +FNXC:WorkflowAuthoringTools 2026-07-12-00:00: +Workflow validation is a read-only authoring support tool: agents can run the create/update validator and inspect typed failures, but the tool must not persist workflow rows or count as a mutation gate. --> ## CLI session actions @@ -37,7 +40,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - Dashboard chat and planning sessions with a scoped task store expose `fn_task_document_write` and `fn_task_document_read`; because neither lane has an ambient task, both tools require an explicit `task_id`. - Agent workflow-routing tools follow an intent boundary: agents may select or change a task workflow only when the user explicitly requested that workflow or when the agent created the task. Executors must not call `fn_workflow_select` to reroute the task they are executing unless the task instructions or a user steering comment explicitly asks for the workflow change. Lanes without an ambient task, including dashboard chat/planning and published/pi extension calls outside a task, must pass an explicit `task_id`; task-bound executor paths may default to the current task. - Executor, heartbeat, and dashboard chat sessions expose artifact registry tools so agents can publish and inspect multi-type deliverables without relying on the dashboard gallery. Planning sessions intentionally exclude artifact tools until they can thread the existing `MessageStore` dependency. -- Permanent/custom heartbeat agents and the published/pi extension receive the broad coordination and work-discovery tool surface instead of a narrowly curated subset: read-only task discovery (`fn_task_list`, `fn_task_show`, `fn_task_search`) for work discovery and duplicate avoidance, workflow discovery and authoring (`fn_workflow_list`, `fn_workflow_get`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`), governed research (`fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`), structured clarification (`fn_ask_question`), artifact, memory, messaging, goal, evaluation, identity, and delegation tools. Task-scoped heartbeat sessions also expose current-task workflow selection and promotion (`fn_workflow_select`, `fn_task_promote`); no-task heartbeats omit those because they have no ambient task, while no-task extension/chat/planning lanes expose `fn_workflow_select` but require explicit `task_id`. Workflow creation, updates, settings writes, deletion, and selection remain permission-gated task/agent mutations even when the tools are exposed in the lane. Prompt-injectable lanes strip workflow approval-bypass flags during `fn_workflow_create`/`fn_workflow_update`; executor-owner paths are the only authoring path that may preserve those flags. Executor-only worktree/workspace tools such as `fn_run_verification` and `fn_acquire_repo_worktree` remain out of the ambient heartbeat lane until that lane owns the required worktree/workspace context. The task read tools are store-backed, text-only, and action-gate-recognized as read-only; dangerous actions are controlled at invocation time by each agent's `AgentPermissionPolicy` through the action gate (allow / require approval / block), not by withholding governed tools from the session. +- Permanent/custom heartbeat agents and the published/pi extension receive the broad coordination and work-discovery tool surface instead of a narrowly curated subset: read-only task discovery (`fn_task_list`, `fn_task_show`, `fn_task_search`) for work discovery and duplicate avoidance, workflow discovery and authoring (`fn_workflow_list`, `fn_workflow_get`, `fn_workflow_validate`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`), governed research (`fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`), structured clarification (`fn_ask_question`), artifact, memory, messaging, goal, evaluation, identity, and delegation tools. Task-scoped heartbeat sessions also expose current-task workflow selection and promotion (`fn_workflow_select`, `fn_task_promote`); no-task heartbeats omit those because they have no ambient task, while no-task extension/chat/planning lanes expose `fn_workflow_select` but require explicit `task_id`. Workflow creation, updates, settings writes, deletion, and selection remain permission-gated task/agent mutations even when the tools are exposed in the lane. Prompt-injectable lanes strip workflow approval-bypass flags during `fn_workflow_create`/`fn_workflow_update`; executor-owner paths are the only authoring path that may preserve those flags. Executor-only worktree/workspace tools such as `fn_run_verification` and `fn_acquire_repo_worktree` remain out of the ambient heartbeat lane until that lane owns the required worktree/workspace context. The task read tools are store-backed, text-only, and action-gate-recognized as read-only; dangerous actions are controlled at invocation time by each agent's `AgentPermissionPolicy` through the action gate (allow / require approval / block), not by withholding governed tools from the session. - `agent.taskId` is an active-execution linkage, not durable ownership. It may legitimately point at a `todo`/`triage` task only while the agent has live run or executor-active proof; task-move sync and self-healing clear stale parked, terminal, or unresolved links otherwise. `fn_list_agents` and `fn_agent_show` therefore include column context in the human-readable `Current Task` line, such as `(triage)`, `(in-progress)`, `(not active — done)`, or `(unresolved)`, so coordinators can distinguish transient planning ownership from drift. - `fn_agent_show` prints `Last Error`, `Pause Reason`, and compact `Error Recovery` counter details when present. `fn_list_agents` prints the same diagnostics only for agents currently in `error` or `paused`, keeping healthy rows compact while making durable-agent recovery state inspectable without direct DB/log access. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 4d4b2c5828..47e2d97d54 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -10,6 +10,9 @@ The published CLI/pi extension must document its agent-facing workflow authoring FNXC:AgentTools 2026-06-30-09:25: The extension docs must list workflow selection and task-creation forwarding alongside CRUD/settings tools so operators do not assume only discovery and selection exist or that agents may reroute arbitrary tasks. + +FNXC:WorkflowCli 2026-07-12-00:00: +Workflow authors need a published CLI dry-run that validates the same IR contract as create/update while performing zero persistence, so scripts can fail before attempting a save. --> ## Published agent extension workflow tools @@ -17,6 +20,7 @@ The extension docs must list workflow selection and task-creation forwarding alo The published `@runfusion/fusion` CLI bundle also exposes the pi extension tool surface used by external agents. Alongside task and coordination helpers, agents can now author and manage workflow definitions: - `fn_workflow_list` / `fn_workflow_get` — discover built-in and custom workflows and inspect a workflow's IR before editing. +- `fn_workflow_validate` — dry-run validate a workflow IR without creating or mutating it. It accepts an existing workflow id or inline IR through the tool surface and returns the same typed validation errors that create/update would reject. - `fn_workflow_create` / `fn_workflow_update` — create or revise custom workflow definitions through Fusion's central workflow validator. Built-in definitions are read-only, and broader-than-default column permission bindings require explicit policy-escalation confirmation. - `fn_workflow_settings` — read and write typed per-project values for a workflow's declared settings. `get` returns stored and engine-effective values; `set` validates atomically and treats `null` as deleting a stored override. - `fn_workflow_delete` — delete custom workflows; built-in workflows remain protected. @@ -26,6 +30,15 @@ The published `@runfusion/fusion` CLI bundle also exposes the pi extension tool Agents should still use `fn_workflow_select` only when the user explicitly requested that workflow or when assigning a workflow to a task they created; they must not reroute arbitrary existing tasks just because another workflow appears more suitable. Prompt-injectable lanes strip workflow approval-bypass flags during `fn_workflow_create` / `fn_workflow_update`; executor-owner paths are the only authoring path that may preserve those flags. +## Workflow commands + +```bash +fn workflow validate [--json] +fn workflow validate --file [--json] +``` + +`fn workflow validate` runs the server-side workflow IR dry run without creating, updating, deleting, or emitting workflow events. The command exits `0` when the IR is valid, exits non-zero for invalid IR or usage errors, and `--json` prints `{ "valid": true }` or `{ "valid": false, "errors": [...] }` for automation. + ## Global Usage ```bash diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 92f17bb330..93aba19ae5 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -33,6 +33,7 @@ Fusion workflows define the task lifecycle policy that moves work from an idea t Operators can select workflows in the dashboard wherever the task or board workflow selector is shown. Agents and automation can discover, author, tune, and assign them with the workflow tools: - `fn_workflow_list` / `fn_workflow_get` — list built-in and custom workflow definitions and inspect a definition's IR before editing. +- `fn_workflow_validate` — dry-run validate a workflow IR by existing workflow id or inline graph object without creating, updating, deleting, emitting workflow events, or mutating any workflow. It runs the same `parseWorkflowIr`, column trait, code-node compile, and column-agent binding checks used by create/update and returns typed validation errors for malformed graphs. - `fn_trait_list` — list the column trait vocabulary needed when authoring workflow IR columns. - `fn_workflow_create` / `fn_workflow_update` / `fn_workflow_delete` — create, edit, or delete custom workflow definitions. Built-in workflow definitions are protected, malformed IRs are rejected by the central validator, and prompt-injectable lanes strip approval-bypass flags before saving. - `fn_workflow_settings` — read or write typed per-project values for a workflow's declared settings; invalid values reject atomically without partial persistence. @@ -78,7 +79,7 @@ Use this inventory as the documentation map for current workflow behavior: | Explicit empty step dependencies | A heading annotation `(depends:)` or JSON step `"depends": []` means the step has no prerequisites; an absent dependency annotation/key still inherits the legacy previous-step dependency. | This page, [Parallel mode & the `(depends:)` annotation](#parallel-mode--the-depends-annotation). | | Workflow settings values | Setting declarations live in workflow IR; values persist per `(workflowId, projectId)` and resolve as `stored value ?? declaration default`, with invalid/orphaned values dropped from effective settings. | [Settings Reference → Workflow Settings](./settings-reference.md#workflow-settings); editor UX in [Workflow Editor](./workflow-editor.md#settings-panel-definitions-and-values). | | Built-in prompt overrides | Built-in topology stays read-only, but prompt/gate node text can be overridden per `(workflowId, nodeId, projectId)` and reset to shipped defaults. | This page, [Overriding built-in workflow prompts](#overriding-built-in-workflow-prompts); dashboard UX in [Dashboard Guide](./dashboard-guide.md#workflow-selection-and-editor). | -| Agent workflow tools | Agents can list/get/create/update/delete workflows, inspect traits, read/write workflow settings, select workflows for explicit task contexts, and pass `workflow_id` when creating/delegating tasks. Prompt-injectable lanes strip approval-bypass flags on workflow writes. | [Agents](./agents.md#interactive-cli-chat) and [CLI Reference](./cli-reference.md#published-agent-extension-workflow-tools). | +| Agent workflow tools | Agents can list/get/validate/create/update/delete workflows, inspect traits, read/write workflow settings, select workflows for explicit task contexts, and pass `workflow_id` when creating/delegating tasks. `fn_workflow_validate` is read-only and uses the same validator as create/update without persistence. Prompt-injectable lanes strip approval-bypass flags on workflow writes. | [Agents](./agents.md#interactive-cli-chat) and [CLI Reference](./cli-reference.md#published-agent-extension-workflow-tools). | | Routing boundary | Agents may select/change a workflow only for explicit user requests or tasks they created; no-commit markers do not imply Quick fix or any other workflow. | This page, [Selecting workflows](#selecting-workflows); [Agents](./agents.md#interactive-cli-chat). | | Dashboard board/list/graph selection | Board/List/Header/Graph share durable per-project workflow selection; stale saved ids fall back to a valid workflow. Board adds a dashboard-only **All workflows** aggregate and task workflow-name badges; Graph uses **All workflows** for the full active graph. | [Dashboard Guide → Board View](./dashboard-guide.md#board-view), [Graph View](./dashboard-guide.md#graph-view), and [Workflow Selection and Editor](./dashboard-guide.md#workflow-selection-and-editor). | | Create/planning forwarding | Quick-create task creation, Planning Mode, Subtask Breakdown, and the New Task dialog forward the active real workflow id when creating tasks; **All workflows** quick-create chooses a real workflow intake/default column instead of saving a synthetic aggregate id. | [Dashboard Guide → Planning Mode](./dashboard-guide.md#planning-mode). | diff --git a/packages/cli/skill/fusion/SKILL.md b/packages/cli/skill/fusion/SKILL.md index a327aeb774..0797823205 100644 --- a/packages/cli/skill/fusion/SKILL.md +++ b/packages/cli/skill/fusion/SKILL.md @@ -28,7 +28,7 @@ Mission → Milestone → Slice → Feature → Task **Tool categories:** - **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_bypass_review`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_browse_gitlab_project_issues`, `fn_task_import_gitlab_project_issues`, `fn_task_browse_gitlab_group_issues`, `fn_task_import_gitlab_group_issues`, `fn_task_browse_gitlab_merge_requests`, `fn_task_import_gitlab_merge_requests`, `fn_task_plan` -- **Workflow tools** — `fn_workflow_list`, `fn_workflow_get`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`, `fn_workflow_select` +- **Workflow tools** — `fn_workflow_list`, `fn_workflow_get`, `fn_workflow_validate`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`, `fn_workflow_select` - **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues` - **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_list_goals`, `fn_mission_link_goal`, `fn_mission_unlink_goal`, `fn_mission_backfill_assertions`, `fn_mission_delete`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_feature_delete`, `fn_slice_delete`, `fn_milestone_delete`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_update`, `fn_milestone_update` - **Goal tools** — `fn_goal_list`, `fn_goal_create`, `fn_goal_archive`, `fn_goal_show` diff --git a/packages/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index 733b1927bf..0b76549f1f 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -223,6 +223,16 @@ Fetch a Fusion workflow definition by ID, including its resolved workflow IR. |-----------|------|----------|-------------| | `workflow_id` | string | ✓ | The workflow definition ID to fetch (e.g. 'WF-003', or a 'builtin:*' id). Use fn_workflow_list to discover available IDs. | +### fn_workflow_validate + +Dry-run validate a Fusion workflow IR without creating or mutating any workflow. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `workflow_id` | string | — | Workflow definition ID to dry-run validate (e.g. 'WF-003', or a 'builtin:*' id). Use either workflow_id or ir; validation performs no persistence. | +| `ir` | unknown | — | Inline workflow graph (intermediate representation) to dry-run validate. Use either ir or workflow_id; validation performs no persistence. | +| `confirm_policy_escalation` | boolean | — | Set true to confirm that validating column-agent bindings may allow a broader agent policy. This is checked exactly like create/update but never persists anything. | + ### fn_workflow_create Create a custom Fusion workflow definition from a validated workflow IR. diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index 17e5ff51cc..edc96bbbbf 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -14,6 +14,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names |------|---------| | `fn_workflow_list` | List built-in and custom Fusion workflow definitions available in this project. | | `fn_workflow_get` | Fetch a Fusion workflow definition by ID, including its resolved workflow IR. | +| `fn_workflow_validate` | Dry-run validate a Fusion workflow IR without creating or mutating any workflow. | | `fn_workflow_create` | Create a custom Fusion workflow definition from a validated workflow IR. | | `fn_workflow_update` | Update a custom Fusion workflow definition's metadata, IR, or layout. | | `fn_workflow_delete` | Delete a custom Fusion workflow definition; built-in workflows are protected. | diff --git a/packages/cli/src/__tests__/extension-workflow-tools.test.ts b/packages/cli/src/__tests__/extension-workflow-tools.test.ts index 5f4c49ea4b..e71e958ed1 100644 --- a/packages/cli/src/__tests__/extension-workflow-tools.test.ts +++ b/packages/cli/src/__tests__/extension-workflow-tools.test.ts @@ -110,6 +110,7 @@ describe("pi extension workflow authoring tools", () => { expect([...api.tools.keys()].sort()).toEqual(expect.arrayContaining([ "fn_workflow_list", "fn_workflow_get", + "fn_workflow_validate", "fn_workflow_create", "fn_workflow_update", "fn_workflow_delete", diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 758bd1d18b..52116c4edc 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -273,6 +273,7 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega const expected = [ "fn_workflow_list", "fn_workflow_get", + "fn_workflow_validate", "fn_workflow_create", "fn_workflow_update", "fn_workflow_delete", diff --git a/packages/cli/src/__tests__/workflow-docs-current.test.ts b/packages/cli/src/__tests__/workflow-docs-current.test.ts index 8761d03df8..ae41c49d2f 100644 --- a/packages/cli/src/__tests__/workflow-docs-current.test.ts +++ b/packages/cli/src/__tests__/workflow-docs-current.test.ts @@ -48,6 +48,7 @@ describe("workflow documentation current behavior", () => { for (const tool of [ "fn_workflow_list", "fn_workflow_get", + "fn_workflow_validate", "fn_workflow_create", "fn_workflow_update", "fn_workflow_delete", diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 8fdfbc4338..4b8ccbb44c 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -127,6 +127,7 @@ async function loadCommandHandlers() { const { runSettingsExport } = await import("./commands/settings-export.js"); const { runSettingsImport } = await import("./commands/settings-import.js"); const { runMcpList, runMcpAdd, runMcpEdit, runMcpRemove, runMcpEnable, runMcpDisable, runMcpImport, runMcpExport, runMcpValidate } = await import("./commands/mcp.js"); + const { runWorkflowValidate } = await import("./commands/workflow.js"); const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js"); const { runBranchGroupList, runBranchGroupShow, runBranchGroupPromote, runBranchGroupAbandon } = await import("./commands/branch-group.js"); const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js"); @@ -205,6 +206,7 @@ async function loadCommandHandlers() { runMcpImport, runMcpExport, runMcpValidate, + runWorkflowValidate, runGitStatus, runGitFetch, runGitPull, @@ -413,6 +415,8 @@ PR: Export Fusion MCP JSON with secret references only fn mcp validate [--scope ] [--json] Validate MCP definitions without revealing secrets + fn workflow validate | --file [--json] + Dry-run validate a workflow IR without creating or mutating it fn git status Show current branch, commit, dirty state, ahead/behind fn git push Push current branch @@ -719,6 +723,7 @@ async function main() { runMcpImport, runMcpExport, runMcpValidate, + runWorkflowValidate, runGitStatus, runGitFetch, runGitPull, @@ -1803,6 +1808,23 @@ async function main() { break; } + case "workflow": { + const subcommand = args[1]; + switch (subcommand) { + case "validate": { + const file = getFlagValue(args, "--file"); + const workflowId = file ? undefined : args[2]; + await runWorkflowValidate({ workflowId, file, projectName, json: args.includes("--json") }); + break; + } + default: + console.error(`Unknown subcommand: workflow ${subcommand || ""}`); + console.log("Try: fn workflow validate | --file [--json]"); + process.exit(1); + } + break; + } + case "git": { const subcommand = args[1]; diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts new file mode 100644 index 0000000000..d207f79fc5 --- /dev/null +++ b/packages/cli/src/commands/workflow.ts @@ -0,0 +1,80 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { TaskStore } from "@fusion/core"; +import { validateWorkflowIrDryRun } from "@fusion/engine"; +import { getStore } from "../project-resolver.js"; + +export interface RunWorkflowValidateOptions { + workflowId?: string; + file?: string; + projectName?: string; + json?: boolean; +} + +async function resolveStore(projectName?: string): Promise { + try { + return await getStore({ project: projectName }); + } catch (error) { + if (projectName) throw error; + const store = new TaskStore(process.cwd()); + await store.init(); + return store; + } +} + +function printJsonAndExit(payload: unknown, code: number): never { + console.log(JSON.stringify(payload, null, 2)); + process.exit(code); +} + +/** + * FNXC:WorkflowCli 2026-07-12-00:00: + * Workflow authors need a script-friendly dry-run command that can validate a saved workflow id or an IR JSON file without creating or mutating workflow rows. + */ +export async function runWorkflowValidate(opts: RunWorkflowValidateOptions): Promise { + const workflowId = opts.workflowId?.trim(); + if (!workflowId && !opts.file) { + const message = "Usage: fn workflow validate | --file [--json]"; + if (opts.json) printJsonAndExit({ valid: false, error: message }, 2); + console.error(message); + process.exit(2); + } + + let store: TaskStore | undefined; + try { + store = await resolveStore(opts.projectName); + let ir: unknown; + if (opts.file) { + const filePath = resolve(opts.file); + try { + ir = JSON.parse(await readFile(filePath, "utf8")); + } catch (error) { + const message = `Failed to read or parse workflow IR file '${opts.file}': ${error instanceof Error ? error.message : String(error)}`; + if (opts.json) printJsonAndExit({ valid: false, error: message }, 2); + console.error(message); + process.exit(2); + } + } else { + const def = await store.getWorkflowDefinition(workflowId!); + if (!def) { + const message = `Workflow '${workflowId}' not found`; + if (opts.json) printJsonAndExit({ valid: false, error: message }, 2); + console.error(message); + process.exit(2); + } + ir = def.ir; + } + + const result = await validateWorkflowIrDryRun(store, ir, false); + if (opts.json) printJsonAndExit(result.valid ? { valid: true } : { valid: false, errors: result.errors }, result.valid ? 0 : 1); + if (result.valid) { + console.log("✓ Workflow IR is valid. No workflow was created or mutated."); + process.exit(0); + } + console.error("✗ Workflow IR is invalid:"); + for (const error of result.errors) console.error(` - ${error.message}`); + process.exit(1); + } finally { + await store?.close?.().catch(() => {}); + } +} diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 123f4cda39..d8700a5cd2 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -56,6 +56,7 @@ import { createWorkflowAuthoringTools, workflowListParams, workflowGetParams, + workflowValidateParams, workflowSelectParams, workflowCreateParams, workflowUpdateParams, @@ -640,6 +641,7 @@ async function fetchGitHubIssueViaGh( type EngineWorkflowToolName = | "fn_workflow_list" | "fn_workflow_get" + | "fn_workflow_validate" | "fn_workflow_create" | "fn_workflow_update" | "fn_workflow_delete" @@ -671,6 +673,14 @@ const workflowExtensionToolSpecs: Array<{ promptGuidelines: ["Use after fn_workflow_list to inspect the current IR before updating a workflow."], parameters: workflowGetParams, }, + { + name: "fn_workflow_validate", + label: "fn: Validate Workflow", + description: "Dry-run validate a Fusion workflow IR without creating or mutating any workflow.", + promptSnippet: "Validate a Fusion workflow IR without persisting it", + promptGuidelines: ["Use before create/update while iterating on custom workflow IR; validation failures are reported as dry-run results with no persistence."], + parameters: workflowValidateParams, + }, { name: "fn_workflow_create", label: "fn: Create Workflow", diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index 1c984c8370..94a82ce788 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -804,6 +804,7 @@ describe("ChatManager.sendMessage", () => { "fn_workflow_settings", "fn_workflow_list", "fn_workflow_get", + "fn_workflow_validate", "fn_workflow_select", "fn_trait_list", ]) { diff --git a/packages/dashboard/src/__tests__/chat.rooms.test.ts b/packages/dashboard/src/__tests__/chat.rooms.test.ts index d522092bfb..c83cae1d7c 100644 --- a/packages/dashboard/src/__tests__/chat.rooms.test.ts +++ b/packages/dashboard/src/__tests__/chat.rooms.test.ts @@ -179,6 +179,7 @@ describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => { "fn_workflow_settings", "fn_workflow_list", "fn_workflow_get", + "fn_workflow_validate", "fn_workflow_select", "fn_trait_list", ])); diff --git a/packages/dashboard/src/__tests__/planning-document-tools-exposure.test.ts b/packages/dashboard/src/__tests__/planning-document-tools-exposure.test.ts index b1d22e3653..21a93c350d 100644 --- a/packages/dashboard/src/__tests__/planning-document-tools-exposure.test.ts +++ b/packages/dashboard/src/__tests__/planning-document-tools-exposure.test.ts @@ -38,6 +38,7 @@ async function waitFor(condition: () => boolean): Promise { const REQUIRED_WORKFLOW_AUTHORING_TOOLS = [ "fn_workflow_list", "fn_workflow_get", + "fn_workflow_validate", "fn_workflow_select", "fn_workflow_create", "fn_workflow_update", diff --git a/packages/dashboard/src/routes/__tests__/workflow-validate-route.test.ts b/packages/dashboard/src/routes/__tests__/workflow-validate-route.test.ts new file mode 100644 index 0000000000..47d073933e --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/workflow-validate-route.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment node + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import express from "express"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TaskStore } from "@fusion/core"; +import type { WorkflowIr } from "@fusion/core"; +import { registerWorkflowRoutes } from "../register-workflow-routes.js"; +import { ApiError, sendErrorResponse } from "../../api-error.js"; +import { request } from "../../test-request.js"; + +function linearIr(): WorkflowIr { + return { + version: "v1", + name: "valid", + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end", condition: "success" }], + } as WorkflowIr; +} + +describe("POST /api/workflows/validate", () => { + let store: TaskStore; + let rootDir: string; + let globalDir: string; + let app: express.Express; + + beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "wf-validate-root-")); + globalDir = mkdtempSync(join(tmpdir(), "wf-validate-global-")); + store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); + await store.init(); + + app = express(); + app.use(express.json()); + const router = express.Router(); + registerWorkflowRoutes({ + router, + getProjectContext: async () => ({ store, engine: undefined, projectId: undefined }), + rethrowAsApiError: (err: unknown) => { + throw err instanceof ApiError ? err : new ApiError(500, err instanceof Error ? err.message : String(err)); + }, + options: {}, + } as unknown as Parameters[0]); + app.use("/api", router); + app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + if (err instanceof ApiError) sendErrorResponse(res, err.statusCode, err.message, { details: err.details }); + else sendErrorResponse(res, 500, err instanceof Error ? err.message : String(err)); + }); + }); + + afterEach(async () => { + await store.close?.(); + rmSync(rootDir, { recursive: true, force: true }); + rmSync(globalDir, { recursive: true, force: true }); + }); + + async function userDefCount(): Promise { + return (await store.listWorkflowDefinitions()).filter((wf) => wf.kind === "custom").length; + } + + async function postValidate(body: unknown) { + return request(app, "POST", "/api/workflows/validate", JSON.stringify(body), { "content-type": "application/json" }); + } + + it("returns valid true for an inline IR without persisting a workflow", async () => { + const before = await userDefCount(); + const res = await postValidate({ ir: linearIr() }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ valid: true }); + expect(await userDefCount()).toBe(before); + }); + + it("returns typed validation errors with 200 for malformed IR", async () => { + const before = await userDefCount(); + const ir = { ...linearIr(), nodes: [{ id: "start", kind: "start" }, { id: "start2", kind: "start" }, { id: "end", kind: "end" }] }; + const res = await postValidate({ ir }); + expect(res.status).toBe(200); + expect(res.body.valid).toBe(false); + expect(res.body.errors[0]).toMatchObject({ type: "workflow-ir" }); + expect(await userDefCount()).toBe(before); + }); + + it("validates an existing workflow by id without mutating it", async () => { + const created = await store.createWorkflowDefinition({ name: "Existing", ir: linearIr() }); + const before = await userDefCount(); + const res = await postValidate({ workflowId: created.id }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ valid: true }); + expect(await userDefCount()).toBe(before); + }); + + it("uses 4xx only for request errors", async () => { + expect((await postValidate({})).status).toBe(400); + expect((await postValidate({ workflowId: "WF-NOPE" })).status).toBe(404); + }); +}); diff --git a/packages/dashboard/src/routes/register-workflow-routes.ts b/packages/dashboard/src/routes/register-workflow-routes.ts index 43b936203f..a39a9c0311 100644 --- a/packages/dashboard/src/routes/register-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-workflow-routes.ts @@ -1,6 +1,6 @@ import type { WorkflowDefinition, WorkflowDefinitionKind, WorkflowIr, WorkflowIrNode, WorkflowSettingDefinition, TaskStore } from "@fusion/core"; import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowIrError, ColumnAgentBindingError, WorkflowSettingRejectionError, SCHEMA_VERSION, assertColumnTraitsValid, layoutForIr, listTraits, listStepParsers, parseWorkflowIr, resolvePlanningSettingsModel, stripApprovalBypassFlags, resolveWorkflowIrById, resolveEffectiveSettingValues, findOrphanedSettingValues, isBuiltinWorkflowId, getBuiltinWorkflow, BUILTIN_WORKFLOW_SETTINGS, AgentStore, validateColumnAgentBindings, resolveWorkflowOptionalSteps, enumeratePromptBearingWorkflowNodes, normalizeWorkflowIcon } from "@fusion/core"; -import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, validateCodeNodeSources } from "@fusion/engine"; +import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, validateCodeNodeSources, validateWorkflowIrDryRun } from "@fusion/engine"; import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js"; import { emitWorkflowSseEvent } from "../sse.js"; import type { ApiRoutesContext } from "./types.js"; @@ -307,6 +307,31 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { } }); + /** + * FNXC:WorkflowRoutes 2026-07-12-00:00: + * Workflow validation callers need the create/update validator as a read-only dry run: invalid IR returns a typed validation payload, while persistence and workflow SSE events are forbidden. + */ + router.post("/workflows/validate", async (req, res) => { + try { + const { store } = await getProjectContext(req); + const body = req.body ?? {}; + const workflowId = typeof body.workflowId === "string" ? body.workflowId.trim() : ""; + let ir: unknown; + if (workflowId) { + const def = await store.getWorkflowDefinition(workflowId); + if (!def) throw notFound(`Workflow '${workflowId}' not found`); + ir = def.ir; + } else { + ir = requireIr(body); + } + const result = await validateWorkflowIrDryRun(store, ir, body.confirmPolicyEscalation === true); + res.status(200).json(result.valid ? { valid: true } : { valid: false, errors: result.errors }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + // POST /api/workflows — create a workflow. Body: { name, description?, ir, layout? } router.post("/workflows", async (req, res) => { try { diff --git a/packages/engine/src/__tests__/agent-action-gate.test.ts b/packages/engine/src/__tests__/agent-action-gate.test.ts index e13cf0179e..88c3c3e4e7 100644 --- a/packages/engine/src/__tests__/agent-action-gate.test.ts +++ b/packages/engine/src/__tests__/agent-action-gate.test.ts @@ -350,7 +350,7 @@ describe("agent-action-gate", () => { }); }); - it.each(["fn_workflow_list", "fn_workflow_get", "fn_trait_list"] as const)("allows workflow discovery tool %s as a known coordination exemption", (toolName) => { + it.each(["fn_workflow_list", "fn_workflow_get", "fn_workflow_validate", "fn_trait_list"] as const)("allows workflow discovery tool %s as a known coordination exemption", (toolName) => { expect(evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: lockedDownPolicy })).toMatchObject({ category: "exempt", disposition: "allow", diff --git a/packages/engine/src/__tests__/agent-workflow-tools-exposure.test.ts b/packages/engine/src/__tests__/agent-workflow-tools-exposure.test.ts index 38ca9aafa9..dbb1228287 100644 --- a/packages/engine/src/__tests__/agent-workflow-tools-exposure.test.ts +++ b/packages/engine/src/__tests__/agent-workflow-tools-exposure.test.ts @@ -1,15 +1,19 @@ import { describe, it, expect } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { createWorkflowAuthoringTools, createWorkflowListTool, createWorkflowGetTool, + createWorkflowValidateTool, createWorkflowSelectTool, createWorkflowCreateTool, createWorkflowUpdateTool, createWorkflowDeleteTool, } from "../index.js"; import { createWorkflowSettingsTool } from "../agent-tools.js"; -import type { TaskStore } from "@fusion/core"; +import { parseWorkflowIr, TaskStore } from "@fusion/core"; /** * U11 / R12 drift guard (engine half): the workflow-authoring tool surface that @@ -28,6 +32,7 @@ import type { TaskStore } from "@fusion/core"; const REQUIRED_WORKFLOW_TOOLS = [ "fn_workflow_list", "fn_workflow_get", + "fn_workflow_validate", "fn_workflow_select", "fn_workflow_create", "fn_workflow_update", @@ -49,6 +54,7 @@ describe("workflow tool exposure (engine factories)", () => { 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(createWorkflowValidateTool(fakeStore).name).toBe("fn_workflow_validate"); 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"); @@ -63,6 +69,68 @@ describe("workflow tool exposure (engine factories)", () => { * prompt-injectable agent lane. The executor lane omits the option (project- * owner escape hatch) and the flags pass through unchanged. */ +describe("fn_workflow_validate dry-run", () => { + const run = (tool: { execute: (...a: any[]) => Promise }, params: unknown) => + tool.execute("call-1", params, undefined, undefined, undefined) as Promise<{ + isError?: boolean; details: any; content: { type: string; text?: string }[]; + }>; + + const validIr = () => ({ + version: "v1" as const, + name: "Valid", + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end", condition: "success" as const }], + }); + + async function withStore(fn: (store: TaskStore) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), "workflow-validate-tool-")); + const store = new TaskStore(dir); + await store.init(); + try { + return await fn(store); + } finally { + await store.close?.().catch(() => {}); + await rm(dir, { recursive: true, force: true }); + } + } + + it("returns valid true for a valid IR and creates no workflow row", async () => { + await withStore(async (store) => { + const before = await store.listWorkflowDefinitions(); + const res = await run(createWorkflowValidateTool(store), { ir: validIr() }); + const after = await store.listWorkflowDefinitions(); + expect(res.isError).toBeFalsy(); + expect(res.details).toEqual({ valid: true }); + expect(after).toHaveLength(before.length); + }); + }); + + it("returns valid false with the same WorkflowIrError message create parsing would throw", async () => { + await withStore(async (store) => { + const malformed = { ...validIr(), nodes: [{ id: "start", kind: "start" }, { id: "start2", kind: "start" }, { id: "end", kind: "end" }] }; + let expected = ""; + try { parseWorkflowIr(malformed); } catch (err) { expected = err instanceof Error ? err.message : String(err); } + const res = await run(createWorkflowValidateTool(store), { ir: malformed }); + expect(res.isError).toBeFalsy(); + expect(res.details.valid).toBe(false); + expect(res.details.errors[0]).toMatchObject({ type: "workflow-ir", message: expected }); + }); + }); + + it("treats missing input and unknown workflow ids as tool errors", async () => { + await withStore(async (store) => { + const tool = createWorkflowValidateTool(store); + expect((await run(tool, {})).isError).toBe(true); + const missing = await run(tool, { workflow_id: "WF-NOPE" }); + expect(missing.isError).toBe(true); + expect(missing.content[0].text).toContain("not found"); + }); + }); +}); + describe("workflow authoring tools approval-flag stripping", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any function captureStore(): { store: TaskStore; captured: { ir?: any } } { diff --git a/packages/engine/src/__tests__/gating-classifications.test.ts b/packages/engine/src/__tests__/gating-classifications.test.ts index 26731b97d7..9a4f66d4b9 100644 --- a/packages/engine/src/__tests__/gating-classifications.test.ts +++ b/packages/engine/src/__tests__/gating-classifications.test.ts @@ -160,6 +160,7 @@ describe("gating-classifications parity", () => { "fn_update_identity", "fn_workflow_get", "fn_workflow_list", + "fn_workflow_validate", "grep", "ls", "read", @@ -467,7 +468,7 @@ describe("gating-classifications parity", () => { expect(permanent).toMatchObject({ category, disposition: "block", recognized: true }); }); - it.each(["fn_workflow_list", "fn_workflow_get", "fn_trait_list"] as const)("recognizes %s as read-only coordination instead of an unknown fallback", (toolName) => { + it.each(["fn_workflow_list", "fn_workflow_get", "fn_workflow_validate", "fn_trait_list"] as const)("recognizes %s as read-only coordination instead of an unknown fallback", (toolName) => { const permanent = classifyPermanentAgentToolCall(toolName); const action = evaluateAgentActionGate({ agentId: "a1", diff --git a/packages/engine/src/__tests__/heartbeat-executor.test.ts b/packages/engine/src/__tests__/heartbeat-executor.test.ts index 346b3b1630..de976c499e 100644 --- a/packages/engine/src/__tests__/heartbeat-executor.test.ts +++ b/packages/engine/src/__tests__/heartbeat-executor.test.ts @@ -3055,7 +3055,7 @@ describe("executeHeartbeat", () => { expect(callArgs.tools).toBe("coding"); // fn_artifact_register/list/view, agent config/provisioning, goals/evaluations/identity, // task read discovery, workflow discovery/authoring, task promotion, bounded research, clarification, web fetch, memory, and fn_heartbeat_done. - expect(callArgs.customTools).toHaveLength(39); + expect(callArgs.customTools).toHaveLength(40); expect(callArgs.customTools![0]!.name).toBe("fn_task_create"); expect(callArgs.customTools![1]!.name).toBe("fn_task_log"); expect(callArgs.customTools![2]!.name).toBe("fn_task_document_write"); @@ -3078,24 +3078,25 @@ describe("executeHeartbeat", () => { expect(callArgs.customTools![19]!.name).toBe("fn_task_search"); expect(callArgs.customTools![20]!.name).toBe("fn_workflow_list"); expect(callArgs.customTools![21]!.name).toBe("fn_workflow_get"); - expect(callArgs.customTools![22]!.name).toBe("fn_workflow_create"); - expect(callArgs.customTools![23]!.name).toBe("fn_workflow_update"); - expect(callArgs.customTools![24]!.name).toBe("fn_workflow_delete"); - expect(callArgs.customTools![25]!.name).toBe("fn_workflow_settings"); - expect(callArgs.customTools![26]!.name).toBe("fn_trait_list"); - expect(callArgs.customTools![27]!.name).toBe("fn_ask_question"); - expect(callArgs.customTools![28]!.name).toBe("fn_research_run"); - expect(callArgs.customTools![29]!.name).toBe("fn_research_list"); - expect(callArgs.customTools![30]!.name).toBe("fn_research_get"); - expect(callArgs.customTools![31]!.name).toBe("fn_research_cancel"); - expect(callArgs.customTools![32]!.name).toBe("fn_workflow_select"); - expect(callArgs.customTools![33]!.name).toBe("fn_task_promote"); - expect(callArgs.customTools![34]!.name).toBe("fn_web_fetch"); - expect(callArgs.customTools![35]!.name).toBe("fn_memory_search"); - expect(callArgs.customTools![36]!.name).toBe("fn_memory_get"); - expect(callArgs.customTools![37]!.name).toBe("fn_memory_append"); + expect(callArgs.customTools![22]!.name).toBe("fn_workflow_validate"); + expect(callArgs.customTools![23]!.name).toBe("fn_workflow_create"); + expect(callArgs.customTools![24]!.name).toBe("fn_workflow_update"); + expect(callArgs.customTools![25]!.name).toBe("fn_workflow_delete"); + expect(callArgs.customTools![26]!.name).toBe("fn_workflow_settings"); + expect(callArgs.customTools![27]!.name).toBe("fn_trait_list"); + expect(callArgs.customTools![28]!.name).toBe("fn_ask_question"); + expect(callArgs.customTools![29]!.name).toBe("fn_research_run"); + expect(callArgs.customTools![30]!.name).toBe("fn_research_list"); + expect(callArgs.customTools![31]!.name).toBe("fn_research_get"); + expect(callArgs.customTools![32]!.name).toBe("fn_research_cancel"); + expect(callArgs.customTools![33]!.name).toBe("fn_workflow_select"); + expect(callArgs.customTools![34]!.name).toBe("fn_task_promote"); + expect(callArgs.customTools![35]!.name).toBe("fn_web_fetch"); + expect(callArgs.customTools![36]!.name).toBe("fn_memory_search"); + expect(callArgs.customTools![37]!.name).toBe("fn_memory_get"); + expect(callArgs.customTools![38]!.name).toBe("fn_memory_append"); // fn_heartbeat_done is last (terminal tool) - expect(callArgs.customTools![38]!.name).toBe("fn_heartbeat_done"); + expect(callArgs.customTools![39]!.name).toBe("fn_heartbeat_done"); }); it("loads workspace memory into system prompt and identity snapshot when inline memory is empty", async () => { diff --git a/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts b/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts index 91317e6297..9246d181c0 100644 --- a/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts +++ b/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts @@ -147,6 +147,7 @@ describe("createHeartbeatTools", () => { "fn_reflect_on_performance", "fn_workflow_list", "fn_workflow_get", + "fn_workflow_validate", "fn_workflow_create", "fn_workflow_update", "fn_workflow_delete", @@ -198,7 +199,7 @@ describe("createHeartbeatTools", () => { const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001"); - expect(tools).toHaveLength(34); + expect(tools).toHaveLength(35); expect(tools[0]!.name).toBe("fn_task_create"); expect(tools[1]!.name).toBe("fn_task_log"); expect(tools[2]!.name).toBe("fn_task_document_write"); @@ -222,6 +223,7 @@ describe("createHeartbeatTools", () => { "fn_task_search", "fn_workflow_list", "fn_workflow_get", + "fn_workflow_validate", "fn_workflow_create", "fn_workflow_update", "fn_workflow_delete", @@ -879,6 +881,7 @@ describe("no-task heartbeat tool surface", () => { "fn_artifact_view", "fn_workflow_list", "fn_workflow_get", + "fn_workflow_validate", "fn_workflow_create", "fn_workflow_update", "fn_workflow_delete", diff --git a/packages/engine/src/__tests__/permanent-agent-gating.test.ts b/packages/engine/src/__tests__/permanent-agent-gating.test.ts index 05e3e91847..e28997ff85 100644 --- a/packages/engine/src/__tests__/permanent-agent-gating.test.ts +++ b/packages/engine/src/__tests__/permanent-agent-gating.test.ts @@ -141,7 +141,7 @@ describe("permanent-agent-gating", () => { expect(decision).toMatchObject({ category, recognized: true, disposition: "block" }); }); - it.each(["fn_workflow_list", "fn_workflow_get", "fn_trait_list"] as const)("classifies %s as recognized readonly", (toolName) => { + it.each(["fn_workflow_list", "fn_workflow_get", "fn_workflow_validate", "fn_trait_list"] as const)("classifies %s as recognized readonly", (toolName) => { expect(classifyPermanentAgentToolCall(toolName)).toEqual({ category: "none", recognized: true }); }); diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 3ef0f2ff11..b4c37ac945 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -23,7 +23,7 @@ import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgen import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Type, type Static } from "@earendil-works/pi-ai"; import { createHash } from "node:crypto"; -import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createTaskReadTools, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, createWorkflowListTool, createWorkflowGetTool, createWorkflowSelectTool, createTaskPromoteTool, createWorkflowCreateTool, createWorkflowUpdateTool, createWorkflowDeleteTool, createWorkflowSettingsTool, createTraitListTool, createAskQuestionTool, createResearchTools, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js"; +import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createTaskReadTools, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, createWorkflowListTool, createWorkflowGetTool, createWorkflowValidateTool, createWorkflowSelectTool, createTaskPromoteTool, createWorkflowCreateTool, createWorkflowUpdateTool, createWorkflowDeleteTool, createWorkflowSettingsTool, createTraitListTool, createAskQuestionTool, createResearchTools, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js"; import { AgentLogger } from "./agent-logger.js"; import { resolveAgentInstructionsWithRatings, @@ -561,7 +561,7 @@ You have coding-capable workspace tools (read/write/edit/bash within worktree bo - fn_artifact_register, fn_artifact_list, and fn_artifact_view (register visual/media outputs so they appear in the dashboard Artifacts gallery: screenshots/wireframes/mockups/diagrams as type="image" via \`path\`; screen recordings as type="video" via \`path\`; HTML mockups as type="document" with mimeType="text/html" — rendered as live previews; PDFs as type="document" with mimeType="application/pdf" via \`path\`. No-task runs have no session workspace directory, so save files under the OS temp directory and pass an absolute \`path\` — relative paths are rejected in this mode) - fn_read_evaluations and fn_update_identity (available in no-task runs) - fn_reflect_on_performance when reflection is enabled for this run -- fn_workflow_list, fn_workflow_get, fn_workflow_create, fn_workflow_update, fn_workflow_delete, fn_workflow_settings, and fn_trait_list for workflow discovery/authoring +- fn_workflow_list, fn_workflow_get, fn_workflow_validate, fn_workflow_create, fn_workflow_update, fn_workflow_delete, fn_workflow_settings, and fn_trait_list for workflow discovery/authoring - fn_research_run, fn_research_list, fn_research_get, and fn_research_cancel for bounded research when configured - fn_ask_question to ask the dashboard user for structured clarification - fn_web_fetch @@ -3780,6 +3780,7 @@ export class HeartbeatMonitor { ...createTaskReadTools(taskStore), createWorkflowListTool(taskStore), createWorkflowGetTool(taskStore), + createWorkflowValidateTool(taskStore), createWorkflowCreateTool(taskStore, { stripApprovalFlags: true }), createWorkflowUpdateTool(taskStore, { stripApprovalFlags: true }), createWorkflowDeleteTool(taskStore), diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index eaa45be0b0..e467548ac3 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -13,8 +13,8 @@ import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { extname, isAbsolute, join, relative, resolve, sep } from "node:path"; import * as fusionCore from "@fusion/core"; -import type { AgentState, AgentCapability, AgentUpdateInput, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus } from "@fusion/core"; -import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS, formatCurrentTaskLine, normalizeWorkflowIcon } from "@fusion/core"; +import type { AgentState, AgentCapability, AgentUpdateInput, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus, WorkflowIrNode } from "@fusion/core"; +import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS, formatCurrentTaskLine, normalizeWorkflowIcon, parseWorkflowIr, WorkflowIrError, assertColumnTraitsValid, ColumnTraitValidationError } from "@fusion/core"; import { promoteHeldTask } from "./hold-release.js"; import { DASHBOARD_USER_ID, dailyMemoryPath, ensureOpenClawMemoryFiles, evaluateImplementationTaskBind, extractAgentProvisioningRequest, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core"; import { ResearchOrchestrator } from "./research-orchestrator.js"; @@ -31,6 +31,7 @@ import { MessageDeliveryAutoRecoveryHandler } from "./auto-recovery-handlers/mes import { emitGoalRetrievalAudit } from "./goal-anchoring-audit.js"; import { recordRetry } from "./retry-burned-logger.js"; import { acquireWorkspaceRepoWorktree, WorkspaceRepoAcquireBusyError } from "./worktree-acquisition.js"; +import { validateCodeNodeSources } from "./code-node-runner.js"; // ── Tool parameter schemas (canonical definitions) ──────────────────────── @@ -230,6 +231,30 @@ export const workflowCreateParams = Type.Object({ ), }); +export const workflowValidateParams = Type.Object({ + workflow_id: Type.Optional( + Type.String({ + description: + "Workflow definition ID to dry-run validate (e.g. 'WF-003', or a 'builtin:*' id). " + + "Use either workflow_id or ir; validation performs no persistence.", + }), + ), + ir: Type.Optional( + Type.Unknown({ + description: + "Inline workflow graph (intermediate representation) to dry-run validate. " + + "Use either ir or workflow_id; validation performs no persistence.", + }), + ), + confirm_policy_escalation: Type.Optional( + Type.Boolean({ + description: + "Set true to confirm that validating column-agent bindings may allow a broader agent policy. " + + "This is checked exactly like create/update but never persists anything.", + }), + ), +}); + export const workflowUpdateParams = Type.Object({ workflow_id: Type.String({ description: "The workflow definition ID to update (built-ins cannot be edited)." }), name: Type.Optional(Type.String({ description: "New name." })), @@ -2319,6 +2344,116 @@ function columnAgentBindingErrorResult(err: ColumnAgentBindingError) { }; } +export type WorkflowValidateDryRunError = + | { type: "workflow-ir"; message: string } + | { type: "column-traits"; message: string; violations: unknown[] } + | { type: "code-node"; message: string; codeNodeErrors: unknown[] } + | { type: "column-agent"; message: string; columnId: string; agentId?: string; reason?: string; policyEscalation?: boolean }; + +function workflowValidationErrorFromUnknown(err: unknown): WorkflowValidateDryRunError | undefined { + if (err instanceof WorkflowIrError) return { type: "workflow-ir", message: err.message }; + if (err instanceof ColumnTraitValidationError) { + return { type: "column-traits", message: err.message, violations: err.violations }; + } + if (err instanceof ColumnAgentBindingError) { + return { + type: "column-agent", + message: err.message, + columnId: err.columnId, + agentId: err.agentId, + reason: err.reason, + ...(err.reason === "policy-escalation" ? { policyEscalation: true } : {}), + }; + } + return undefined; +} + +/** + * FNXC:WorkflowAuthoringTools 2026-07-12-00:00: + * Workflow authors need a no-persistence dry run that executes the same IR, trait, code-node, and column-agent checks used before create/update persistence. + * Keep validation failures as successful dry-run results so agents can iterate on malformed graphs without mutating workflow rows. + */ +export async function validateWorkflowIrDryRun( + store: TaskStore, + ir: unknown, + confirmPolicyEscalation = false, +): Promise<{ valid: true } | { valid: false; errors: WorkflowValidateDryRunError[] }> { + try { + const parsed = parseWorkflowIr(ir as Parameters[0]); + if (parsed.version === "v2") assertColumnTraitsValid(parsed.columns); + const codeNodeFailures = await validateCodeNodeSources({ nodes: parsed.nodes as WorkflowIrNode[] }); + if (codeNodeFailures.length > 0) { + return { + valid: false, + errors: [{ + type: "code-node", + message: `Workflow has ${codeNodeFailures.length} code node(s) that failed to compile`, + codeNodeErrors: codeNodeFailures, + }], + }; + } + await assertWorkflowColumnAgentBindings(store, parsed, confirmPolicyEscalation); + return { valid: true }; + } catch (err: unknown) { + const validationError = workflowValidationErrorFromUnknown(err); + if (validationError) return { valid: false, errors: [validationError] }; + throw err; + } +} + +export function createWorkflowValidateTool(store: TaskStore): ToolDefinition { + return { + name: "fn_workflow_validate", + label: "Validate Workflow", + description: + "Dry-run validate a workflow IR by workflow_id or inline ir without creating or mutating any workflow. " + + "Runs the same server-side IR, trait, code-node, and column-agent validation as create/update and returns typed errors.", + parameters: workflowValidateParams, + execute: async (_id: string, params: Static) => { + try { + const workflowId = params.workflow_id?.trim(); + if (!workflowId && params.ir === undefined) { + return { + content: [{ type: "text" as const, text: "ERROR: workflow_id or ir is required." }], + details: { error: "missing-input" }, + isError: true, + }; + } + let ir = params.ir; + if (workflowId) { + const def = await store.getWorkflowDefinition(workflowId); + if (!def) { + return { + content: [{ type: "text" as const, text: `ERROR: Workflow '${workflowId}' not found.` }], + details: { workflowId }, + isError: true, + }; + } + ir = def.ir; + } + const result = await validateWorkflowIrDryRun(store, ir, params.confirm_policy_escalation === true); + if (result.valid) { + return { + content: [{ type: "text" as const, text: "IR is valid. No workflow was created or mutated." }], + details: { valid: true, ...(workflowId ? { workflowId } : {}) }, + }; + } + return { + content: [{ type: "text" as const, text: `IR is invalid: ${result.errors.map((e) => e.message).join("; ")}` }], + details: { valid: false, errors: result.errors, ...(workflowId ? { workflowId } : {}) }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + return { + content: [{ type: "text" as const, text: `ERROR: Failed to validate workflow: ${err?.message ?? err}` }], + details: {}, + isError: true, + }; + } + }, + }; +} + /** * 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. @@ -2744,6 +2879,7 @@ export function createWorkflowAuthoringTools( return [ createWorkflowListTool(store), createWorkflowGetTool(store), + createWorkflowValidateTool(store), createWorkflowSelectTool(store, currentTaskId), createWorkflowCreateTool(store, opts), createWorkflowUpdateTool(store, opts), diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 63cc821da0..159d4a557b 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -222,6 +222,7 @@ import { createTaskLogTool as sharedCreateTaskLogTool, createWorkflowListTool as sharedCreateWorkflowListTool, createWorkflowGetTool as sharedCreateWorkflowGetTool, + createWorkflowValidateTool as sharedCreateWorkflowValidateTool, createWorkflowSelectTool as sharedCreateWorkflowSelectTool, createTaskPromoteTool as sharedCreateTaskPromoteTool, createWorkflowCreateTool as sharedCreateWorkflowCreateTool, @@ -10709,6 +10710,7 @@ export class TaskExecutor { this.createArtifactRegisterTool(assignedAgentId ?? "executor", task.id, worktreePath), this.createWorkflowListTool(), this.createWorkflowGetTool(), + this.createWorkflowValidateTool(), this.createWorkflowSelectTool(task.id), this.createTaskPromoteTool(task.id), this.createWorkflowCreateTool(), @@ -12787,6 +12789,10 @@ export class TaskExecutor { return sharedCreateWorkflowGetTool(this.store); } + private createWorkflowValidateTool(): ToolDefinition { + return sharedCreateWorkflowValidateTool(this.store); + } + private createWorkflowSelectTool(taskId: string): ToolDefinition { return sharedCreateWorkflowSelectTool(this.store, taskId); } diff --git a/packages/engine/src/gating-classifications.ts b/packages/engine/src/gating-classifications.ts index 5747d1d263..f794d9700d 100644 --- a/packages/engine/src/gating-classifications.ts +++ b/packages/engine/src/gating-classifications.ts @@ -164,6 +164,7 @@ export const READONLY_FN_TOOLS: ReadonlySet = new Set([ // FNXC:ToolGovernance 2026-06-29-23:36: Workflow and trait discovery tools are read-only authoring support. Positively classify list/get/trait vocabulary so newly exposed published and prompt-injectable lanes never rely on unknown-tool fallback. "fn_workflow_list", "fn_workflow_get", + "fn_workflow_validate", "fn_trait_list", "fn_mission_list", "fn_mission_show", @@ -225,6 +226,7 @@ export const COORDINATION_EXEMPT_TOOLS = [ "fn_agent_org_chart", "fn_workflow_list", "fn_workflow_get", + "fn_workflow_validate", "fn_trait_list", /** * FNXC:ToolGovernance 2026-06-28-00:00: diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index bfcfbd9743..a78ee0ec2e 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -62,6 +62,7 @@ export { askQuestionParams, workflowListParams, workflowGetParams, + workflowValidateParams, workflowSelectParams, workflowCreateParams, workflowUpdateParams, @@ -69,6 +70,9 @@ export { workflowSettingsParams, traitListParams, executeApprovedAgentProvisioning, + createWorkflowValidateTool, + validateWorkflowIrDryRun, + type WorkflowValidateDryRunError, } from "./agent-tools.js"; export { POSTGRES_MIGRATION_HELP_URL,