feat(dashboard): workflow and template JSON import/export with trust-boundary stripping
This commit is contained in:
@@ -453,6 +453,7 @@ export {
|
||||
toJson,
|
||||
toJsonNullable,
|
||||
fromJson,
|
||||
SCHEMA_VERSION,
|
||||
} from "./db.js";
|
||||
export {
|
||||
ProjectIdentityConflictError,
|
||||
|
||||
@@ -5108,6 +5108,61 @@ export function compileWorkflow(id: string, projectId?: string): Promise<{ steps
|
||||
});
|
||||
}
|
||||
|
||||
/** A workflow export envelope (U5/R9/KTD-5). `schemaVersion` is the SERVER's
|
||||
* schema version at export time — the import route version-gates against it
|
||||
* (the app build aliases @fusion/core to types-only, so the value can only come
|
||||
* from the server, never an app-side core import). */
|
||||
export interface WorkflowExportEnvelope {
|
||||
fusionWorkflowExport: 1;
|
||||
schemaVersion: number;
|
||||
kind: import("@fusion/core").WorkflowDefinition["kind"];
|
||||
name: string;
|
||||
description: string;
|
||||
ir: import("@fusion/core").WorkflowIr;
|
||||
layout: import("@fusion/core").WorkflowDefinition["layout"];
|
||||
}
|
||||
|
||||
/** Fetch a workflow's export envelope and trigger a browser download as
|
||||
* `<name>.workflow.json` (U5/R9). Built-ins are exportable too. Mirrors the
|
||||
* SettingsModal export pattern (Blob + createObjectURL + a.download). */
|
||||
export async function exportWorkflow(id: string, projectId?: string): Promise<WorkflowExportEnvelope> {
|
||||
const envelope = await api<WorkflowExportEnvelope>(
|
||||
withProjectId(`/workflows/${encodeURIComponent(id)}/export`, projectId),
|
||||
);
|
||||
const safeName = (envelope.name || "workflow").replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "workflow";
|
||||
const blob = new Blob([JSON.stringify(envelope, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${safeName}.workflow.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
return envelope;
|
||||
}
|
||||
|
||||
/** Result of POST /api/workflows/import (U5/R10). `strippedApprovalFlags` is set
|
||||
* when `cliSkipApproval`/`autoApprove` were removed from any node config at the
|
||||
* trust boundary; `warnings` lists non-blocking issues (e.g. unknown scriptName). */
|
||||
export interface ImportWorkflowResult {
|
||||
workflow: import("@fusion/core").WorkflowDefinition;
|
||||
strippedApprovalFlags: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** Import a workflow export envelope (U5/R10). The server is the sole validator;
|
||||
* validation failures reject with an ApiError carrying the server message. */
|
||||
export function importWorkflow(
|
||||
envelope: unknown,
|
||||
projectId?: string,
|
||||
): Promise<ImportWorkflowResult> {
|
||||
return api<ImportWorkflowResult>(withProjectId("/workflows/import", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(envelope),
|
||||
});
|
||||
}
|
||||
|
||||
/** Result of the lazy legacy-step migration (U2/R5). `migrated` is the number of
|
||||
* newly converted user steps; `skipped` the count already migrated; when the
|
||||
* defaultOn subset was non-empty a combined "Migrated steps" workflow id is set. */
|
||||
|
||||
@@ -102,6 +102,54 @@
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* U5/R10: sidebar import affordance + persistent inline error/warning regions. */
|
||||
.wf-editor-import {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wf-editor-import:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.wf-editor-import:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wf-editor-import-error {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: color-mix(in srgb, var(--ws-error) 8%, var(--bg-secondary));
|
||||
border: 1px solid var(--ws-error);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ws-error);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.wf-editor-import-warnings {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: color-mix(in srgb, var(--ws-warning) 8%, var(--bg-secondary));
|
||||
border: 1px solid var(--ws-warning);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ws-warning);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.wf-editor-import-warning {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wf-editor-import-warning + .wf-editor-import-warning {
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.wf-editor-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -215,6 +263,7 @@
|
||||
}
|
||||
|
||||
.wf-palette-btn,
|
||||
.wf-editor-action,
|
||||
.wf-editor-delete,
|
||||
.wf-editor-save {
|
||||
display: inline-flex;
|
||||
@@ -230,10 +279,16 @@
|
||||
}
|
||||
|
||||
.wf-palette-btn:hover,
|
||||
.wf-editor-action:hover,
|
||||
.wf-editor-delete:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.wf-editor-action:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wf-editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type Edge as FlowEdge,
|
||||
} from "@xyflow/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid, Workflow } from "lucide-react";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid, Workflow, Download, Upload } from "lucide-react";
|
||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
updateWorkflow,
|
||||
deleteWorkflow,
|
||||
compileWorkflow,
|
||||
exportWorkflow,
|
||||
importWorkflow,
|
||||
ApiRequestError,
|
||||
migrateLegacyWorkflowSteps,
|
||||
fetchModels,
|
||||
fetchAgents,
|
||||
@@ -349,6 +352,15 @@ function InnerEditor({
|
||||
);
|
||||
const [showMigrationNotice, setShowMigrationNotice] = useState(false);
|
||||
|
||||
// U5/R10: import affordance state. `importError` renders a PERSISTENT inline
|
||||
// error region (not a toast) for client parse failures and server 4xx
|
||||
// validation failures; `importWarnings` renders non-blocking notes in the same
|
||||
// region. The hidden file input is reset after every attempt.
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [importWarnings, setImportWarnings] = useState<string[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]);
|
||||
const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id);
|
||||
|
||||
@@ -475,6 +487,68 @@ function InnerEditor({
|
||||
}
|
||||
}, [migrationNoticeStorageKey]);
|
||||
|
||||
// U5/R9: export the active workflow as a downloaded JSON envelope. Enabled for
|
||||
// built-ins; the caller gates on `isDirty` (a stale export is impossible
|
||||
// because the server reads the persisted definition). Network failures toast.
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!activeWorkflow) return;
|
||||
try {
|
||||
await exportWorkflow(activeWorkflow.id, projectId);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || t("workflows.exportFailed", "Failed to export workflow"), "error");
|
||||
}
|
||||
}, [activeWorkflow, projectId, addToast, t]);
|
||||
|
||||
// U5/R10: import a workflow envelope from a selected file. Validation failures
|
||||
// (client JSON.parse or server 4xx) populate the PERSISTENT inline error region
|
||||
// — never a toast. Network/5xx errors toast. The file input resets after every
|
||||
// attempt so re-selecting the same file fires `onChange` again.
|
||||
const handleImportFile = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
// Reset the input immediately so the same file can be re-picked later.
|
||||
if (importInputRef.current) importInputRef.current.value = "";
|
||||
if (!file) return;
|
||||
setImportError(null);
|
||||
setImportWarnings([]);
|
||||
setImporting(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
let envelope: unknown;
|
||||
try {
|
||||
envelope = JSON.parse(text);
|
||||
} catch {
|
||||
setImportError(t("workflows.importInvalidJson", "That file isn't valid JSON."));
|
||||
return;
|
||||
}
|
||||
const result = await importWorkflow(envelope, projectId);
|
||||
await loadWorkflows();
|
||||
setActiveId(result.workflow.id);
|
||||
addToast(
|
||||
t("workflows.imported", 'Imported workflow "{{name}}"', { name: result.workflow.name }),
|
||||
"success",
|
||||
);
|
||||
if (result.strippedApprovalFlags) {
|
||||
addToast(
|
||||
t("workflows.importStripped", "Auto-approval flags were removed from imported nodes"),
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
if (result.warnings.length > 0) setImportWarnings(result.warnings);
|
||||
} catch (err) {
|
||||
// 4xx → persistent inline validation error; anything else → toast.
|
||||
if (err instanceof ApiRequestError && err.status >= 400 && err.status < 500) {
|
||||
setImportError(getErrorMessage(err) || t("workflows.importFailed", "Import failed"));
|
||||
} else {
|
||||
addToast(getErrorMessage(err) || t("workflows.importFailed", "Import failed"), "error");
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
},
|
||||
[projectId, loadWorkflows, addToast, t],
|
||||
);
|
||||
|
||||
// Load the active workflow graph into the canvas.
|
||||
useEffect(() => {
|
||||
if (!activeWorkflow) {
|
||||
@@ -1151,6 +1225,43 @@ function InnerEditor({
|
||||
>
|
||||
<Plus size={14} /> {t("workflows.newWorkflow", "New workflow")}
|
||||
</button>
|
||||
{/* U5/R10: keyboard-accessible import affordance triggering a hidden
|
||||
file input; validation failures render in the persistent inline
|
||||
region below (role="alert"), not a toast. */}
|
||||
<button
|
||||
type="button"
|
||||
className="wf-editor-import"
|
||||
data-testid="wf-import"
|
||||
disabled={importing}
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
title={t("workflows.importTooltip", "Import a workflow from a JSON file")}
|
||||
>
|
||||
{importing ? <Loader2 size={14} className="wf-spin" /> : <Upload size={14} />}{" "}
|
||||
{t("workflows.import", "Import")}
|
||||
</button>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="wf-editor-import-input"
|
||||
data-testid="wf-import-input"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
{importError && (
|
||||
<div className="wf-editor-import-error" role="alert" data-testid="wf-import-error">
|
||||
{importError}
|
||||
</div>
|
||||
)}
|
||||
{importWarnings.length > 0 && (
|
||||
<div className="wf-editor-import-warnings" data-testid="wf-import-warnings">
|
||||
{importWarnings.map((w, i) => (
|
||||
<p key={i} className="wf-editor-import-warning">
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="wf-editor-empty">
|
||||
<Loader2 size={16} className="wf-spin" /> Loading…
|
||||
@@ -1265,6 +1376,17 @@ function InnerEditor({
|
||||
<span className="wf-editor-readonly-note">
|
||||
{t("workflows.readOnlyBuiltin", "Read-only built-in workflow")}
|
||||
</span>
|
||||
<button
|
||||
className="wf-editor-action"
|
||||
data-testid="wf-export"
|
||||
onClick={handleExport}
|
||||
title={t(
|
||||
"workflows.exportTooltip",
|
||||
"Download as JSON — contains your full prompt and command text",
|
||||
)}
|
||||
>
|
||||
<Download size={13} /> {t("workflows.export", "Export")}
|
||||
</button>
|
||||
<button className="wf-editor-save wf-editor-duplicate-primary" onClick={handleDuplicate}>
|
||||
<Plus size={13} /> {t("workflows.duplicateToCustomize", "Duplicate to customize")}
|
||||
</button>
|
||||
@@ -1290,6 +1412,22 @@ function InnerEditor({
|
||||
>
|
||||
<LayoutGrid size={13} /> {t("workflowNodes.autoLayout", "Auto-layout")}
|
||||
</button>
|
||||
<button
|
||||
className="wf-editor-action"
|
||||
data-testid="wf-export"
|
||||
onClick={handleExport}
|
||||
disabled={isDirty}
|
||||
title={
|
||||
isDirty
|
||||
? t("workflows.exportDirtyTooltip", "Save before exporting")
|
||||
: t(
|
||||
"workflows.exportTooltip",
|
||||
"Download as JSON — contains your full prompt and command text",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Download size={13} /> {t("workflows.export", "Export")}
|
||||
</button>
|
||||
<button className="wf-editor-delete" onClick={handleDeleteWorkflow}>
|
||||
<Trash2 size={13} /> {t("common.delete", "Delete")}
|
||||
</button>
|
||||
|
||||
@@ -10,6 +10,16 @@ vi.mock("../../api", () => ({
|
||||
updateWorkflow: vi.fn(),
|
||||
deleteWorkflow: vi.fn(),
|
||||
compileWorkflow: vi.fn(),
|
||||
exportWorkflow: vi.fn(),
|
||||
importWorkflow: vi.fn(),
|
||||
ApiRequestError: class ApiRequestError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "ApiRequestError";
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
migrateLegacyWorkflowSteps: vi.fn(),
|
||||
fetchTraits: vi.fn(),
|
||||
fetchStepParsers: vi.fn(),
|
||||
@@ -19,7 +29,7 @@ vi.mock("../../api", () => ({
|
||||
}));
|
||||
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, deleteWorkflow, fetchModels, migrateLegacyWorkflowSteps } from "../../api";
|
||||
import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, deleteWorkflow, fetchModels, migrateLegacyWorkflowSteps, exportWorkflow, importWorkflow, ApiRequestError } from "../../api";
|
||||
import type { TraitCatalogEntry } from "../../api";
|
||||
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
|
||||
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
|
||||
@@ -1131,3 +1141,128 @@ describe("WorkflowNodeEditor — U2 legacy-step migration notice", () => {
|
||||
expect(screen.queryByTestId("wf-migration-notice")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── U5: import/export ───────────────────────────────────────────────────────
|
||||
|
||||
describe("WorkflowNodeEditor — U5 import/export", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
|
||||
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
|
||||
vi.mocked(migrateLegacyWorkflowSteps).mockResolvedValue({ migrated: 0, skipped: 0 });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("export button is enabled on a clean canvas and disabled after an edit", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
const exportBtn = await screen.findByTestId("wf-export");
|
||||
// Clean canvas → enabled.
|
||||
await waitFor(() => expect(exportBtn).not.toBeDisabled());
|
||||
|
||||
// Make an edit: rename the workflow (click the name, type a new value).
|
||||
fireEvent.click(screen.getByTestId("wf-workflow-name"));
|
||||
const nameInput = await screen.findByTestId("wf-workflow-name-input");
|
||||
fireEvent.change(nameInput, { target: { value: "Custom edited" } });
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("wf-export")).toBeDisabled());
|
||||
});
|
||||
|
||||
it("export click downloads via exportWorkflow", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
vi.mocked(exportWorkflow).mockResolvedValue({
|
||||
fusionWorkflowExport: 1,
|
||||
schemaVersion: 109,
|
||||
kind: "workflow",
|
||||
name: "Custom",
|
||||
description: "",
|
||||
ir: v2Def().ir,
|
||||
layout: {},
|
||||
});
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
const exportBtn = await screen.findByTestId("wf-export");
|
||||
await waitFor(() => expect(exportBtn).not.toBeDisabled());
|
||||
fireEvent.click(exportBtn);
|
||||
await waitFor(() => expect(exportWorkflow).toHaveBeenCalledWith("WF-002", undefined));
|
||||
});
|
||||
|
||||
it("import success refreshes the list, activates the imported workflow, and toasts", async () => {
|
||||
const addToast = vi.fn();
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
const imported: WorkflowDefinition = { ...v2Def(), id: "WF-IMP", name: "Brought in" };
|
||||
vi.mocked(importWorkflow).mockResolvedValue({
|
||||
workflow: imported,
|
||||
strippedApprovalFlags: false,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={addToast} />);
|
||||
await screen.findByTestId("wf-import");
|
||||
// After the import resolves, loadWorkflows re-runs; return the imported one.
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([imported]);
|
||||
|
||||
const fileInput = screen.getByTestId("wf-import-input") as HTMLInputElement;
|
||||
const file = new File([JSON.stringify({ fusionWorkflowExport: 1 })], "wf.json", { type: "application/json" });
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => expect(importWorkflow).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/Brought in/), "success"),
|
||||
);
|
||||
// Imported workflow is now active in the sidebar (its list item carries the
|
||||
// active class) and rendered into the canvas — appearing more than once.
|
||||
await waitFor(() => expect(screen.getAllByText("Brought in").length).toBeGreaterThan(0));
|
||||
const activeItem = document.querySelector(".wf-editor-list-item.active");
|
||||
expect(activeItem).toHaveTextContent("Brought in");
|
||||
});
|
||||
|
||||
it("import 4xx renders the persistent inline error region; list unchanged", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(importWorkflow).mockRejectedValue(
|
||||
new ApiRequestError("Not a Fusion workflow export file", 400),
|
||||
);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-import");
|
||||
|
||||
const fileInput = screen.getByTestId("wf-import-input") as HTMLInputElement;
|
||||
const file = new File([JSON.stringify({ nope: true })], "wf.json", { type: "application/json" });
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
|
||||
const errorRegion = await screen.findByTestId("wf-import-error");
|
||||
expect(errorRegion).toHaveTextContent("Not a Fusion workflow export file");
|
||||
expect(errorRegion).toHaveAttribute("role", "alert");
|
||||
// List unchanged: still no workflows.
|
||||
expect(screen.getByText(/No workflows yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("import strip notice toast fires when approval flags were removed", async () => {
|
||||
const addToast = vi.fn();
|
||||
const imported: WorkflowDefinition = { ...v2Def(), id: "WF-IMP2", name: "Stripped in" };
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(importWorkflow).mockResolvedValue({
|
||||
workflow: imported,
|
||||
strippedApprovalFlags: true,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={addToast} />);
|
||||
await screen.findByTestId("wf-import");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([imported]);
|
||||
|
||||
const fileInput = screen.getByTestId("wf-import-input") as HTMLInputElement;
|
||||
const file = new File([JSON.stringify({ fusionWorkflowExport: 1 })], "wf.json", { type: "application/json" });
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Auto-approval flags were removed/),
|
||||
"warning",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// @vitest-environment node
|
||||
//
|
||||
// U5/R9/R10 — HTTP integration coverage for GET /api/workflows/:id/export and
|
||||
// POST /api/workflows/import. Exercises the routes end-to-end against a REAL
|
||||
// TaskStore (no store-method mocking — mock-masked dead-wiring learning): the
|
||||
// import route must validate the envelope at the write boundary and persist a
|
||||
// fresh definition only when every gate passes.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore, SCHEMA_VERSION, isBuiltinWorkflowId } from "@fusion/core";
|
||||
import type { WorkflowIr } from "@fusion/core";
|
||||
import { registerWorkflowRoutes } from "../register-workflow-routes.js";
|
||||
import { ApiError, sendErrorResponse } from "../../api-error.js";
|
||||
import { request } from "../../test-request.js";
|
||||
|
||||
describe("workflow import/export routes (U5/R9/R10)", () => {
|
||||
let store: TaskStore;
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "wf-impexp-root-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "wf-impexp-global-"));
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
const router = express.Router();
|
||||
registerWorkflowRoutes({
|
||||
router,
|
||||
getProjectContext: async () => ({ store, engine: undefined, projectId: undefined }),
|
||||
rethrowAsApiError: (err: unknown) => {
|
||||
throw err instanceof ApiError ? err : new ApiError(500, err instanceof Error ? err.message : String(err));
|
||||
},
|
||||
} as unknown as Parameters<typeof registerWorkflowRoutes>[0]);
|
||||
app.use("/api", router);
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
if (err instanceof ApiError) sendErrorResponse(res, err.statusCode, err.message, { details: err.details });
|
||||
else sendErrorResponse(res, 500, err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
rmSync(globalDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const get = (path: string) => request(app, "GET", path);
|
||||
const postJson = (path: string, body: unknown) =>
|
||||
request(app, "POST", path, JSON.stringify(body), { "Content-Type": "application/json" });
|
||||
|
||||
/** A minimal valid v1 linear IR with one prompt node. */
|
||||
function linearIr(overrides?: { nodeConfig?: Record<string, unknown> }): WorkflowIr {
|
||||
return {
|
||||
version: "v1",
|
||||
name: "graph",
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "n1", kind: "prompt", config: { name: "Do it", prompt: "go", ...(overrides?.nodeConfig ?? {}) } },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "n1", condition: "success" },
|
||||
{ from: "n1", to: "end", condition: "success" },
|
||||
],
|
||||
} as WorkflowIr;
|
||||
}
|
||||
|
||||
function envelope(overrides?: Partial<Record<string, unknown>>): Record<string, unknown> {
|
||||
return {
|
||||
fusionWorkflowExport: 1,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
kind: "workflow",
|
||||
name: "Imported flow",
|
||||
description: "desc",
|
||||
ir: linearIr(),
|
||||
layout: { n1: { x: 10, y: 20 } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function userDefs() {
|
||||
return (await store.listWorkflowDefinitions()).filter((w) => !isBuiltinWorkflowId(w.id));
|
||||
}
|
||||
|
||||
it("round-trips export → import reproducing ir/layout/description and kind", async () => {
|
||||
const created = await store.createWorkflowDefinition({
|
||||
name: "Source flow",
|
||||
description: "round trip",
|
||||
kind: "fragment",
|
||||
ir: linearIr(),
|
||||
layout: { n1: { x: 5, y: 6 } },
|
||||
});
|
||||
|
||||
const exp = await get(`/api/workflows/${created.id}/export`);
|
||||
expect(exp.status).toBe(200);
|
||||
const env = exp.body as Record<string, unknown>;
|
||||
expect(env.fusionWorkflowExport).toBe(1);
|
||||
expect(env.schemaVersion).toBe(SCHEMA_VERSION);
|
||||
expect(env.kind).toBe("fragment");
|
||||
|
||||
const imp = await postJson("/api/workflows/import", env);
|
||||
expect(imp.status).toBe(201);
|
||||
const body = imp.body as { workflow: { id: string; kind: string; description: string; layout: unknown; ir: WorkflowIr } };
|
||||
expect(body.workflow.id).not.toBe(created.id);
|
||||
expect(body.workflow.kind).toBe("fragment");
|
||||
expect(body.workflow.description).toBe("round trip");
|
||||
expect(body.workflow.layout).toEqual({ n1: { x: 5, y: 6 } });
|
||||
// Semantic IR equality: same node ids/kinds.
|
||||
expect(body.workflow.ir.nodes.map((n) => n.id)).toEqual(created.ir.nodes.map((n) => n.id));
|
||||
});
|
||||
|
||||
it("suffixes the name on collision", async () => {
|
||||
await store.createWorkflowDefinition({ name: "Imported flow", ir: linearIr() });
|
||||
const res = await postJson("/api/workflows/import", envelope());
|
||||
expect(res.status).toBe(201);
|
||||
const body = res.body as { workflow: { name: string } };
|
||||
expect(body.workflow.name).toBe("Imported flow (imported)");
|
||||
});
|
||||
|
||||
it("exporting a built-in yields a fresh, non-builtin, editable id on import", async () => {
|
||||
const exp = await get(`/api/workflows/builtin:coding/export`);
|
||||
expect(exp.status).toBe(200);
|
||||
const imp = await postJson("/api/workflows/import", exp.body);
|
||||
expect(imp.status).toBe(201);
|
||||
const body = imp.body as { workflow: { id: string } };
|
||||
expect(isBuiltinWorkflowId(body.workflow.id)).toBe(false);
|
||||
// Editable: an update succeeds (built-ins reject).
|
||||
await expect(store.updateWorkflowDefinition(body.workflow.id, { description: "edited" })).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects a missing envelope marker with 400 and persists nothing", async () => {
|
||||
const res = await postJson("/api/workflows/import", { ...envelope(), fusionWorkflowExport: undefined });
|
||||
expect(res.status).toBe(400);
|
||||
expect(await userDefs()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a malformed IR with 422 carrying the parser message and zero writes", async () => {
|
||||
const res = await postJson("/api/workflows/import", envelope({ ir: { version: "v1", nodes: "nope" } }));
|
||||
expect(res.status).toBe(422);
|
||||
expect((res.body as { error?: string }).error).toBeTruthy();
|
||||
expect(await userDefs()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects an unknown trait with 422 naming the trait", async () => {
|
||||
const v2Ir: WorkflowIr = {
|
||||
version: "v2",
|
||||
name: "traited",
|
||||
columns: [{ id: "c1", name: "C1", traits: [{ trait: "totally-bogus-trait" }] }],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "c1" },
|
||||
{ id: "end", kind: "end", column: "c1" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end", condition: "success" }],
|
||||
} as WorkflowIr;
|
||||
const res = await postJson("/api/workflows/import", envelope({ ir: v2Ir }));
|
||||
expect(res.status).toBe(422);
|
||||
expect((res.body as { error: string }).error).toContain("totally-bogus-trait");
|
||||
expect(await userDefs()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a newer schemaVersion with 409 naming both versions", async () => {
|
||||
const res = await postJson("/api/workflows/import", envelope({ schemaVersion: 9999 }));
|
||||
expect(res.status).toBe(409);
|
||||
const msg = (res.body as { error: string }).error;
|
||||
expect(msg).toContain("9999");
|
||||
expect(msg).toContain(String(SCHEMA_VERSION));
|
||||
expect(await userDefs()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts an older-or-equal schemaVersion with 201", async () => {
|
||||
const older = await postJson("/api/workflows/import", envelope({ schemaVersion: 1, name: "Older" }));
|
||||
expect(older.status).toBe(201);
|
||||
const equal = await postJson("/api/workflows/import", envelope({ name: "Equal" }));
|
||||
expect(equal.status).toBe(201);
|
||||
});
|
||||
|
||||
it("strips cliSkipApproval from node config and flags it in the response", async () => {
|
||||
const res = await postJson(
|
||||
"/api/workflows/import",
|
||||
envelope({ ir: linearIr({ nodeConfig: { cliSkipApproval: true, autoApprove: true } }) }),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const body = res.body as { workflow: { id: string; ir: WorkflowIr }; strippedApprovalFlags: boolean };
|
||||
expect(body.strippedApprovalFlags).toBe(true);
|
||||
const node = body.workflow.ir.nodes.find((n) => n.id === "n1");
|
||||
expect(node?.config?.cliSkipApproval).toBeUndefined();
|
||||
expect(node?.config?.autoApprove).toBeUndefined();
|
||||
// Persisted definition also lacks the flags.
|
||||
const persisted = await store.getWorkflowDefinition(body.workflow.id);
|
||||
const pnode = persisted?.ir.nodes.find((n) => n.id === "n1");
|
||||
expect(pnode?.config?.cliSkipApproval).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns (non-blocking) when a script node references an unknown scriptName", async () => {
|
||||
const scriptIr = linearIr();
|
||||
(scriptIr.nodes[1] as { kind: string }).kind = "script";
|
||||
(scriptIr.nodes[1] as { config: Record<string, unknown> }).config = { name: "Run", scriptName: "ghost-script" };
|
||||
const res = await postJson("/api/workflows/import", envelope({ ir: scriptIr }));
|
||||
expect(res.status).toBe(201);
|
||||
const body = res.body as { warnings: string[] };
|
||||
expect(body.warnings.some((w) => w.includes("ghost-script"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WorkflowIr, WorkflowIrNode } from "@fusion/core";
|
||||
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, compileWorkflowToSteps, listTraits, listStepParsers } from "@fusion/core";
|
||||
import type { WorkflowDefinition, WorkflowDefinitionKind, WorkflowIr, WorkflowIrNode } from "@fusion/core";
|
||||
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, SCHEMA_VERSION, assertColumnTraitsValid, compileWorkflowToSteps, listTraits, listStepParsers, parseWorkflowIr } from "@fusion/core";
|
||||
import { validateCodeNodeSources } from "@fusion/engine";
|
||||
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
|
||||
import { emitWorkflowSseEvent } from "../sse.js";
|
||||
@@ -372,4 +372,221 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/workflows/:id/export — emit a portable, versioned JSON envelope for
|
||||
// a single workflow or fragment (U5/R9/KTD-5). Built-ins are exportable too —
|
||||
// the lookup mirrors GET /workflows/:id (built-ins resolved by
|
||||
// getWorkflowDefinition). The envelope carries the server's SCHEMA_VERSION so
|
||||
// import can version-gate it; the client triggers a file download.
|
||||
router.get("/workflows/:id/export", async (req, res) => {
|
||||
try {
|
||||
const { store } = await getProjectContext(req);
|
||||
const def = await store.getWorkflowDefinition(req.params.id);
|
||||
if (!def) throw notFound(`Workflow '${req.params.id}' not found`);
|
||||
res.json({
|
||||
fusionWorkflowExport: 1,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
kind: def.kind,
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
ir: def.ir,
|
||||
layout: def.layout,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/workflows/import — validate a workflow export envelope at the write
|
||||
// boundary and create a fresh definition (U5/R10/KTD-5). Validation order is
|
||||
// strict: ANY failure short-circuits with a 4xx and ZERO writes.
|
||||
// 1. envelope marker → 400
|
||||
// 2. schemaVersion > server's → 409 (forward-incompatible)
|
||||
// 3. parseWorkflowIr → 422 (parser message)
|
||||
// 4. trait availability → 422 (names the missing trait)
|
||||
// 5. strip cliSkipApproval/autoApprove from every node config (incl. foreach
|
||||
// template nodes) — trust boundary; flagged in the response.
|
||||
// 6. scriptName existence → non-blocking WARNINGS
|
||||
// 7. fresh id + name collision suffix → store.createWorkflowDefinition
|
||||
router.post("/workflows/import", async (req, res) => {
|
||||
try {
|
||||
const { store, projectId } = await getProjectContext(req);
|
||||
const envelope = (req.body ?? {}) as Record<string, unknown>;
|
||||
|
||||
// 1. Envelope marker.
|
||||
if (envelope.fusionWorkflowExport !== 1) {
|
||||
throw badRequest(
|
||||
"Not a Fusion workflow export file (missing or invalid fusionWorkflowExport marker)",
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Schema version gate: equal/older accepted, newer rejected.
|
||||
const schemaVersion = envelope.schemaVersion;
|
||||
if (typeof schemaVersion === "number" && schemaVersion > SCHEMA_VERSION) {
|
||||
throw conflict(
|
||||
`This file was exported from a newer Fusion (schema version ${schemaVersion}); this server supports up to ${SCHEMA_VERSION}. Update Fusion to import it.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Parse/validate the IR (parser message surfaced as 422).
|
||||
let ir: WorkflowIr;
|
||||
try {
|
||||
ir = parseWorkflowIr(envelope.ir as WorkflowIr);
|
||||
} catch (parseErr: unknown) {
|
||||
if (parseErr instanceof WorkflowIrError) {
|
||||
throw new ApiError(422, parseErr.message);
|
||||
}
|
||||
throw new ApiError(
|
||||
422,
|
||||
parseErr instanceof Error ? parseErr.message : "Invalid workflow IR",
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Trait availability (v2 columns) — names the missing/unknown trait.
|
||||
try {
|
||||
assertImportTraitsValid(ir);
|
||||
} catch (traitErr: unknown) {
|
||||
if (traitErr instanceof ColumnTraitValidationError) {
|
||||
throw new ApiError(422, traitErr.message);
|
||||
}
|
||||
throw traitErr;
|
||||
}
|
||||
|
||||
// 5. Strip trust-escalating flags from every node config (incl. foreach
|
||||
// templates). Operates on the parsed IR so the stored definition can never
|
||||
// carry an approval bypass smuggled through an untrusted file.
|
||||
const strippedApprovalFlags = stripApprovalFlags(ir);
|
||||
|
||||
// 6. scriptName warnings (non-blocking): a script node referencing a name
|
||||
// absent from the project's configured scripts is importable, but flagged.
|
||||
const settings = await store.getSettingsFast();
|
||||
const knownScripts = new Set(Object.keys(settings.scripts ?? {}));
|
||||
const warnings = collectScriptNameWarnings(ir, knownScripts);
|
||||
|
||||
// 7. Fresh id is server-minted by createWorkflowDefinition; resolve a
|
||||
// collision-free name (case-sensitive exact match across the merged set,
|
||||
// built-ins included).
|
||||
const existingNames = new Set(
|
||||
(await store.listWorkflowDefinitions()).map((w) => w.name),
|
||||
);
|
||||
const rawName =
|
||||
typeof envelope.name === "string" && envelope.name.trim()
|
||||
? envelope.name.trim()
|
||||
: "Imported workflow";
|
||||
const name = resolveImportName(rawName, existingNames);
|
||||
|
||||
const kind: WorkflowDefinitionKind =
|
||||
envelope.kind === "fragment" ? "fragment" : "workflow";
|
||||
const layout =
|
||||
envelope.layout && typeof envelope.layout === "object"
|
||||
? (envelope.layout as WorkflowDefinition["layout"])
|
||||
: {};
|
||||
|
||||
let workflow: WorkflowDefinition;
|
||||
try {
|
||||
workflow = await store.createWorkflowDefinition({
|
||||
name,
|
||||
description:
|
||||
typeof envelope.description === "string" ? envelope.description : "",
|
||||
kind,
|
||||
ir,
|
||||
layout,
|
||||
});
|
||||
} catch (createErr: unknown) {
|
||||
// The store re-validates IR/traits; surface those as 422 (the envelope is
|
||||
// the untrusted input) rather than a 500.
|
||||
if (createErr instanceof WorkflowIrError) throw new ApiError(422, createErr.message);
|
||||
if (createErr instanceof ColumnTraitValidationError) {
|
||||
throw new ApiError(422, createErr.message);
|
||||
}
|
||||
throw createErr;
|
||||
}
|
||||
|
||||
emitWorkflowSseEvent("workflow:created", workflow, projectId);
|
||||
res.status(201).json({ workflow, strippedApprovalFlags, warnings });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Validate trait availability for an imported IR exactly as the store does on
|
||||
* create (v1 IRs with no columns are a no-op). Kept local to the import route so
|
||||
* the 422 fires BEFORE any write — the store would also reject, but importing
|
||||
* must short-circuit on the untrusted envelope, not after a partial write. */
|
||||
function assertImportTraitsValid(ir: WorkflowIr): void {
|
||||
const columns = (ir as { columns?: Parameters<typeof assertColumnTraitsValid>[0] }).columns;
|
||||
if (Array.isArray(columns) && columns.length > 0) {
|
||||
// Throws ColumnTraitValidationError naming the unknown trait.
|
||||
assertColumnTraitsValid(columns);
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip `cliSkipApproval`/`autoApprove` from every node config in the IR,
|
||||
* including configs nested inside foreach `template.nodes`. Returns true when
|
||||
* anything was removed so the response can flag it (R10 trust boundary). */
|
||||
function stripApprovalFlags(ir: WorkflowIr): boolean {
|
||||
const nodes = (ir as { nodes?: WorkflowIrNode[] }).nodes;
|
||||
if (!Array.isArray(nodes)) return false;
|
||||
let stripped = false;
|
||||
const stripNode = (node: WorkflowIrNode): void => {
|
||||
const cfg = node.config as Record<string, unknown> | undefined;
|
||||
if (cfg && typeof cfg === "object") {
|
||||
if ("cliSkipApproval" in cfg) {
|
||||
delete cfg.cliSkipApproval;
|
||||
stripped = true;
|
||||
}
|
||||
if ("autoApprove" in cfg) {
|
||||
delete cfg.autoApprove;
|
||||
stripped = true;
|
||||
}
|
||||
const template = (cfg as { template?: { nodes?: unknown } }).template;
|
||||
if (template && Array.isArray(template.nodes)) {
|
||||
for (const inner of template.nodes as WorkflowIrNode[]) stripNode(inner);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const node of nodes) stripNode(node);
|
||||
return stripped;
|
||||
}
|
||||
|
||||
/** Collect non-blocking warnings for script nodes (and any config carrying a
|
||||
* `scriptName`) whose script is absent from the project's configured scripts.
|
||||
* Recurses into foreach templates so nested script nodes are covered too. */
|
||||
function collectScriptNameWarnings(ir: WorkflowIr, knownScripts: Set<string>): string[] {
|
||||
const nodes = (ir as { nodes?: WorkflowIrNode[] }).nodes;
|
||||
if (!Array.isArray(nodes)) return [];
|
||||
const warnings: string[] = [];
|
||||
const visit = (node: WorkflowIrNode): void => {
|
||||
const cfg = node.config as Record<string, unknown> | undefined;
|
||||
if (cfg && typeof cfg === "object") {
|
||||
const scriptName = cfg.scriptName;
|
||||
if (typeof scriptName === "string" && scriptName.trim() && !knownScripts.has(scriptName)) {
|
||||
warnings.push(
|
||||
`Node '${node.id}' references script '${scriptName}', which is not configured in this project. Add it under Settings → Scripts before running this workflow.`,
|
||||
);
|
||||
}
|
||||
const template = (cfg as { template?: { nodes?: unknown } }).template;
|
||||
if (template && Array.isArray(template.nodes)) {
|
||||
for (const inner of template.nodes as WorkflowIrNode[]) visit(inner);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const node of nodes) visit(node);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/** Case-sensitive exact-match collision policy (R10/KTD-5): append " (imported)"
|
||||
* then " (imported 2)", " (imported 3)" … until the name is unique. */
|
||||
function resolveImportName(baseName: string, existing: Set<string>): string {
|
||||
if (!existing.has(baseName)) return baseName;
|
||||
let candidate = `${baseName} (imported)`;
|
||||
let n = 2;
|
||||
while (existing.has(candidate)) {
|
||||
candidate = `${baseName} (imported ${n})`;
|
||||
n += 1;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
@@ -6840,6 +6840,16 @@
|
||||
"duplicateToCustomize": "Duplicate to customize",
|
||||
"emptyDescription": "Workflows orchestrate the steps and gates that run around task execution. Create one to start arranging that flow.",
|
||||
"emptyTitle": "No workflow selected",
|
||||
"export": "Export",
|
||||
"exportDirtyTooltip": "Save before exporting",
|
||||
"exportFailed": "Failed to export workflow",
|
||||
"exportTooltip": "Download as JSON — contains your full prompt and command text",
|
||||
"import": "Import",
|
||||
"imported": "Imported workflow \"{{name}}\"",
|
||||
"importFailed": "Import failed",
|
||||
"importInvalidJson": "That file isn't valid JSON.",
|
||||
"importStripped": "Auto-approval flags were removed from imported nodes",
|
||||
"importTooltip": "Import a workflow from a JSON file",
|
||||
"migrationNotice": "Your legacy workflow steps were converted — find them as templates in the palette and as the \"Migrated steps\" workflow.",
|
||||
"nameLabel": "Workflow name",
|
||||
"newWorkflow": "New workflow",
|
||||
|
||||
10
packages/i18n/src/resources.d.ts
vendored
10
packages/i18n/src/resources.d.ts
vendored
@@ -6848,6 +6848,16 @@ export default interface Resources {
|
||||
"duplicateToCustomize": "Duplicate to customize",
|
||||
"emptyDescription": "Workflows orchestrate the steps and gates that run around task execution. Create one to start arranging that flow.",
|
||||
"emptyTitle": "No workflow selected",
|
||||
"export": "Export",
|
||||
"exportDirtyTooltip": "Save before exporting",
|
||||
"exportFailed": "Failed to export workflow",
|
||||
"exportTooltip": "Download as JSON — contains your full prompt and command text",
|
||||
"import": "Import",
|
||||
"importFailed": "Import failed",
|
||||
"importInvalidJson": "That file isn't valid JSON.",
|
||||
"importStripped": "Auto-approval flags were removed from imported nodes",
|
||||
"importTooltip": "Import a workflow from a JSON file",
|
||||
"imported": "Imported workflow \"{{name}}\"",
|
||||
"migrationNotice": "Your legacy workflow steps were converted — find them as templates in the palette and as the \"Migrated steps\" workflow.",
|
||||
"nameLabel": "Workflow name",
|
||||
"newWorkflow": "New workflow",
|
||||
|
||||
Reference in New Issue
Block a user