FN-6893: add editable built-in workflow prompts

Enable project-scoped prompt overrides for built-in workflows without allowing structural edits.

- Add workflow prompt override storage, normalization, and IR overlay support for prompt and gate nodes.
- Expose dashboard API routes and Workflow Node Editor controls to edit or reset built-in prompts.
- Cover override persistence, route behavior, editor flows, and engine workflow resolution with tests.
- Document editable built-in prompts and reset-to-default behavior.

Files changed:
 docs/dashboard-guide.md                            |   2 +-
 docs/settings-reference.md                         |   2 +
 docs/workflow-steps.md                             |  20 ++-
 .../__tests__/workflow-definition-store.test.ts    |  25 +++
 .../workflow-prompt-overrides-store.test.ts        | 190 ++++++++++++++++++++
 .../__tests__/workflow-prompt-overrides.test.ts    |  58 +++++++
 packages/core/src/db.ts                            |  35 +++-
 packages/core/src/index.ts                         |   7 +
 packages/core/src/store.ts                         |  97 ++++++++++-
 packages/core/src/workflow-ir-resolver.ts          |  21 ++-
 packages/core/src/workflow-prompt-overrides.ts     |  65 +++++++
 packages/dashboard/app/api/legacy.ts               |  34 ++++
 .../app/components/WorkflowNodeEditor.css          |  35 ++++
 .../app/components/WorkflowNodeEditor.tsx          | 191 ++++++++++++++++++++-
 .../__tests__/WorkflowNodeEditor.test.tsx          | 136 ++++++++++++++-
 .../src/__tests__/workflow-routes.test.ts          |  59 +++++++
 .../src/routes/register-workflow-routes.ts         |  86 +++++++++-
 .../workflow-prompt-overrides-resolution.test.ts   |  61 +++++++
 packages/i18n/locales/en/app.json                  |  14 +-
 packages/i18n/locales/es/app.json                  |  14 +-
 packages/i18n/locales/fr/app.json                  |  14 +-
 packages/i18n/locales/ko/app.json                  |  14 +-
 packages/i18n/locales/zh-CN/app.json               |  14 +-
 packages/i18n/locales/zh-TW/app.json               |  14 +-
 24 files changed, 1168 insertions(+), 40 deletions(-)

Fusion-Task-Id: FN-6893

Fusion-Task-Lineage: 961eb119-32e1-44c3-9b51-6edd982fa565
This commit is contained in:
gsxdsm
2026-06-21 21:23:41 -07:00
parent 5b33da9268
commit 26c8d960a2
24 changed files with 1168 additions and 40 deletions

View File

@@ -148,7 +148,7 @@ Navigation:
Behavior:
- Opens a workflow node editor with a workflow list/sidebar, canvas, inspector, and settings/authoring panels
- Read-only built-in workflows are inspectable in the same canvas as custom workflows, including connected success, failure, and rework edges for their graph topology.
- Built-in workflows are inspectable in the same canvas as custom workflows, including connected success, failure, and rework edges for their graph topology. Their graph structure stays read-only, but prompt/gate node Prompt fields can be edited per project and reset to the shipped default from the node inspector or expanded prompt editor.
- Custom workflows can be created from blank, duplicated from built-ins/custom definitions, imported/exported, AI-designed, validated, and saved from the editor.
- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Known workflow model values use the same model dropdown picker as **Settings → Project Models** so provider/model pairs are saved together; custom or non-model string values can still use typed inputs. Definitions remain available for custom workflow schema authoring.
- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; the modal's primary **Save** action writes those dropdown values as workflow setting values for the active default workflow.

View File

@@ -223,6 +223,8 @@ default — so an untuned project behaves exactly as before. Switching a project
**new** custom workflow starts that workflow from its own declaration defaults, not
the project's prior customized values.
**Built-in prompt overrides.** Built-in workflow prompt/gate node text has a similar project-scoped persistence model, but it is separate from workflow settings: prompt overrides are stored per `(workflowId, nodeId, projectId)` and resolve as `stored prompt ?? shipped prompt`. Resetting a prompt deletes the stored node override and restores the built-in IR text; graph structure and setting declarations remain read-only for built-ins. See [Workflow Steps → Overriding built-in workflow prompts](./workflow-steps.md#overriding-built-in-workflow-prompts).
**Agents.** `fn_workflow_create`/`fn_workflow_update` accept `settings` declarations,
and the `fn_workflow_settings` tool reads and writes values with the same typed
validation as the editor (invalid values are rejected, never persisted). See

View File

@@ -45,7 +45,25 @@ Decision-only or investigation tasks can also declare `noCommitsExpected` / `**N
### Custom workflow authoring
Use the dashboard [Workflow Editor](./workflow-editor.md) to inspect read-only built-ins, duplicate them, or author custom workflows. Custom workflows can declare graph nodes and edges, columns/traits, task fields, typed workflow settings, model lanes, optional workflow-step templates, and author-time validation. Use this page for runtime semantics; use the editor guide for the visual authoring surface.
Use the dashboard [Workflow Editor](./workflow-editor.md) to inspect built-ins, tune built-in prompts, duplicate workflows, or author custom workflows. Custom workflows can declare graph nodes and edges, columns/traits, task fields, typed workflow settings, model lanes, optional workflow-step templates, and author-time validation. Use this page for runtime semantics; use the editor guide for the visual authoring surface.
### Overriding built-in workflow prompts
<!--
FNXC:Docs 2026-06-21-21:22:
Built-in workflows stay structurally read-only while prompt/gate node text is project-tunable, so operators need a resettable prompt-override model without implying graph topology edits are allowed.
-->
Built-in workflow graph structure is still shipped and read-only: nodes, edges, columns, traits, executor configuration, and workflow setting declarations cannot be edited in place. Prompt-bearing nodes are the exception. In the workflow editor, select any `prompt` or `gate` node in a built-in workflow and edit its **Prompt** field to create a project-scoped override.
Prompt overrides are stored per `(workflowId, nodeId, projectId)`. At runtime Fusion resolves the effective prompt as:
1. the stored override for that workflow/node/project, when present and non-empty; otherwise
2. the shipped prompt text from the built-in workflow IR.
This same overlay is used by the dashboard preview, seam prompt resolution during live task runs, synchronous workflow IR resolution used by lifecycle movement, and workflow-step materialization for non-seam prompt/gate nodes. Empty or whitespace-only prompt edits are treated as reset/delete operations, never as blank prompts.
Use **Reset to default** on an overridden prompt to delete the stored override and return to the shipped built-in prompt. Duplicating a built-in remains the path when you need to change topology, columns, traits, settings declarations, or non-prompt configuration.
## Workflow IR (v1)

View File

@@ -167,6 +167,31 @@ describe("TaskStore workflow definitions (U1)", () => {
expect((await store.listWorkflowDefinitions()).filter((w) => !isBuiltinWorkflowId(w.id))).toHaveLength(0);
});
it("persists, resets, and cascades workflow prompt overrides", async () => {
const projectId = store.getWorkflowSettingsProjectId();
const created = await store.createWorkflowDefinition({ name: "Promptable", ir: makeIr() });
expect(store.getWorkflowPromptOverrides(created.id, projectId)).toEqual({});
expect(store.updateWorkflowPromptOverrides(created.id, projectId, { lint: "Run a stricter lint review" })).toEqual({
lint: "Run a stricter lint review",
});
expect(store.getWorkflowPromptOverrides(created.id, projectId)).toEqual({
lint: "Run a stricter lint review",
});
expect(
store.updateWorkflowPromptOverrides(created.id, projectId, {
lint: " ",
missing: null,
review: "Review carefully",
}),
).toEqual({ review: "Review carefully" });
expect(store.listWorkflowPromptOverridesForProject()[created.id]).toEqual({ review: "Review carefully" });
await store.deleteWorkflowDefinition(created.id);
expect(store.getWorkflowPromptOverrides(created.id, projectId)).toEqual({});
});
it("throws when deleting a non-existent workflow", async () => {
await expect(store.deleteWorkflowDefinition("WF-999")).rejects.toThrow(/not found/i);
});

View File

@@ -0,0 +1,190 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { getBuiltinWorkflow } from "../builtin-workflows.js";
import { resolveSeamPromptFromIr, resolveWorkflowIrById, resolveWorkflowIrForTask } from "../workflow-ir-resolver.js";
import type { WorkflowIr } from "../workflow-ir-types.js";
function makeIr(): WorkflowIr {
return {
version: "v1",
name: "prompt-overrides-test",
nodes: [
{ id: "start", kind: "start" },
{ id: "lint", kind: "gate", config: { prompt: "Run lint" } },
{ id: "review", kind: "prompt", config: { prompt: "Review carefully" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "lint" },
{ from: "lint", to: "review" },
{ from: "review", to: "end" },
],
};
}
describe("TaskStore workflow prompt overrides", () => {
const harness = createTaskStoreTestHarness();
beforeEach(harness.beforeEach);
afterEach(harness.afterEach);
it("returns an empty map when no override row exists", () => {
const store = harness.store();
expect(store.getWorkflowPromptOverrides("builtin:coding", store.getWorkflowSettingsProjectId())).toEqual({});
});
it("upserts and merges prompt override maps by workflow and project", async () => {
const store = harness.store();
const workflow = await store.createWorkflowDefinition({ name: "Promptable", ir: makeIr() });
const projectId = store.getWorkflowSettingsProjectId();
expect(store.updateWorkflowPromptOverrides(workflow.id, projectId, { lint: "Run a stricter lint" })).toEqual({
lint: "Run a stricter lint",
});
expect(store.updateWorkflowPromptOverrides(workflow.id, projectId, { review: "Review with context" })).toEqual({
lint: "Run a stricter lint",
review: "Review with context",
});
expect(store.getWorkflowPromptOverrides(workflow.id, projectId)).toEqual({
lint: "Run a stricter lint",
review: "Review with context",
});
});
it("treats null, empty, and whitespace values as reset-to-default deletes", async () => {
const store = harness.store();
const workflow = await store.createWorkflowDefinition({ name: "Promptable", ir: makeIr() });
const projectId = store.getWorkflowSettingsProjectId();
store.updateWorkflowPromptOverrides(workflow.id, projectId, {
lint: "Run a stricter lint",
review: "Review with context",
extra: "Extra prompt",
});
expect(
store.updateWorkflowPromptOverrides(workflow.id, projectId, {
lint: null,
review: "",
extra: " ",
}),
).toEqual({});
expect(store.getWorkflowPromptOverrides(workflow.id, projectId)).toEqual({});
});
it("enumerates stored prompt overrides for the current project", async () => {
const store = harness.store();
const first = await store.createWorkflowDefinition({ name: "First", ir: makeIr() });
const second = await store.createWorkflowDefinition({ name: "Second", ir: makeIr() });
const projectId = store.getWorkflowSettingsProjectId();
store.updateWorkflowPromptOverrides(first.id, projectId, { lint: "First lint" });
store.updateWorkflowPromptOverrides(second.id, projectId, { review: "Second review" });
expect(store.listWorkflowPromptOverridesForProject()).toMatchObject({
[first.id]: { lint: "First lint" },
[second.id]: { review: "Second review" },
});
});
it("cascades prompt override rows when a custom workflow is deleted", async () => {
const store = harness.store();
const workflow = await store.createWorkflowDefinition({ name: "Temporary", ir: makeIr() });
const projectId = store.getWorkflowSettingsProjectId();
store.updateWorkflowPromptOverrides(workflow.id, projectId, { lint: "Temporary override" });
await store.deleteWorkflowDefinition(workflow.id);
expect(store.getWorkflowPromptOverrides(workflow.id, projectId)).toEqual({});
expect(store.listWorkflowPromptOverridesForProject()[workflow.id]).toBeUndefined();
});
it("overlays built-in prompt overrides in getWorkflowDefinition without mutating the shared IR", async () => {
const store = harness.store();
const projectId = store.getWorkflowSettingsProjectId();
const before = JSON.stringify(BUILTIN_CODING_WORKFLOW_IR);
store.updateWorkflowPromptOverrides("builtin:coding", projectId, { execute: "Execute from store override" });
const def = await store.getWorkflowDefinition("builtin:coding");
expect(def?.ir.nodes.find((node) => node.id === "execute")?.config?.prompt).toBe("Execute from store override");
expect(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)).toBe(before);
});
it("overlays sync task IR resolution for default and explicitly selected built-ins", async () => {
const store = harness.store();
const projectId = store.getWorkflowSettingsProjectId();
store.updateWorkflowPromptOverrides("builtin:coding", projectId, { execute: "Execute sync override" });
store.updateWorkflowPromptOverrides("builtin:review-heavy", projectId, { security: "Security sync override" });
const defaultTask = await store.createTask({ description: "uses default", workflowId: null });
const explicitTask = await store.createTask({ description: "uses review heavy", workflowId: "builtin:review-heavy" });
const resolveSync = store as unknown as { resolveTaskWorkflowIrSync(taskId: string): WorkflowIr };
expect(resolveSeamPromptFromIr(resolveSync.resolveTaskWorkflowIrSync(defaultTask.id), "execute")).toBe("Execute sync override");
expect(resolveSync.resolveTaskWorkflowIrSync(explicitTask.id).nodes.find((node) => node.id === "security")?.config?.prompt).toBe(
"Security sync override",
);
});
it("overlays public workflow IR resolver paths with project-scoped built-in overrides", async () => {
const store = harness.store();
const projectId = store.getWorkflowSettingsProjectId();
store.updateWorkflowPromptOverrides("builtin:coding", projectId, { execute: "Execute resolver override" });
const task = await store.createTask({ description: "resolver default", workflowId: null });
expect(resolveSeamPromptFromIr(await resolveWorkflowIrById(store, "builtin:coding"), "execute")).toBe(
"Execute resolver override",
);
expect(resolveSeamPromptFromIr(await resolveWorkflowIrForTask(store, task.id), "execute")).toBe(
"Execute resolver override",
);
});
it("materializes built-in non-seam prompt and gate overrides into WorkflowStep rows", async () => {
const store = harness.store();
const projectId = store.getWorkflowSettingsProjectId();
store.updateWorkflowPromptOverrides("builtin:review-heavy", projectId, { security: "Security materialized override" });
store.updateWorkflowPromptOverrides("builtin:compound-engineering", projectId, { plan: "Plan materialized override" });
const reviewTask = await store.createTask({ description: "review heavy", workflowId: "builtin:review-heavy" });
const reviewSteps = await Promise.all((reviewTask.enabledWorkflowSteps ?? []).map((id) => store.getWorkflowStep(id)));
expect(reviewSteps.find((step) => step?.name === "Security review")?.prompt).toBe("Security materialized override");
const ceIr = getBuiltinWorkflow("builtin:compound-engineering")!.ir;
const originalPlan = ceIr.nodes.find((node) => node.id === "plan")?.config?.prompt;
const ceDef = await store.getWorkflowDefinition("builtin:compound-engineering");
// Plugin-gated built-ins may be unavailable through the store in a bare test
// project; the pure overlay test covers CE compilation directly.
if (ceDef) {
const ceTask = await store.createTask({ description: "compound", workflowId: "builtin:compound-engineering" });
const ceSteps = await Promise.all((ceTask.enabledWorkflowSteps ?? []).map((id) => store.getWorkflowStep(id)));
expect(ceSteps.find((step) => step?.name === "Plan")?.prompt).toBe("Plan materialized override");
}
expect(ceIr.nodes.find((node) => node.id === "plan")?.config?.prompt).toBe(originalPlan);
});
it("migration 128 creates the prompt override table and project index on existing databases", async () => {
await harness.reopenDiskBackedStore();
const store = harness.store();
const db = store.getDatabase();
db.prepare("DROP INDEX IF EXISTS idx_workflow_prompt_overrides_project").run();
db.prepare("DROP TABLE IF EXISTS workflow_prompt_overrides").run();
db.prepare("UPDATE __meta SET value = '127' WHERE key = 'schemaVersion'").run();
await harness.reopenDiskBackedStore();
const migratedDb = harness.store().getDatabase();
const table = migratedDb
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'workflow_prompt_overrides'")
.get();
const index = migratedDb
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_workflow_prompt_overrides_project'")
.get();
expect(table).toBeDefined();
expect(index).toBeDefined();
});
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { getBuiltinWorkflow } from "../builtin-workflows.js";
import { compileWorkflowToSteps } from "../workflow-compiler.js";
import {
applyPromptOverridesToIr,
enumeratePromptBearingWorkflowNodes,
normalizeWorkflowPromptOverrides,
} from "../workflow-prompt-overrides.js";
describe("workflow prompt override overlay", () => {
it("normalizes empty and whitespace overrides as absent", () => {
expect(normalizeWorkflowPromptOverrides({ execute: " ", review: "Review tightly", bad: 1 })).toEqual({
review: "Review tightly",
});
});
it("overlays prompt and gate nodes without mutating the shared built-in IR", () => {
const reviewHeavy = getBuiltinWorkflow("builtin:review-heavy")!.ir;
const before = JSON.stringify(reviewHeavy);
const overlaid = applyPromptOverridesToIr(reviewHeavy, {
execute: "Execute override",
security: "Security gate override",
end: "Ignored non-prompt node",
});
expect(overlaid).not.toBe(reviewHeavy);
expect(overlaid.nodes.find((node) => node.id === "execute")?.config?.prompt).toBe("Execute override");
expect(overlaid.nodes.find((node) => node.id === "security")?.config?.prompt).toBe("Security gate override");
expect(JSON.stringify(reviewHeavy)).toBe(before);
});
it("returns the original IR when no override targets a prompt-bearing node", () => {
expect(applyPromptOverridesToIr(BUILTIN_CODING_WORKFLOW_IR, { end: "ignored" })).toBe(BUILTIN_CODING_WORKFLOW_IR);
});
it("enumerates prompt defaults from inline IR prompt text", () => {
const defaults = enumeratePromptBearingWorkflowNodes(getBuiltinWorkflow("builtin:lead-generation")!.ir);
expect(defaults).toEqual(
expect.arrayContaining([
expect.objectContaining({ nodeId: "qualification-gate", kind: "gate" }),
expect.objectContaining({ nodeId: "enrich-lead", kind: "prompt" }),
]),
);
expect(defaults.find((entry) => entry.nodeId === "enrich-lead")?.prompt).toBe(
getBuiltinWorkflow("builtin:lead-generation")!.ir.nodes.find((node) => node.id === "enrich-lead")?.config?.prompt,
);
});
it("bakes non-seam prompt overrides before compilation", () => {
const ce = getBuiltinWorkflow("builtin:compound-engineering")!.ir;
const overlaid = applyPromptOverridesToIr(ce, { plan: "Plan override" });
const steps = compileWorkflowToSteps(overlaid);
expect(steps.find((step) => step.name === "Plan")?.prompt).toBe("Plan override");
});
});

View File

@@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 127;
const SCHEMA_VERSION = 128;
const TASKS_FTS_AUTOMERGE = 8;
const TASKS_FTS_CRISISMERGE = 16;
@@ -682,6 +682,17 @@ CREATE TABLE IF NOT EXISTS workflow_settings (
);
CREATE INDEX IF NOT EXISTS idx_workflow_settings_project ON workflow_settings(projectId);
-- FNXC:CustomWorkflows 2026-06-21-19:07:
-- Built-in workflows keep their graph structure read-only, but users need project-scoped prompt tuning. Store only per-node prompt text overrides here so reset-to-default is a key delete, not an IR mutation.
CREATE TABLE IF NOT EXISTS workflow_prompt_overrides (
workflowId TEXT NOT NULL,
projectId TEXT NOT NULL,
overrides TEXT NOT NULL DEFAULT '{}',
updatedAt TEXT NOT NULL,
PRIMARY KEY (workflowId, projectId)
);
CREATE INDEX IF NOT EXISTS idx_workflow_prompt_overrides_project ON workflow_prompt_overrides(projectId);
-- Task documents (key-value store per task with revision tracking)
CREATE TABLE IF NOT EXISTS task_documents (
id TEXT PRIMARY KEY,
@@ -5255,6 +5266,28 @@ export class Database {
});
}
// Migration 128: Built-in workflow prompt overrides.
// Mirrors workflow_settings: one project-scoped JSON map per workflow id, but
// values are nodeId → prompt overrides. Reset-to-default deletes keys; graph
// structure remains owned by the shipped/custom workflow IR.
// FNXC:CustomWorkflows 2026-06-21-19:07:
// Built-in prompt editing must be a separate per-project authority so users can tune prompts and reset them without lifting the built-in workflow read-only guard.
if (version < 128) {
this.applyMigration(128, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS workflow_prompt_overrides (
workflowId TEXT NOT NULL,
projectId TEXT NOT NULL,
overrides TEXT NOT NULL DEFAULT '{}',
updatedAt TEXT NOT NULL,
PRIMARY KEY (workflowId, projectId)
);
CREATE INDEX IF NOT EXISTS idx_workflow_prompt_overrides_project ON workflow_prompt_overrides(projectId);
`);
});
}
}
/**

View File

@@ -120,6 +120,13 @@ export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
export { BUILTIN_MARKETING_WORKFLOW_IR } from "./builtin-marketing-workflow-ir.js";
export { resolveWorkflowOptionalSteps } from "./workflow-optional-steps.js";
export type { ResolvedWorkflowOptionalStep } from "./workflow-optional-steps.js";
export {
applyPromptOverridesToIr,
enumeratePromptBearingWorkflowNodes,
isPromptBearingWorkflowNode,
normalizeWorkflowPromptOverrides,
} from "./workflow-prompt-overrides.js";
export type { WorkflowPromptDefault, WorkflowPromptOverrides } from "./workflow-prompt-overrides.js";
export { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js";
export { BUILTIN_PR_WORKFLOW_IR } from "./builtin-pr-workflow-ir.js";
export { BUILTIN_LEAD_GENERATION_WORKFLOW_IR } from "./builtin-lead-generation-workflow-ir.js";

View File

@@ -64,6 +64,7 @@ import {
type CustomFieldRejection,
} from "./task-fields.js";
import { validateSettingValuePatch, WorkflowSettingRejectionError } from "./workflow-settings.js";
import { applyPromptOverridesToIr } from "./workflow-prompt-overrides.js";
// Side-effect import: registers the 14 built-in trait DEFINITIONS into the
// shared trait registry on load (the flag-ON path resolves traits by id).
import "./builtin-traits.js";
@@ -8130,6 +8131,88 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
}
}
// ── Built-in workflow prompt overrides (FN-6893) ───────────────────────────
//
// FNXC:CustomWorkflows 2026-06-21-19:07:
// Built-in workflow graphs remain read-only, but prompt-bearing prompt/gate nodes need project-scoped text overrides with reset-to-default. Keep this as a separate authority from updateWorkflowDefinition so structure edits remain blocked.
private parseWorkflowPromptOverrideJson(raw: string | null | undefined): Record<string, string> {
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof value !== "string") continue;
const trimmed = value.trim();
if (trimmed.length === 0) continue;
out[key] = value;
}
return out;
} catch {
return {};
}
}
/** Enumerate every stored prompt override row for THIS project, returned as
* `workflowId → { nodeId: prompt }`. Corrupt rows and blank prompt entries are
* skipped so callers only see runnable override text. */
listWorkflowPromptOverridesForProject(): Record<string, Record<string, string>> {
const projectId = this.getWorkflowSettingsProjectId();
const rows = this.db
.prepare("SELECT workflowId, overrides FROM workflow_prompt_overrides WHERE projectId = ?")
.all(projectId) as Array<{ workflowId: string; overrides: string }>;
const out: Record<string, Record<string, string>> = {};
for (const row of rows) {
out[row.workflowId] = this.parseWorkflowPromptOverrideJson(row.overrides);
}
return out;
}
/** Read the raw stored prompt override map for `(workflowId, projectId)`.
* Returns `{}` when no row exists. Empty/whitespace prompts are treated as
* absent because a blank override would blank an agent run. */
getWorkflowPromptOverrides(workflowId: string, projectId: string): Record<string, string> {
const row = this.db
.prepare("SELECT overrides FROM workflow_prompt_overrides WHERE workflowId = ? AND projectId = ?")
.get(workflowId, projectId) as { overrides: string } | undefined;
return this.parseWorkflowPromptOverrideJson(row?.overrides);
}
/** Merge prompt override updates into `(workflowId, projectId)`. A `null`,
* non-string, empty, or whitespace value deletes that nodeId override, which
* is the reset-to-default operation. */
updateWorkflowPromptOverrides(
workflowId: string,
projectId: string,
patch: Record<string, string | null | undefined>,
): Record<string, string> {
return this.db.transactionImmediate(() => {
const current = this.getWorkflowPromptOverrides(workflowId, projectId);
const next: Record<string, string> = { ...current };
for (const [nodeId, value] of Object.entries(patch)) {
if (typeof value !== "string" || value.trim().length === 0) {
delete next[nodeId];
} else {
next[nodeId] = value;
}
}
const now = new Date().toISOString();
this.db
.prepare(
`INSERT INTO workflow_prompt_overrides (workflowId, projectId, overrides, updatedAt)
VALUES (?, ?, ?, ?)
ON CONFLICT(workflowId, projectId)
DO UPDATE SET overrides = excluded.overrides, updatedAt = excluded.updatedAt`,
)
.run(workflowId, projectId, JSON.stringify(next), now);
this.db.bumpLastModified();
return next;
});
}
/**
* Write setting VALUES for `(workflowId, projectId)`. The patch is validated
* against the NAMED workflow's declarations via {@link validateSettingValuePatch};
@@ -14599,6 +14682,13 @@ ${stepsSection}`;
return this.workflowDefinitionsCache;
}
private applyBuiltInPromptOverridesSync(workflowId: string, ir: WorkflowIr): WorkflowIr {
if (!isBuiltinWorkflowId(workflowId)) return ir;
const projectId = this.getWorkflowSettingsProjectId();
const overrides = this.getWorkflowPromptOverrides(workflowId, projectId);
return applyPromptOverridesToIr(ir, overrides);
}
/** Get a single workflow definition by id, or undefined when absent. */
async getWorkflowDefinition(
id: string,
@@ -14609,7 +14699,7 @@ ${stepsSection}`;
const requiredPluginId = getRequiredPluginIdForBuiltinWorkflow(id);
if (!requiredPluginId || !(await this.isPluginInstalled(requiredPluginId))) return undefined;
}
return builtin;
return { ...builtin, ir: this.applyBuiltInPromptOverridesSync(id, builtin.ir) };
}
const row = this.db.prepare("SELECT * FROM workflows WHERE id = ?").get(id) as
| {
@@ -14807,6 +14897,7 @@ ${stepsSection}`;
// via the resolver and read built-in declarations + built-in values, so no
// unreachable orphan value rows remain.
this.db.prepare("DELETE FROM workflow_settings WHERE workflowId = ?").run(id);
this.db.prepare("DELETE FROM workflow_prompt_overrides WHERE workflowId = ?").run(id);
// Cascade: clear the project default when it pointed at this workflow.
try {
@@ -15581,10 +15672,10 @@ ${stepsSection}`;
private resolveTaskWorkflowIrSync(taskId: string): WorkflowIr {
const selection = this.getTaskWorkflowSelection(taskId);
const workflowId = selection?.workflowId;
if (!workflowId) return BUILTIN_CODING_WORKFLOW_IR;
if (!workflowId) return this.applyBuiltInPromptOverridesSync("builtin:coding", BUILTIN_CODING_WORKFLOW_IR);
if (isBuiltinWorkflowId(workflowId)) {
const builtin = getBuiltinWorkflow(workflowId);
return builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR;
return this.applyBuiltInPromptOverridesSync(workflowId, builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR);
}
try {
const row = this.db

View File

@@ -17,12 +17,15 @@
import { getBuiltinWorkflow, isBuiltinWorkflowId } from "./builtin-workflows.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
import { parseWorkflowIr } from "./workflow-ir.js";
import { applyPromptOverridesToIr } from "./workflow-prompt-overrides.js";
import type { WorkflowIr } from "./workflow-ir-types.js";
/** Minimal store surface the resolver needs (public APIs only). */
export interface WorkflowIrResolverStore {
getTaskWorkflowSelection(taskId: string): { workflowId: string; stepIds: string[] } | undefined;
getWorkflowDefinition(id: string): Promise<{ ir: string | WorkflowIr } | undefined>;
getWorkflowSettingsProjectId?(): string;
getWorkflowPromptOverrides?(workflowId: string, projectId: string): Record<string, string>;
}
/**
@@ -80,26 +83,32 @@ export async function resolveTaskPlanningPrompt(
* sweep. Hits short-circuit before any builtin/db lookup.
*/
export async function resolveWorkflowIrById(
store: Pick<WorkflowIrResolverStore, "getWorkflowDefinition">,
store: Pick<WorkflowIrResolverStore, "getWorkflowDefinition"> & Partial<Pick<WorkflowIrResolverStore, "getWorkflowSettingsProjectId" | "getWorkflowPromptOverrides">>,
workflowId: string,
irCache?: Map<string, WorkflowIr>,
): Promise<WorkflowIr> {
const cached = irCache?.get(workflowId);
const projectId = store.getWorkflowSettingsProjectId?.();
const cacheKey = projectId ? `${workflowId}\u0000${projectId}` : workflowId;
const cached = irCache?.get(cacheKey);
if (cached) return cached;
if (isBuiltinWorkflowId(workflowId)) {
const builtin = getBuiltinWorkflow(workflowId);
const ir = builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR;
const resolved = typeof ir === "string" ? parseWorkflowIr(ir) : ir;
irCache?.set(workflowId, resolved);
return resolved;
const overrides = projectId ? store.getWorkflowPromptOverrides?.(workflowId, projectId) : undefined;
// FNXC:CustomWorkflows 2026-06-21-19:12:
// Public IR resolution must see the same project-scoped built-in prompt overrides as task execution, while callers without the new store methods keep the canonical built-in IR.
const effective = applyPromptOverridesToIr(resolved, overrides);
irCache?.set(cacheKey, effective);
return effective;
}
try {
const def = await store.getWorkflowDefinition(workflowId);
if (!def) return BUILTIN_CODING_WORKFLOW_IR;
const ir = typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir;
irCache?.set(workflowId, ir);
irCache?.set(cacheKey, ir);
return ir;
} catch {
return BUILTIN_CODING_WORKFLOW_IR;
@@ -121,6 +130,6 @@ export async function resolveWorkflowIrForTask(
} catch {
return BUILTIN_CODING_WORKFLOW_IR;
}
if (!workflowId) return BUILTIN_CODING_WORKFLOW_IR;
if (!workflowId) return resolveWorkflowIrById(store, "builtin:coding", irCache);
return resolveWorkflowIrById(store, workflowId, irCache);
}

View File

@@ -0,0 +1,65 @@
import type { WorkflowIr, WorkflowIrNode } from "./workflow-ir-types.js";
export type WorkflowPromptOverrides = Record<string, string>;
export interface WorkflowPromptDefault {
nodeId: string;
kind: "prompt" | "gate";
prompt: string;
}
function normalizePromptOverride(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
return value.trim().length > 0 ? value : undefined;
}
export function normalizeWorkflowPromptOverrides(overrides: Record<string, unknown> | undefined): WorkflowPromptOverrides {
const normalized: WorkflowPromptOverrides = {};
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) return normalized;
for (const [nodeId, value] of Object.entries(overrides)) {
const prompt = normalizePromptOverride(value);
if (prompt !== undefined) normalized[nodeId] = prompt;
}
return normalized;
}
export function isPromptBearingWorkflowNode(node: WorkflowIrNode): node is WorkflowIrNode & { kind: "prompt" | "gate" } {
return node.kind === "prompt" || node.kind === "gate";
}
export function enumeratePromptBearingWorkflowNodes(ir: WorkflowIr): WorkflowPromptDefault[] {
const defaults: WorkflowPromptDefault[] = [];
for (const node of ir.nodes) {
if (!isPromptBearingWorkflowNode(node)) continue;
const prompt = node.config?.prompt;
if (typeof prompt !== "string") continue;
defaults.push({ nodeId: node.id, kind: node.kind, prompt });
}
return defaults;
}
/**
* FNXC:CustomWorkflows 2026-06-21-19:10:
* Built-in prompt overrides must overlay effective IRs without mutating shipped built-in objects. Return the original IR when no non-empty override targets a prompt/gate node; otherwise clone only the graph shell and changed node/config records.
*/
export function applyPromptOverridesToIr(ir: WorkflowIr, overrides: Record<string, unknown> | undefined): WorkflowIr {
const normalized = normalizeWorkflowPromptOverrides(overrides);
if (Object.keys(normalized).length === 0) return ir;
let changed = false;
const nodes = ir.nodes.map((node) => {
if (!isPromptBearingWorkflowNode(node)) return node;
const override = normalized[node.id];
if (override === undefined) return node;
changed = true;
return {
...node,
config: {
...(node.config ?? {}),
prompt: override,
},
};
});
return changed ? ({ ...ir, nodes } as WorkflowIr) : ir;
}

View File

@@ -5232,6 +5232,15 @@ export interface WorkflowSettingValuesPayload {
orphaned: Array<{ id: string; value: unknown }>;
}
/** Per-project workflow prompt override payload. `defaults` is the shipped prompt
* by node id, `stored` is the persisted override map, and `effective` is the
* prompt text the editor/executor sees after stored-over-default resolution. */
export interface WorkflowPromptOverridesPayload {
stored: Record<string, string>;
effective: Record<string, string>;
defaults: Record<string, string>;
}
/** Read the setting VALUES (stored/effective/orphaned) for a workflow in the
* current project context (U6). The project is bound server-side to the
* scoped store. */
@@ -5262,6 +5271,31 @@ export function updateWorkflowSettingValues(
);
}
/** Read per-node prompt overrides for a workflow in the current project context. */
export function fetchWorkflowPromptOverrides(
id: string,
projectId?: string,
): Promise<WorkflowPromptOverridesPayload> {
return api<WorkflowPromptOverridesPayload>(
withProjectId(`/workflows/${encodeURIComponent(id)}/prompt-overrides`, projectId),
);
}
/** Patch per-node prompt overrides. Null, empty, and whitespace values reset to the shipped default. */
export function updateWorkflowPromptOverrides(
id: string,
overrides: Record<string, string | null>,
projectId?: string,
): Promise<WorkflowPromptOverridesPayload> {
return api<WorkflowPromptOverridesPayload>(
withProjectId(`/workflows/${encodeURIComponent(id)}/prompt-overrides`, projectId),
{
method: "PATCH",
body: JSON.stringify({ overrides }),
},
);
}
/** Preview the compiled steps for a workflow. Rejects (422) for non-linear graphs. */
export function compileWorkflow(id: string, projectId?: string): Promise<{ steps: WorkflowStepInput[] }> {
return api<{ steps: WorkflowStepInput[] }>(withProjectId(`/workflows/${encodeURIComponent(id)}/compile`, projectId), {

View File

@@ -947,6 +947,35 @@ React Flow ships white default controls and mini-map chrome, but the workflow ed
padding-right: calc(var(--space-xl) + var(--space-md));
}
/*
FNXC:WorkflowEditor 2026-06-21-20:18:
Built-in workflow prompts need visible override state and a reset action without making graph controls editable; keep the controls outside disabled fieldsets and use shared button/token styles.
*/
.wf-prompt-override-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-sm);
margin-top: var(--space-sm);
padding-right: calc(var(--space-xl) + var(--space-md));
}
.wf-prompt-override-actions--fullscreen {
justify-content: space-between;
padding-right: 0;
}
.wf-prompt-override-badge {
display: inline-flex;
align-items: center;
border: 1px solid var(--color-warning);
border-radius: var(--radius-pill);
color: var(--color-warning);
padding: calc(var(--space-xs) / 2) var(--space-sm);
font-size: 0.75rem;
font-weight: 600;
}
.wf-inspector-note {
margin: 0;
font-size: 0.78rem;
@@ -1730,6 +1759,12 @@ Column trait toggles are left-sidebar workflow controls; keep their enabled and
padding: var(--space-sm);
}
.wf-prompt-override-actions {
align-items: stretch;
flex-direction: column;
padding-right: 0;
}
.modal-overlay:has(.wf-editor-modal),
.modal-overlay:has(.wf-create-modal) {
padding-top: 0;

View File

@@ -35,7 +35,10 @@ import {
fetchDiscoveredSkills,
fetchWorkflowStepTemplates,
fetchPluginWorkflowStepTemplates,
fetchWorkflowPromptOverrides,
updateWorkflowPromptOverrides,
type ModelInfo,
type WorkflowPromptOverridesPayload,
} from "../api";
import type { Agent } from "../api";
import type { DiscoveredSkill } from "../api";
@@ -745,6 +748,10 @@ function InnerEditor({
// managed by the panel's Values tab, not this declaration array.
const [settings, setSettings] = useState<WorkflowSettingDefinition[]>([]);
const [optionalSteps, setOptionalSteps] = useState<WorkflowOptionalStep[]>([]);
// FNXC:WorkflowEditor 2026-06-21-20:06:
// Built-in workflow graph structure remains read-only, but prompt/gate node prompts need a separate per-project override state so editing prompts does not mark structural graph edits dirty or use the read-only workflow PATCH authority.
const [promptOverrides, setPromptOverrides] = useState<WorkflowPromptOverridesPayload | null>(null);
const [promptOverrideSavingNodeId, setPromptOverrideSavingNodeId] = useState<string | null>(null);
// Ref to the settings panel so a `?panel=settings` deep link can scroll it
// into view on mount (U6/U9 redirect stubs).
const settingsPanelRef = useRef<HTMLDivElement | null>(null);
@@ -1181,6 +1188,8 @@ function InnerEditor({
setFields([]);
setSettings([]);
setOptionalSteps([]);
setPromptOverrides(null);
setPromptOverrideSavingNodeId(null);
setName("");
setDescription("");
loadedSnapshotRef.current = null;
@@ -1236,6 +1245,41 @@ function InnerEditor({
if (nodes.length > 0) canvasNodesMaterializedRef.current = true;
}, [nodes]);
useEffect(() => {
if (!activeWorkflow || !isBuiltin) {
setPromptOverrides(null);
setPromptOverrideSavingNodeId(null);
return;
}
let cancelled = false;
void fetchWorkflowPromptOverrides(activeWorkflow.id, projectId)
.then((payload) => {
if (cancelled) return;
setPromptOverrides(payload);
setNodes((ns) =>
ns.map((node) => {
if (node.data.kind !== "prompt" && node.data.kind !== "gate") return node;
const effective = payload.effective[node.id];
if (effective === undefined) return node;
return {
...node,
data: {
...node.data,
config: { ...(node.data.config ?? {}), prompt: effective },
},
};
}),
);
})
.catch((err) => {
if (cancelled) return;
addToast(getErrorMessage(err) || t("workflowEditor.promptOverridesLoadFailed", "Failed to load prompt overrides"), "error");
});
return () => {
cancelled = true;
};
}, [activeWorkflow, isBuiltin, projectId, addToast, t, setNodes]);
// `?panel=settings` deep link (U6/U9 redirect stubs): once the active workflow
// has loaded, scroll the settings panel into view. Runs once per editor open.
const didScrollToSettings = useRef(false);
@@ -1597,6 +1641,67 @@ function InnerEditor({
[selectedNodeId, setNodes],
);
const applyPromptOverridePayloadToNode = useCallback(
(nodeId: string, payload: WorkflowPromptOverridesPayload) => {
setPromptOverrides(payload);
const effective = payload.effective[nodeId] ?? payload.defaults[nodeId] ?? "";
setNodes((ns) =>
ns.map((node) =>
node.id === nodeId
? {
...node,
data: {
...node.data,
config: { ...(node.data.config ?? {}), prompt: effective },
},
}
: node,
),
);
},
[setNodes],
);
const persistBuiltinPromptOverride = useCallback(
async (nodeId: string, prompt: string) => {
if (!activeWorkflow || !isBuiltin) return;
setPromptOverrideSavingNodeId(nodeId);
try {
const payload = await updateWorkflowPromptOverrides(activeWorkflow.id, { [nodeId]: prompt }, projectId);
applyPromptOverridePayloadToNode(nodeId, payload);
addToast(t("workflowEditor.promptOverrideSaved", "Prompt override saved"), "success");
} catch (err) {
addToast(getErrorMessage(err) || t("workflowEditor.promptOverrideSaveFailed", "Failed to save prompt override"), "error");
try {
const payload = await fetchWorkflowPromptOverrides(activeWorkflow.id, projectId);
applyPromptOverridePayloadToNode(nodeId, payload);
} catch {
// Best-effort rollback; a later workflow reload will reconcile.
}
} finally {
setPromptOverrideSavingNodeId((current) => (current === nodeId ? null : current));
}
},
[activeWorkflow, isBuiltin, projectId, addToast, t, applyPromptOverridePayloadToNode],
);
const resetBuiltinPromptOverride = useCallback(
async (nodeId: string) => {
if (!activeWorkflow || !isBuiltin) return;
setPromptOverrideSavingNodeId(nodeId);
try {
const payload = await updateWorkflowPromptOverrides(activeWorkflow.id, { [nodeId]: null }, projectId);
applyPromptOverridePayloadToNode(nodeId, payload);
addToast(t("workflowEditor.promptOverrideReset", "Prompt reset to default"), "success");
} catch (err) {
addToast(getErrorMessage(err) || t("workflowEditor.promptOverrideResetFailed", "Failed to reset prompt"), "error");
} finally {
setPromptOverrideSavingNodeId((current) => (current === nodeId ? null : current));
}
},
[activeWorkflow, isBuiltin, projectId, addToast, t, applyPromptOverridePayloadToNode],
);
// Edge inspector (KTD-4/5): mutate the selected edge's condition + rework
// kind, keeping its display label in sync. Rework edges render dashed/animated.
const updateSelectedEdge = useCallback(
@@ -1985,9 +2090,39 @@ function InnerEditor({
selectedNode && (selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate")
? String(
selectedNode.data.config?.prompt
?? promptOverrides?.effective[selectedNode.id]
?? (isBuiltin ? builtinSeamPrompt(selectedNode.data.config as Record<string, unknown> | undefined) : ""),
)
: "";
const selectedPromptDefault = selectedNode ? promptOverrides?.defaults[selectedNode.id] : undefined;
const selectedPromptStored = selectedNode ? promptOverrides?.stored[selectedNode.id] : undefined;
const selectedPromptHasOverride = selectedPromptStored !== undefined;
const selectedPromptIsOverridden =
selectedPromptHasOverride && selectedPromptDefault !== undefined && selectedPromptStored !== selectedPromptDefault;
const selectedPromptOverrideSaving = !!selectedNode && promptOverrideSavingNodeId === selectedNode.id;
const selectedNodePromptEditable =
!!selectedNode &&
(selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate") &&
(!isBuiltin || selectedPromptDefault !== undefined);
const handlePromptTextChange = useCallback(
(value: string) => {
if (!selectedNodePromptEditable) return;
updateSelectedData({ config: { prompt: value } });
},
[selectedNodePromptEditable, updateSelectedData],
);
const handlePromptTextBlur = useCallback(() => {
if (!isBuiltin || !selectedNode || (selectedNode.data.kind !== "prompt" && selectedNode.data.kind !== "gate")) return;
const stored = promptOverrides?.stored[selectedNode.id];
const defaultPrompt = promptOverrides?.defaults[selectedNode.id];
const nextPrompt = selectedNodePromptValue.trim() ? selectedNodePromptValue : "";
if ((stored === undefined && (defaultPrompt === undefined || nextPrompt === defaultPrompt)) || stored === nextPrompt) return;
void persistBuiltinPromptOverride(selectedNode.id, selectedNodePromptValue);
}, [isBuiltin, selectedNode, selectedNodePromptValue, promptOverrides, persistBuiltinPromptOverride]);
const handlePromptResetClick = useCallback(() => {
if (!selectedNode) return;
void resetBuiltinPromptOverride(selectedNode.id);
}, [selectedNode, resetBuiltinPromptOverride]);
// The edge inspector renders different controls per source-node kind (KTD-2):
// step-review → verdict controls; prompt/script/gate/code/foreach →
// success/failure select; everything else → a read-only condition note.
@@ -2270,11 +2405,31 @@ function InnerEditor({
<textarea
rows={undefined}
value={selectedNodePromptValue}
readOnly={isBuiltin}
onChange={(e) => updateSelectedData({ config: { prompt: e.target.value } })}
readOnly={!selectedNodePromptEditable}
onChange={(e) => handlePromptTextChange(e.target.value)}
onBlur={handlePromptTextBlur}
autoFocus
/>
</label>
{isBuiltin ? (
<div className="wf-prompt-override-actions wf-prompt-override-actions--fullscreen">
{selectedPromptIsOverridden ? (
<span className="wf-prompt-override-badge" data-testid="wf-prompt-overridden">
{t("workflowEditor.promptOverridden", "Overridden")}
</span>
) : null}
<button
type="button"
className="btn btn-sm"
onClick={handlePromptResetClick}
disabled={!selectedPromptHasOverride || selectedPromptOverrideSaving}
>
{selectedPromptOverrideSaving
? t("workflowEditor.promptSaving", "Saving…")
: t("workflowEditor.resetPromptDefault", "Reset to default")}
</button>
</div>
) : null}
</div>,
document.body,
)
@@ -2670,7 +2825,7 @@ function InnerEditor({
<div className="wf-mobile-add">
{isBuiltin ? (
<p className="wf-inspector-note wf-inspector-note--info">
{t("workflows.readOnlyBuiltin", "Read-only built-in workflow")}
{t("workflows.readOnlyBuiltin", "Built-in workflow: structure is read-only, prompts are editable.")}
</p>
) : (
<>
@@ -2837,7 +2992,7 @@ function InnerEditor({
{isBuiltin ? (
<>
<p className="wf-inspector-note wf-inspector-note--info">
{t("workflows.readOnlyBuiltin", "Read-only built-in workflow")}
{t("workflows.readOnlyBuiltin", "Built-in workflow: structure is read-only, prompts are editable.")}
</p>
<button className="wf-editor-action" data-testid="wf-mobile-export" onClick={handleExport}>
<Download size={15} /> {t("workflows.export", "Export")}
@@ -2917,7 +3072,7 @@ function InnerEditor({
// (not an overlay); the canvas below stays inspectable.
<div className="wf-editor-readonly-banner" role="status" data-testid="wf-readonly-banner">
<span className="wf-editor-readonly-note">
{t("workflows.readOnlyBuiltin", "Read-only built-in workflow")}
{t("workflows.readOnlyBuiltin", "Built-in workflow: structure is read-only, prompts are editable.")}
</span>
<button
className="wf-editor-action"
@@ -3331,7 +3486,7 @@ function InnerEditor({
</div>
{isBuiltin && (
<p className="wf-inspector-note wf-inspector-note--info">
{t("workflowNodes.readOnlyDuplicateToEdit", "Read-only built-in — duplicate the workflow to edit nodes.")}
{t("workflowNodes.readOnlyDuplicateToEdit", "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.")}
</p>
)}
<fieldset className="wf-inspector-fields" disabled={isBuiltin}>
@@ -3384,10 +3539,30 @@ function InnerEditor({
<textarea
rows={5}
value={selectedNodePromptValue}
readOnly={isBuiltin}
onChange={(e) => updateSelectedData({ config: { prompt: e.target.value } })}
readOnly={!selectedNodePromptEditable}
onChange={(e) => handlePromptTextChange(e.target.value)}
onBlur={handlePromptTextBlur}
/>
</label>
<div className="wf-prompt-override-actions">
{isBuiltin && selectedPromptIsOverridden ? (
<span className="wf-prompt-override-badge" data-testid="wf-prompt-overridden">
{t("workflowEditor.promptOverridden", "Overridden")}
</span>
) : null}
{isBuiltin ? (
<button
type="button"
className="btn btn-sm"
onClick={handlePromptResetClick}
disabled={!selectedPromptHasOverride || selectedPromptOverrideSaving}
>
{selectedPromptOverrideSaving
? t("workflowEditor.promptSaving", "Saving…")
: t("workflowEditor.resetPromptDefault", "Reset to default")}
</button>
) : null}
</div>
{/* Expand button is outside <fieldset disabled={isBuiltin}> so it remains
clickable for builtin workflows. Root cause: HTML spec disables all
descendant buttons inside a disabled fieldset, including type="button". */}

View File

@@ -54,6 +54,8 @@ vi.mock("../../api", () => ({
updateGlobalSettings: vi.fn(),
fetchWorkflowSettingValues: vi.fn().mockResolvedValue({ stored: {}, effective: {}, orphaned: [] }),
updateWorkflowSettingValues: vi.fn().mockResolvedValue({ stored: {}, effective: {}, orphaned: [] }),
fetchWorkflowPromptOverrides: vi.fn().mockResolvedValue({ stored: {}, effective: {}, defaults: {} }),
updateWorkflowPromptOverrides: vi.fn().mockResolvedValue({ stored: {}, effective: {}, defaults: {} }),
}));
import { fireEvent } from "@testing-library/react";
@@ -76,6 +78,8 @@ import {
fetchAgents,
fetchConfig,
fetchSettings,
fetchWorkflowPromptOverrides,
updateWorkflowPromptOverrides,
} from "../../api";
import type { TraitCatalogEntry } from "../../api";
import type { WorkflowStepTemplate } from "@fusion/core";
@@ -122,6 +126,8 @@ viBeforeEach(() => {
vi.mocked(fetchConfig).mockResolvedValue({ maxConcurrent: 2, rootDir: "." });
vi.mocked(fetchSettings).mockResolvedValue({} as never);
vi.mocked(fetchAgents).mockResolvedValue([]);
vi.mocked(fetchWorkflowPromptOverrides).mockResolvedValue({ stored: {}, effective: {}, defaults: {} });
vi.mocked(updateWorkflowPromptOverrides).mockResolvedValue({ stored: {}, effective: {}, defaults: {} });
});
const TRAIT_CATALOG: TraitCatalogEntry[] = [
@@ -803,10 +809,94 @@ describe("WorkflowNodeEditor", () => {
fireEvent.click(await screen.findByTestId("wf-node-start"));
const inspector = await screen.findByTestId("wf-node-inspector");
expect(within(inspector).getByText(/Read-only built-in/i)).toBeInTheDocument();
expect(within(inspector).getByText(/structure is read-only/i)).toBeInTheDocument();
expect(within(inspector).getByTestId("wf-start-entry-column")).toBeDisabled();
});
it("edits and resets built-in prompt overrides from the node inspector", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
vi.mocked(fetchWorkflowPromptOverrides).mockResolvedValue({
stored: {},
effective: { execute: "Default execute prompt" },
defaults: { execute: "Default execute prompt" },
});
vi.mocked(updateWorkflowPromptOverrides)
.mockResolvedValueOnce({
stored: { execute: "Custom execute prompt" },
effective: { execute: "Custom execute prompt" },
defaults: { execute: "Default execute prompt" },
})
.mockResolvedValueOnce({
stored: {},
effective: { execute: "Default execute prompt" },
defaults: { execute: "Default execute prompt" },
});
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await selectBuiltinExecutePromptNode();
const inspector = await screen.findByTestId("wf-node-inspector");
const prompt = within(inspector).getByLabelText("Prompt") as HTMLTextAreaElement;
expect(prompt).not.toHaveAttribute("readonly");
expect(within(inspector).getByRole("button", { name: "Reset to default" })).toBeDisabled();
fireEvent.change(prompt, { target: { value: "Custom execute prompt" } });
fireEvent.blur(prompt);
await waitFor(() =>
expect(updateWorkflowPromptOverrides).toHaveBeenCalledWith("builtin:coding", { execute: "Custom execute prompt" }, undefined),
);
expect(await within(inspector).findByTestId("wf-prompt-overridden")).toHaveTextContent("Overridden");
const reset = within(inspector).getByRole("button", { name: "Reset to default" });
expect(reset).not.toBeDisabled();
fireEvent.click(reset);
await waitFor(() =>
expect(updateWorkflowPromptOverrides).toHaveBeenLastCalledWith("builtin:coding", { execute: null }, undefined),
);
await waitFor(() => expect(prompt).toHaveValue("Default execute prompt"));
});
it("edits gate prompts and shows reset controls in mobile built-in panels", async () => {
mockWorkflowEditorViewport("mobile");
const gateWorkflow: WorkflowDefinition = {
...builtinDef(),
ir: {
version: "v2",
name: "Gate built-in",
columns: [],
nodes: [
{ id: "start", kind: "start" },
{ id: "security", kind: "gate", config: { prompt: "Default security prompt" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "security", condition: "success" },
{ from: "security", to: "end", condition: "success" },
],
},
layout: {},
};
vi.mocked(fetchWorkflows).mockResolvedValue([gateWorkflow]);
vi.mocked(fetchWorkflowPromptOverrides).mockResolvedValue({
stored: { security: "Custom security prompt" },
effective: { security: "Custom security prompt" },
defaults: { security: "Default security prompt" },
});
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Default coding workflow" }));
fireEvent.click(within(await screen.findByTestId("mobile-wf-node-security")).getAllByRole("button")[0]);
const inspector = await screen.findByTestId("wf-node-inspector");
const prompt = within(inspector).getByLabelText("Prompt") as HTMLTextAreaElement;
expect(prompt).not.toHaveAttribute("readonly");
expect(prompt).toHaveValue("Custom security prompt");
expect(within(inspector).getByTestId("wf-prompt-overridden")).toHaveTextContent("Overridden");
expect(within(inspector).getByRole("button", { name: "Reset to default" })).not.toBeDisabled();
});
it("opens the start node inspector from the mobile node-detail stage", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
@@ -1038,6 +1128,50 @@ describe("WorkflowNodeEditor", () => {
expect(getPromptFullscreenOverlay()).toBeNull();
});
it("edits and resets built-in prompt overrides in the fullscreen editor", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
vi.mocked(fetchWorkflowPromptOverrides).mockResolvedValue({
stored: { execute: "Existing execute override" },
effective: { execute: "Existing execute override" },
defaults: { execute: "Default execute prompt" },
});
vi.mocked(updateWorkflowPromptOverrides)
.mockResolvedValueOnce({
stored: { execute: "Fullscreen execute override" },
effective: { execute: "Fullscreen execute override" },
defaults: { execute: "Default execute prompt" },
})
.mockResolvedValueOnce({
stored: {},
effective: { execute: "Default execute prompt" },
defaults: { execute: "Default execute prompt" },
});
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await selectBuiltinExecutePromptNode();
fireEvent.click(await screen.findByRole("button", { name: "Expand prompt editor" }));
const fullscreenPromptEditor = getPromptFullscreenOverlay();
expect(fullscreenPromptEditor).toBeInTheDocument();
const textarea = getPromptFullscreenTextarea();
expect(textarea).not.toHaveAttribute("readonly");
expect(textarea).toHaveValue("Existing execute override");
expect(within(fullscreenPromptEditor!).getByTestId("wf-prompt-overridden")).toHaveTextContent("Overridden");
fireEvent.change(textarea, { target: { value: "Fullscreen execute override" } });
fireEvent.blur(textarea);
await waitFor(() =>
expect(updateWorkflowPromptOverrides).toHaveBeenCalledWith("builtin:coding", { execute: "Fullscreen execute override" }, undefined),
);
fireEvent.click(within(fullscreenPromptEditor!).getByRole("button", { name: "Reset to default" }));
await waitFor(() =>
expect(updateWorkflowPromptOverrides).toHaveBeenLastCalledWith("builtin:coding", { execute: null }, undefined),
);
await waitFor(() => expect(textarea).toHaveValue("Default execute prompt"));
});
it("opens and collapses the fullscreen prompt editor for builtin workflows on mobile", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);

View File

@@ -753,6 +753,65 @@ describe("workflow routes (U4)", () => {
expect(res.status).toBe(404);
});
});
describe("prompt-overrides routes (FN-6893)", () => {
it("GET returns shipped defaults and effective prompts for a built-in workflow", async () => {
const res = await get("/api/workflows/builtin:coding/prompt-overrides");
expect(res.status).toBe(200);
const body = res.body as { stored: Record<string, string>; defaults: Record<string, string>; effective: Record<string, string> };
expect(body.stored).toEqual({});
expect(body.defaults.execute).toContain("You are a task execution agent");
expect(body.effective.execute).toBe(body.defaults.execute);
});
it("PATCH sets and resets a built-in prompt override", async () => {
const set = await patch("/api/workflows/builtin:coding/prompt-overrides", {
overrides: { execute: "Execute route override" },
});
expect(set.status).toBe(200);
expect((set.body as { stored: Record<string, string>; effective: Record<string, string> }).stored.execute).toBe(
"Execute route override",
);
expect((set.body as { effective: Record<string, string> }).effective.execute).toBe("Execute route override");
expect(emitWorkflowSseEvent).toHaveBeenCalledWith(
"workflow:updated",
expect.objectContaining({ id: "builtin:coding" }),
"proj-workflow-routes",
);
const reset = await patch("/api/workflows/builtin:coding/prompt-overrides", {
overrides: { execute: null },
});
expect(reset.status).toBe(200);
const resetBody = reset.body as { stored: Record<string, string>; defaults: Record<string, string>; effective: Record<string, string> };
expect(resetBody.stored.execute).toBeUndefined();
expect(resetBody.effective.execute).toBe(resetBody.defaults.execute);
});
it("PATCH treats empty and whitespace prompt overrides as reset", async () => {
await patch("/api/workflows/builtin:coding/prompt-overrides", {
overrides: { execute: "Execute route override" },
});
const res = await patch("/api/workflows/builtin:coding/prompt-overrides", {
overrides: { execute: " " },
});
expect(res.status).toBe(200);
expect((res.body as { stored: Record<string, string> }).stored.execute).toBeUndefined();
});
it("PATCH rejects node ids that are not prompt-bearing", async () => {
const res = await patch("/api/workflows/builtin:coding/prompt-overrides", {
overrides: { end: "No prompt here" },
});
expect(res.status).toBe(400);
expect((res.body as { details?: { nodeId?: string } }).details?.nodeId).toBe("end");
});
it("GET and PATCH return 404 for an unknown workflow id", async () => {
expect((await get("/api/workflows/WF-404/prompt-overrides")).status).toBe(404);
expect((await patch("/api/workflows/WF-404/prompt-overrides", { overrides: { execute: "x" } })).status).toBe(404);
});
});
});
// ── U6: write-time column-agent validation (existence + policy escalation) ────

View File

@@ -1,5 +1,5 @@
import type { WorkflowDefinition, WorkflowDefinitionKind, WorkflowIr, WorkflowIrNode, WorkflowSettingDefinition, TaskStore } from "@fusion/core";
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, ColumnAgentBindingError, WorkflowSettingRejectionError, SCHEMA_VERSION, assertColumnTraitsValid, compileWorkflowToSteps, layoutForIr, listTraits, listStepParsers, parseWorkflowIr, resolvePlanningSettingsModel, stripApprovalBypassFlags, resolveWorkflowIrById, resolveEffectiveSettingValues, findOrphanedSettingValues, isBuiltinWorkflowId, BUILTIN_WORKFLOW_SETTINGS, AgentStore, validateColumnAgentBindings, resolveWorkflowOptionalSteps } from "@fusion/core";
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, ColumnAgentBindingError, WorkflowSettingRejectionError, SCHEMA_VERSION, assertColumnTraitsValid, compileWorkflowToSteps, layoutForIr, listTraits, listStepParsers, parseWorkflowIr, resolvePlanningSettingsModel, stripApprovalBypassFlags, resolveWorkflowIrById, resolveEffectiveSettingValues, findOrphanedSettingValues, isBuiltinWorkflowId, getBuiltinWorkflow, BUILTIN_WORKFLOW_SETTINGS, AgentStore, validateColumnAgentBindings, resolveWorkflowOptionalSteps, enumeratePromptBearingWorkflowNodes } from "@fusion/core";
import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, validateCodeNodeSources } from "@fusion/engine";
import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js";
import { emitWorkflowSseEvent } from "../sse.js";
@@ -195,6 +195,25 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
if (!def) throw notFound(`Workflow '${workflowId}' not found`);
}
async function resolvePromptOverrideDefaults(store: TaskStore, workflowId: string): Promise<Record<string, string>> {
const builtin = isBuiltinWorkflowId(workflowId) ? getBuiltinWorkflow(workflowId) : undefined;
const ir = builtin?.ir ?? (await store.getWorkflowDefinition(workflowId))?.ir;
if (!ir) return {};
const defaults: Record<string, string> = {};
for (const entry of enumeratePromptBearingWorkflowNodes(ir)) {
defaults[entry.nodeId] = entry.prompt;
}
return defaults;
}
function resolveEffectivePromptOverrides(defaults: Record<string, string>, stored: Record<string, string>): Record<string, string> {
const effective: Record<string, string> = {};
for (const [nodeId, prompt] of Object.entries(defaults)) {
effective[nodeId] = stored[nodeId] ?? prompt;
}
return effective;
}
/**
* Write-time column-agent validation (U6, R11/R13). Delegates to the shared
* `validateColumnAgentBindings` helper in @fusion/core (the SAME gate the
@@ -493,6 +512,71 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
}
});
// GET /api/workflows/:id/prompt-overrides — read per-project prompt overrides
// for prompt/gate nodes. Defaults are the shipped/custom IR prompt text, while
// effective applies the stored nodeId → prompt override map.
// FNXC:CustomWorkflows 2026-06-21-19:24:
// The dashboard needs a separate prompt-override route so built-in workflow prompt edits do not pass through the graph-edit PATCH route that remains read-only for built-ins.
router.get("/workflows/:id/prompt-overrides", async (req, res) => {
try {
const { store } = await getProjectContext(req);
const workflowId = req.params.id;
await assertWorkflowExists(store, workflowId);
const projectId = store.getWorkflowSettingsProjectId();
const defaults = await resolvePromptOverrideDefaults(store, workflowId);
const stored = store.getWorkflowPromptOverrides(workflowId, projectId);
res.json({
stored,
effective: resolveEffectivePromptOverrides(defaults, stored),
defaults,
});
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// PATCH /api/workflows/:id/prompt-overrides — merge prompt overrides for a
// workflow. Body: { overrides: Record<nodeId, string | null> }. Null, empty,
// and whitespace values reset a node back to its default prompt.
router.patch("/workflows/:id/prompt-overrides", async (req, res) => {
try {
const { store, projectId: sseProjectId } = await getProjectContext(req);
const workflowId = req.params.id;
const overrides = (req.body ?? {}).overrides;
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) {
throw badRequest("overrides is required and must be an object map of node id → prompt (null to reset)");
}
await assertWorkflowExists(store, workflowId);
const defaults = await resolvePromptOverrideDefaults(store, workflowId);
const promptNodeIds = new Set(Object.keys(defaults));
for (const [nodeId, value] of Object.entries(overrides as Record<string, unknown>)) {
if (!promptNodeIds.has(nodeId)) {
throw badRequest(`Node '${nodeId}' is not a prompt-bearing node in workflow '${workflowId}'`, { nodeId });
}
if (value !== null && typeof value !== "string") {
throw badRequest(`Override for node '${nodeId}' must be a string or null`, { nodeId });
}
}
const projectId = store.getWorkflowSettingsProjectId();
const stored = store.updateWorkflowPromptOverrides(
workflowId,
projectId,
overrides as Record<string, string | null>,
);
const payload = {
stored,
effective: resolveEffectivePromptOverrides(defaults, stored),
defaults,
};
emitWorkflowSseEvent("workflow:updated", (await store.getWorkflowDefinition(workflowId)) ?? { id: workflowId }, sseProjectId);
res.json(payload);
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// GET /api/tasks/:taskId/workflow — current selection for a task.
router.get("/tasks/:taskId/workflow", async (req, res) => {
try {

View File

@@ -0,0 +1,61 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
BUILTIN_CODING_WORKFLOW_IR,
TaskStore,
resolveSeamPromptFromIr,
resolveTaskSeamPrompt,
type WorkflowIr,
} from "@fusion/core";
let rootDir: string;
let globalDir: string;
let store: TaskStore;
type StoreWithSyncWorkflowResolution = TaskStore & {
resolveTaskWorkflowIrSync(taskId: string): WorkflowIr;
};
describe("workflow prompt override resolution", () => {
beforeEach(async () => {
rootDir = await mkdtemp(join(tmpdir(), "fusion-engine-prompt-overrides-"));
globalDir = await mkdtemp(join(tmpdir(), "fusion-engine-prompt-overrides-global-"));
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(async () => {
store.stopWatching();
await store.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
it("applies and resets built-in execute seam prompt overrides without mutating the shared IR", async () => {
const projectId = store.getWorkflowSettingsProjectId();
const defaultExecutePrompt = resolveSeamPromptFromIr(BUILTIN_CODING_WORKFLOW_IR, "execute");
const beforeStaticIr = JSON.stringify(BUILTIN_CODING_WORKFLOW_IR);
const task = await store.createTask({ description: "uses prompt override", workflowId: "builtin:coding" });
// FNXC:CustomWorkflows 2026-06-21-21:04:
// Engine seam resolution must consume the same built-in prompt override overlay as dashboard preview and sync store resolution, while reset-to-default must reveal the shipped static prompt again.
store.updateWorkflowPromptOverrides("builtin:coding", projectId, { execute: "Engine execute override" });
expect(await resolveTaskSeamPrompt(store, task.id, "execute")).toBe("Engine execute override");
const syncIr = (store as StoreWithSyncWorkflowResolution).resolveTaskWorkflowIrSync(task.id);
expect(resolveSeamPromptFromIr(syncIr, "execute")).toBe("Engine execute override");
expect(syncIr).not.toBe(BUILTIN_CODING_WORKFLOW_IR);
expect(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)).toBe(beforeStaticIr);
store.updateWorkflowPromptOverrides("builtin:coding", projectId, { execute: null });
expect(await resolveTaskSeamPrompt(store, task.id, "execute")).toBe(defaultExecutePrompt);
expect(resolveSeamPromptFromIr((store as StoreWithSyncWorkflowResolution).resolveTaskWorkflowIrSync(task.id), "execute")).toBe(
defaultExecutePrompt,
);
expect(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)).toBe(beforeStaticIr);
});
});

View File

@@ -8207,7 +8207,15 @@
"skillsLoadFailed": "Failed to load skills",
"skipFirstRunApproval": "Skip first-run approval (runs without pausing)",
"waitForUserInput": "Wait for user input",
"waitForUserInputNote": "This node pauses the task until you reply in the task's comments and unpause. The Prompt field above is shown to the user as the question."
"waitForUserInputNote": "This node pauses the task until you reply in the task's comments and unpause. The Prompt field above is shown to the user as the question.",
"promptOverrideSaved": "Prompt override saved",
"promptOverrideSaveFailed": "Failed to save prompt override",
"promptOverrideReset": "Prompt reset to default",
"promptOverrideResetFailed": "Failed to reset prompt",
"promptOverridesLoadFailed": "Failed to load prompt overrides",
"promptOverridden": "Overridden",
"promptSaving": "Saving…",
"resetPromptDefault": "Reset to default"
},
"workflowFields": {
"add": "Add field",
@@ -8345,7 +8353,7 @@
"conditionFailure": "failure",
"conditionSuccess": "success",
"nodeInspector": "Node",
"readOnlyDuplicateToEdit": "Read-only built-in — duplicate the workflow to edit nodes."
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
},
"workflows": {
"aiEdit": "Design with AI",
@@ -8395,7 +8403,7 @@
"mobileSelectNote": "Select a workflow to edit.",
"nameLabel": "Workflow name",
"newWorkflow": "New workflow",
"readOnlyBuiltin": "Read-only built-in workflow",
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
"saved": "Workflow saved",
"savedNotCompilable": "Workflow saved but cannot be compiled",
"saveFailed": "Failed to save workflow",

View File

@@ -8207,7 +8207,15 @@
"skillsLoadFailed": "",
"skipFirstRunApproval": "",
"waitForUserInput": "",
"waitForUserInputNote": ""
"waitForUserInputNote": "",
"promptOverrideSaved": "Prompt override saved",
"promptOverrideSaveFailed": "Failed to save prompt override",
"promptOverrideReset": "Prompt reset to default",
"promptOverrideResetFailed": "Failed to reset prompt",
"promptOverridesLoadFailed": "Failed to load prompt overrides",
"promptOverridden": "Overridden",
"promptSaving": "Saving…",
"resetPromptDefault": "Reset to default"
},
"workflowFields": {
"add": "Agregar campo",
@@ -8345,7 +8353,7 @@
"conditionFailure": "",
"conditionSuccess": "",
"nodeInspector": "",
"readOnlyDuplicateToEdit": ""
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
},
"workflows": {
"aiEdit": "Diseñar con IA",
@@ -8395,7 +8403,7 @@
"mobileSelectNote": "",
"nameLabel": "Nombre del flujo de trabajo",
"newWorkflow": "Nuevo flujo de trabajo",
"readOnlyBuiltin": "",
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
"saved": "",
"savedNotCompilable": "",
"saveFailed": "",

View File

@@ -8207,7 +8207,15 @@
"skillsLoadFailed": "",
"skipFirstRunApproval": "",
"waitForUserInput": "",
"waitForUserInputNote": ""
"waitForUserInputNote": "",
"promptOverrideSaved": "Prompt override saved",
"promptOverrideSaveFailed": "Failed to save prompt override",
"promptOverrideReset": "Prompt reset to default",
"promptOverrideResetFailed": "Failed to reset prompt",
"promptOverridesLoadFailed": "Failed to load prompt overrides",
"promptOverridden": "Overridden",
"promptSaving": "Saving…",
"resetPromptDefault": "Reset to default"
},
"workflowFields": {
"add": "Ajouter un champ",
@@ -8345,7 +8353,7 @@
"conditionFailure": "",
"conditionSuccess": "",
"nodeInspector": "",
"readOnlyDuplicateToEdit": ""
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
},
"workflows": {
"aiEdit": "Concevoir avec l'IA",
@@ -8395,7 +8403,7 @@
"mobileSelectNote": "",
"nameLabel": "Nom du workflow",
"newWorkflow": "Nouveau workflow",
"readOnlyBuiltin": "",
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
"saved": "",
"savedNotCompilable": "",
"saveFailed": "",

View File

@@ -8207,7 +8207,15 @@
"skillsLoadFailed": "",
"skipFirstRunApproval": "",
"waitForUserInput": "",
"waitForUserInputNote": ""
"waitForUserInputNote": "",
"promptOverrideSaved": "Prompt override saved",
"promptOverrideSaveFailed": "Failed to save prompt override",
"promptOverrideReset": "Prompt reset to default",
"promptOverrideResetFailed": "Failed to reset prompt",
"promptOverridesLoadFailed": "Failed to load prompt overrides",
"promptOverridden": "Overridden",
"promptSaving": "Saving…",
"resetPromptDefault": "Reset to default"
},
"workflowFields": {
"add": "필드 추가",
@@ -8345,7 +8353,7 @@
"conditionFailure": "",
"conditionSuccess": "",
"nodeInspector": "",
"readOnlyDuplicateToEdit": ""
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
},
"workflows": {
"aiEdit": "AI로 디자인",
@@ -8395,7 +8403,7 @@
"mobileSelectNote": "",
"nameLabel": "워크플로 이름",
"newWorkflow": "새 워크플로",
"readOnlyBuiltin": "",
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
"saved": "",
"savedNotCompilable": "",
"saveFailed": "",

View File

@@ -8207,7 +8207,15 @@
"skillsLoadFailed": "",
"skipFirstRunApproval": "",
"waitForUserInput": "",
"waitForUserInputNote": ""
"waitForUserInputNote": "",
"promptOverrideSaved": "Prompt override saved",
"promptOverrideSaveFailed": "Failed to save prompt override",
"promptOverrideReset": "Prompt reset to default",
"promptOverrideResetFailed": "Failed to reset prompt",
"promptOverridesLoadFailed": "Failed to load prompt overrides",
"promptOverridden": "Overridden",
"promptSaving": "Saving…",
"resetPromptDefault": "Reset to default"
},
"workflowFields": {
"add": "添加字段",
@@ -8345,7 +8353,7 @@
"conditionFailure": "",
"conditionSuccess": "",
"nodeInspector": "",
"readOnlyDuplicateToEdit": ""
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
},
"workflows": {
"aiEdit": "用 AI 设计",
@@ -8395,7 +8403,7 @@
"mobileSelectNote": "",
"nameLabel": "工作流名称",
"newWorkflow": "新建工作流",
"readOnlyBuiltin": "",
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
"saved": "",
"savedNotCompilable": "",
"saveFailed": "",

View File

@@ -8207,7 +8207,15 @@
"skillsLoadFailed": "",
"skipFirstRunApproval": "",
"waitForUserInput": "",
"waitForUserInputNote": ""
"waitForUserInputNote": "",
"promptOverrideSaved": "Prompt override saved",
"promptOverrideSaveFailed": "Failed to save prompt override",
"promptOverrideReset": "Prompt reset to default",
"promptOverrideResetFailed": "Failed to reset prompt",
"promptOverridesLoadFailed": "Failed to load prompt overrides",
"promptOverridden": "Overridden",
"promptSaving": "Saving…",
"resetPromptDefault": "Reset to default"
},
"workflowFields": {
"add": "新增欄位",
@@ -8345,7 +8353,7 @@
"conditionFailure": "",
"conditionSuccess": "",
"nodeInspector": "",
"readOnlyDuplicateToEdit": ""
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
},
"workflows": {
"aiEdit": "使用 AI 設計",
@@ -8395,7 +8403,7 @@
"mobileSelectNote": "",
"nameLabel": "工作流程名稱",
"newWorkflow": "新工作流程",
"readOnlyBuiltin": "",
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
"saved": "",
"savedNotCompilable": "",
"saveFailed": "",