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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
65
packages/core/src/workflow-prompt-overrides.ts
Normal file
65
packages/core/src/workflow-prompt-overrides.ts
Normal 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;
|
||||
}
|
||||
@@ -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), {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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". */}
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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) ────
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
Reference in New Issue
Block a user