FN-7911: add workflow validate dry-run command, tool, and API route

Adds a non-mutating `fn workflow validate` dry-run path across CLI, agent tools, and dashboard API so custom workflow IR can be checked before create/update.

- Add `packages/cli/src/commands/workflow.ts` implementing `fn workflow validate <id> | --file <path>` with JSON/text output, wired into `bin.ts`.
- Add `fn_workflow_validate` agent tool (`agent-tools.ts`, `index.ts`) reusing the existing parseWorkflowIr/trait/code-node/column-agent validation used by create/update, performing no persistence.
- Add `POST /api/workflows/validate` route in `register-workflow-routes.ts` plus dashboard route test coverage.
- Extend heartbeat tool-gating/exposure tests and gating classifications to include `fn_workflow_validate` alongside the other workflow tools.
- Update CLI/agent extension docs (`docs/cli-reference.md`, `docs/agents.md`, `docs/workflow-steps.md`, fusion skill references) to document the new command/tool.
- Add changeset `.changeset/fn-7911-workflow-validate.md` (minor) describing the new capability.

Files changed:
 .changeset/fn-7911-workflow-validate.md            |   7 ++
 docs/agents.md                                     |   5 +-
 docs/cli-reference.md                              |  13 ++
 docs/workflow-steps.md                             |   3 +-
 packages/cli/skill/fusion/SKILL.md                 |   2 +-
 .../cli/skill/fusion/references/extension-tools.md |  10 ++
 .../skill/fusion/references/fusion-capabilities.md |   1 +
 .../src/__tests__/extension-workflow-tools.test.ts |   1 +
 packages/cli/src/__tests__/extension.test.ts       |   1 +
 .../src/__tests__/workflow-docs-current.test.ts    |   1 +
 packages/cli/src/bin.ts                            |  22 ++++
 packages/cli/src/commands/workflow.ts              |  80 ++++++++++++
 packages/cli/src/extension.ts                      |  10 ++
 .../dashboard/src/__tests__/chat-manager.test.ts   |   1 +
 .../dashboard/src/__tests__/chat.rooms.test.ts     |   1 +
 .../planning-document-tools-exposure.test.ts       |   1 +
 .../__tests__/workflow-validate-route.test.ts      | 101 +++++++++++++++
 .../src/routes/register-workflow-routes.ts         |  27 +++-
 .../engine/src/__tests__/agent-action-gate.test.ts |   2 +-
 .../agent-workflow-tools-exposure.test.ts          |  70 ++++++++++-
 .../src/__tests__/gating-classifications.test.ts   |   3 +-
 .../src/__tests__/heartbeat-executor.test.ts       |  37 +++---
 .../src/__tests__/heartbeat-session-prompt.test.ts |   5 +-
 .../src/__tests__/permanent-agent-gating.test.ts   |   2 +-
 packages/engine/src/agent-heartbeat.ts             |   5 +-
 packages/engine/src/agent-tools.ts                 | 140 ++++++++++++++++++++-
 packages/engine/src/executor.ts                    |   6 +
 packages/engine/src/gating-classifications.ts      |   2 +
 packages/engine/src/index.ts                       |   4 +
 29 files changed, 532 insertions(+), 31 deletions(-)

Fusion-Task-Id: FN-7911

Fusion-Task-Lineage: 903d15fe-a7ec-458f-aa34-8f2e895a9603

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 21:39:23 -07:00
parent 8835c6cb48
commit 1ea185daa5
29 changed files with 532 additions and 31 deletions

View File

@@ -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 <id> | --file <path>` CLI command. Reuses the same parseWorkflowIr/trait/code-node/column-agent validation as create/update; performs no persistence.

View File

@@ -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 <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
- 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.

View File

@@ -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 <id> [--json]
fn workflow validate --file <path> [--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

View File

@@ -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). |

View File

@@ -28,7 +28,7 @@ Mission → Milestone → Slice → Feature → Task
**Tool categories:**
<!-- BEGIN: tool-categories (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->
- **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`

View File

@@ -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.

View File

@@ -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. |

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 <global|project|effective>] [--json]
Validate MCP definitions without revealing secrets
fn workflow validate <id> | --file <path> [--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 <id> | --file <path> [--json]");
process.exit(1);
}
break;
}
case "git": {
const subcommand = args[1];

View File

@@ -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<TaskStore> {
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<void> {
const workflowId = opts.workflowId?.trim();
if (!workflowId && !opts.file) {
const message = "Usage: fn workflow validate <id> | --file <path> [--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(() => {});
}
}

View File

@@ -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",

View File

@@ -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",
]) {

View File

@@ -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",
]));

View File

@@ -38,6 +38,7 @@ async function waitFor(condition: () => boolean): Promise<void> {
const REQUIRED_WORKFLOW_AUTHORING_TOOLS = [
"fn_workflow_list",
"fn_workflow_get",
"fn_workflow_validate",
"fn_workflow_select",
"fn_workflow_create",
"fn_workflow_update",

View File

@@ -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<typeof registerWorkflowRoutes>[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<number> {
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);
});
});

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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<any> }, 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<T>(fn: (store: TaskStore) => Promise<T>): Promise<T> {
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 } } {

View File

@@ -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",

View File

@@ -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 () => {

View File

@@ -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",

View File

@@ -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 });
});

View File

@@ -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),

View File

@@ -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<typeof parseWorkflowIr>[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<typeof workflowValidateParams>) => {
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),

View File

@@ -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);
}

View File

@@ -164,6 +164,7 @@ export const READONLY_FN_TOOLS: ReadonlySet<string> = 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:

View File

@@ -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,