From 26c8d960a24da81738388e3c26a83fccab0670f9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 21:23:41 -0700 Subject: [PATCH] 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 --- docs/dashboard-guide.md | 2 +- docs/settings-reference.md | 2 + docs/workflow-steps.md | 20 +- .../workflow-definition-store.test.ts | 25 +++ .../workflow-prompt-overrides-store.test.ts | 190 +++++++++++++++++ .../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 +- .../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 +++++++- ...rkflow-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(-) create mode 100644 packages/core/src/__tests__/workflow-prompt-overrides-store.test.ts create mode 100644 packages/core/src/__tests__/workflow-prompt-overrides.test.ts create mode 100644 packages/core/src/workflow-prompt-overrides.ts create mode 100644 packages/engine/src/__tests__/workflow-prompt-overrides-resolution.test.ts diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 6cf3eeecec..11f40743a4 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index a7be0baacf..8f9b51a214 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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 diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 4607e5c936..beed5235b3 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -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 + + + +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) diff --git a/packages/core/src/__tests__/workflow-definition-store.test.ts b/packages/core/src/__tests__/workflow-definition-store.test.ts index b52a8e4686..b9a831c7e4 100644 --- a/packages/core/src/__tests__/workflow-definition-store.test.ts +++ b/packages/core/src/__tests__/workflow-definition-store.test.ts @@ -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); }); diff --git a/packages/core/src/__tests__/workflow-prompt-overrides-store.test.ts b/packages/core/src/__tests__/workflow-prompt-overrides-store.test.ts new file mode 100644 index 0000000000..98e1dca066 --- /dev/null +++ b/packages/core/src/__tests__/workflow-prompt-overrides-store.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/__tests__/workflow-prompt-overrides.test.ts b/packages/core/src/__tests__/workflow-prompt-overrides.test.ts new file mode 100644 index 0000000000..8f80f6375c --- /dev/null +++ b/packages/core/src/__tests__/workflow-prompt-overrides.test.ts @@ -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"); + }); +}); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 86c70ede9c..83603bf28d 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -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); + `); + }); + } + } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fdc55886ef..64ee100fd9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 115bb82384..6df8418f6c 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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 { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + 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> { + 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> = {}; + 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 { + 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, + ): Record { + return this.db.transactionImmediate(() => { + const current = this.getWorkflowPromptOverrides(workflowId, projectId); + const next: Record = { ...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 diff --git a/packages/core/src/workflow-ir-resolver.ts b/packages/core/src/workflow-ir-resolver.ts index 5f25a733a2..2d6bc93925 100644 --- a/packages/core/src/workflow-ir-resolver.ts +++ b/packages/core/src/workflow-ir-resolver.ts @@ -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; } /** @@ -80,26 +83,32 @@ export async function resolveTaskPlanningPrompt( * sweep. Hits short-circuit before any builtin/db lookup. */ export async function resolveWorkflowIrById( - store: Pick, + store: Pick & Partial>, workflowId: string, irCache?: Map, ): Promise { - 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); } diff --git a/packages/core/src/workflow-prompt-overrides.ts b/packages/core/src/workflow-prompt-overrides.ts new file mode 100644 index 0000000000..b29d94511c --- /dev/null +++ b/packages/core/src/workflow-prompt-overrides.ts @@ -0,0 +1,65 @@ +import type { WorkflowIr, WorkflowIrNode } from "./workflow-ir-types.js"; + +export type WorkflowPromptOverrides = Record; + +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 | 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 | 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; +} diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 6767c4231a..03e441497f 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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; + effective: Record; + defaults: Record; +} + /** 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 { + return api( + 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, + projectId?: string, +): Promise { + return api( + 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), { diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index e94ada1125..76291dcc97 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -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; diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 69c5017161..96bb377571 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -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([]); const [optionalSteps, setOptionalSteps] = useState([]); + // 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(null); + const [promptOverrideSavingNodeId, setPromptOverrideSavingNodeId] = useState(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(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 | 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({