feat(dashboard): visual graph node editor for workflows (U6, U7)

Add a React Flow (@xyflow/react) based WorkflowNodeEditor: a lazy-loaded modal
with a workflow list, a node palette (prompt/script/gate/merge-boundary),
drag-to-connect edges, a per-node inspector, and save with compile-validation
that surfaces non-linear graphs as a banner. Pure irToFlow/flowToIr mapping
round-trips the v1 IR plus editor layout. Reachable via a Graph editor button in
the Workflow Steps manager; mounted in AppModals behind a new modal-manager
flag. Adds a vendor-reactflow Vite chunk and a feature changeset.
This commit is contained in:
gsxdsm
2026-06-03 09:45:26 -07:00
parent 80470155eb
commit d071aecda6
14 changed files with 1184 additions and 32 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add executable custom workflows with a visual graph node editor. Author a workflow as a graph (start → prompt/script/gate steps → end) in a new React Flow–based editor, then select it per task or set a project default. Selected workflows compile to the existing WorkflowStep engine and run at the pre/post-merge boundaries — no changes to the scheduler/executor/merger. Non-linear graphs are rejected with a clear message and reserved for the (deferred) graph interpreter.

View File

@@ -29,6 +29,7 @@ import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
const SetupWizardModal = lazy(() => import("./SetupWizardModal").then((m) => ({ default: m.SetupWizardModal }))); const SetupWizardModal = lazy(() => import("./SetupWizardModal").then((m) => ({ default: m.SetupWizardModal })));
const SettingsModal = lazy(() => import("./SettingsModal").then((m) => ({ default: m.SettingsModal }))); const SettingsModal = lazy(() => import("./SettingsModal").then((m) => ({ default: m.SettingsModal })));
const WorkflowNodeEditor = lazy(() => import("./WorkflowNodeEditor").then((m) => ({ default: m.WorkflowNodeEditor })));
function prefetchSettingsModal() { function prefetchSettingsModal() {
const idle: (cb: () => void, opts?: { timeout?: number }) => number = const idle: (cb: () => void, opts?: { timeout?: number }) => number =
@@ -378,9 +379,26 @@ export function AppModals({
onClose={modalManager.closeWorkflowSteps} onClose={modalManager.closeWorkflowSteps}
addToast={addToast} addToast={addToast}
projectId={projectId} projectId={projectId}
onOpenGraphEditor={() => {
modalManager.closeWorkflowSteps();
modalManager.openWorkflowEditor();
}}
/> />
</ModalErrorBoundary> </ModalErrorBoundary>
{modalManager.workflowEditorOpen && (
<ModalErrorBoundary>
<Suspense fallback={null}>
<WorkflowNodeEditor
isOpen={modalManager.workflowEditorOpen}
onClose={modalManager.closeWorkflowEditor}
addToast={addToast}
projectId={projectId}
/>
</Suspense>
</ModalErrorBoundary>
)}
<AgentListModal <AgentListModal
isOpen={modalManager.agentsOpen} isOpen={modalManager.agentsOpen}
onClose={modalManager.closeAgents} onClose={modalManager.closeAgents}

View File

@@ -0,0 +1,297 @@
.wf-editor-modal {
display: flex;
flex-direction: column;
width: min(1200px, 95vw);
height: min(820px, 92vh);
min-width: 640px;
min-height: 480px;
resize: both;
overflow: hidden;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.wf-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md);
border-bottom: 1px solid var(--border);
}
.wf-editor-header h2 {
margin: 0;
font-size: 1rem;
color: var(--text);
}
.wf-editor-close {
display: inline-flex;
align-items: center;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
}
.wf-editor-close:hover {
color: var(--text);
}
.wf-editor-body {
display: flex;
flex: 1;
min-height: 0;
}
.wf-editor-sidebar {
display: flex;
flex-direction: column;
gap: var(--space-xs);
width: 220px;
padding: var(--space-sm);
border-right: 1px solid var(--border);
overflow-y: auto;
}
.wf-editor-new {
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-new:hover {
background: var(--bg-tertiary);
}
.wf-editor-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.wf-editor-list-item {
width: 100%;
text-align: left;
padding: var(--space-xs) var(--space-sm);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text);
cursor: pointer;
}
.wf-editor-list-item:hover {
background: var(--bg-secondary);
}
.wf-editor-list-item.active {
background: var(--bg-tertiary);
border-color: var(--border);
}
.wf-editor-empty {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-md);
color: var(--text-muted);
font-size: 0.85rem;
}
.wf-editor-canvas-wrap {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.wf-editor-canvas-empty {
flex: 1;
justify-content: center;
}
.wf-editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.wf-editor-palette {
display: flex;
gap: var(--space-xs);
flex-wrap: wrap;
}
.wf-palette-btn,
.wf-editor-delete,
.wf-editor-save {
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;
transition: background var(--transition-fast);
}
.wf-palette-btn:hover,
.wf-editor-delete:hover {
background: var(--bg-tertiary);
}
.wf-editor-actions {
display: flex;
gap: var(--space-xs);
}
.wf-editor-save {
background: var(--todo);
border-color: var(--todo);
color: var(--bg);
}
.wf-editor-save:disabled {
opacity: 0.6;
cursor: default;
}
.wf-editor-delete {
color: var(--ws-error);
}
.wf-editor-banner {
padding: var(--space-sm) var(--space-md);
background: var(--bg-secondary);
border-bottom: 1px solid var(--ws-warning);
color: var(--ws-warning);
font-size: 0.85rem;
}
.wf-editor-canvas {
flex: 1;
min-height: 0;
position: relative;
}
.wf-editor-inspector {
display: flex;
flex-direction: column;
gap: var(--space-sm);
width: 280px;
padding: var(--space-md);
border-left: 1px solid var(--border);
overflow-y: auto;
}
.wf-editor-inspector h3 {
margin: 0;
font-size: 0.9rem;
color: var(--text);
}
.wf-field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-size: 0.8rem;
color: var(--text-muted);
}
.wf-field input,
.wf-field textarea,
.wf-field select {
padding: var(--space-xs) var(--space-sm);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font: inherit;
}
.wf-field input:focus,
.wf-field textarea:focus,
.wf-field select:focus {
outline: none;
box-shadow: var(--focus-ring);
}
.wf-inspector-note {
margin: 0;
font-size: 0.78rem;
color: var(--text-tertiary);
}
/* Canvas nodes */
.wf-node {
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);
font-size: 0.8rem;
}
.wf-node-start {
border-color: var(--ws-success);
}
.wf-node-end {
border-color: var(--text-muted);
}
.wf-node-gate {
border-color: var(--ws-warning);
}
.wf-node-merge {
border-color: var(--ws-info);
border-style: dashed;
}
.wf-node-icon {
display: inline-flex;
color: var(--text-muted);
}
.wf-node-badge {
font-size: 0.65rem;
text-transform: uppercase;
padding: 1px var(--space-xs);
border-radius: var(--radius-sm);
background: var(--ws-warning);
color: var(--bg);
}
.wf-spin {
animation: wf-spin 1s linear infinite;
}
@keyframes wf-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,359 @@
import "@xyflow/react/dist/style.css";
import "./WorkflowNodeEditor.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ReactFlow,
ReactFlowProvider,
Background,
Controls,
MiniMap,
addEdge,
useNodesState,
useEdgesState,
type Connection,
type Node as FlowNode,
type Edge as FlowEdge,
} from "@xyflow/react";
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2 } from "lucide-react";
import type { WorkflowDefinition } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import {
fetchWorkflows,
createWorkflow,
updateWorkflow,
deleteWorkflow,
compileWorkflow,
} from "../api";
import type { ToastType } from "../hooks/useToast";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout } from "./workflow-flow-mapping";
interface WorkflowNodeEditorProps {
isOpen: boolean;
onClose: () => void;
addToast: (message: string, type?: ToastType) => void;
projectId?: string;
}
let nodeSeq = 0;
function newNodeId(): string {
nodeSeq += 1;
return `n-${Date.now().toString(36)}-${nodeSeq}`;
}
const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof MessageSquare }> = [
{ kind: "prompt", label: "Prompt", icon: MessageSquare },
{ kind: "script", label: "Script", icon: Terminal },
{ kind: "gate", label: "Gate", icon: Shield },
{ kind: "merge", label: "Merge boundary", icon: GitMerge },
];
function InnerEditor({
onClose,
addToast,
projectId,
modalRef,
}: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [nodes, setNodes, onNodesChange] = useNodesState<FlowNode<WorkflowFlowNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<FlowEdge>([]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]);
const loadWorkflows = useCallback(async () => {
setLoading(true);
try {
const data = await fetchWorkflows(projectId);
setWorkflows(data);
setActiveId((prev) => prev ?? data[0]?.id ?? null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to load workflows", "error");
} finally {
setLoading(false);
}
}, [projectId, addToast]);
useEffect(() => {
void loadWorkflows();
}, [loadWorkflows]);
// Load the active workflow graph into the canvas.
useEffect(() => {
if (!activeWorkflow) {
setNodes([]);
setEdges([]);
return;
}
const flow = irToFlow(activeWorkflow);
setNodes(flow.nodes);
setEdges(flow.edges);
setSelectedNodeId(null);
setValidationError(null);
}, [activeWorkflow, setNodes, setEdges]);
const onConnect = useCallback(
(connection: Connection) => {
setEdges((eds) =>
addEdge({ ...connection, label: "success", data: { condition: "success" } }, eds),
);
},
[setEdges],
);
const addNode = useCallback(
(kind: WorkflowEditorNodeKind) => {
const id = newNodeId();
const label = kind === "merge" ? "Merge boundary" : kind.charAt(0).toUpperCase() + kind.slice(1);
setNodes((ns) => [
...ns,
{
id,
type: kind,
position: { x: 200 + ns.length * 40, y: 240 + (ns.length % 3) * 70 },
data: { kind, label, config: kind === "gate" ? { gateMode: "gate" } : {} },
deletable: true,
},
]);
setSelectedNodeId(id);
},
[setNodes],
);
const updateSelectedData = useCallback(
(patch: Partial<WorkflowFlowNodeData> | { config: Record<string, unknown> }) => {
if (!selectedNodeId) return;
setNodes((ns) =>
ns.map((n) =>
n.id === selectedNodeId
? {
...n,
data: {
...n.data,
...("config" in patch ? { config: { ...n.data.config, ...patch.config } } : patch),
},
}
: n,
),
);
},
[selectedNodeId, setNodes],
);
const handleCreateWorkflow = useCallback(async () => {
const name = window.prompt("New workflow name");
if (!name?.trim()) return;
try {
const created = await createWorkflow(
{ name: name.trim(), ir: emptyWorkflowIr(name.trim()), layout: emptyWorkflowLayout() },
projectId,
);
setWorkflows((ws) => [...ws, created]);
setActiveId(created.id);
addToast(`Created workflow "${created.name}"`, "success");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to create workflow", "error");
}
}, [projectId, addToast]);
const handleDeleteWorkflow = useCallback(async () => {
if (!activeWorkflow) return;
if (!window.confirm(`Delete workflow "${activeWorkflow.name}"?`)) return;
try {
await deleteWorkflow(activeWorkflow.id, projectId);
setWorkflows((ws) => ws.filter((w) => w.id !== activeWorkflow.id));
setActiveId(null);
addToast("Workflow deleted", "success");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to delete workflow", "error");
}
}, [activeWorkflow, projectId, addToast]);
const handleSave = useCallback(async () => {
if (!activeWorkflow) return;
setSaving(true);
setValidationError(null);
try {
const { ir, layout } = flowToIr(activeWorkflow.name, nodes, edges);
const updated = await updateWorkflow(activeWorkflow.id, { ir, layout }, projectId);
setWorkflows((ws) => ws.map((w) => (w.id === updated.id ? updated : w)));
// Validate by compiling — surfaces non-linear graphs as a banner.
try {
await compileWorkflow(updated.id, projectId);
addToast("Workflow saved", "success");
} catch (compileErr) {
setValidationError(getErrorMessage(compileErr) || "Workflow saved but cannot be compiled");
}
} catch (err) {
const message = getErrorMessage(err) || "Failed to save workflow";
setValidationError(message);
addToast(message, "error");
} finally {
setSaving(false);
}
}, [activeWorkflow, nodes, edges, projectId, addToast]);
const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null;
const overlayProps = useOverlayDismiss(onClose);
return (
<div className="modal-overlay wf-editor-overlay" {...overlayProps}>
<div className="modal wf-editor-modal" ref={modalRef} onClick={(e) => e.stopPropagation()}>
<header className="wf-editor-header">
<h2>Workflows</h2>
<button className="wf-editor-close" onClick={onClose} aria-label="Close workflow editor">
<X size={18} />
</button>
</header>
<div className="wf-editor-body">
<aside className="wf-editor-sidebar">
<button className="wf-editor-new" onClick={handleCreateWorkflow}>
<Plus size={14} /> New workflow
</button>
{loading ? (
<div className="wf-editor-empty">
<Loader2 size={16} className="wf-spin" /> Loading…
</div>
) : workflows.length === 0 ? (
<div className="wf-editor-empty">No workflows yet.</div>
) : (
<ul className="wf-editor-list">
{workflows.map((w) => (
<li key={w.id}>
<button
className={`wf-editor-list-item${w.id === activeId ? " active" : ""}`}
onClick={() => setActiveId(w.id)}
>
{w.name}
</button>
</li>
))}
</ul>
)}
</aside>
<section className="wf-editor-canvas-wrap">
{activeWorkflow ? (
<>
<div className="wf-editor-toolbar">
<div className="wf-editor-palette">
{PALETTE.map(({ kind, label, icon: Icon }) => (
<button key={kind} className="wf-palette-btn" onClick={() => addNode(kind)}>
<Icon size={13} /> {label}
</button>
))}
</div>
<div className="wf-editor-actions">
<button className="wf-editor-delete" onClick={handleDeleteWorkflow}>
<Trash2 size={13} /> Delete
</button>
<button className="wf-editor-save" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 size={13} className="wf-spin" /> : <Save size={13} />} Save
</button>
</div>
</div>
{validationError && (
<div className="wf-editor-banner" role="alert">
{validationError}
</div>
)}
<div className="wf-editor-canvas">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={workflowNodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
onPaneClick={() => setSelectedNodeId(null)}
fitView
>
<Background />
<Controls />
<MiniMap pannable zoomable />
</ReactFlow>
</div>
</>
) : (
<div className="wf-editor-empty wf-editor-canvas-empty">
Select or create a workflow to start editing.
</div>
)}
</section>
{selectedNode && selectedNode.data.kind !== "start" && selectedNode.data.kind !== "end" && (
<aside className="wf-editor-inspector">
<h3>Node</h3>
<label className="wf-field">
<span>Name</span>
<input
value={selectedNode.data.label}
onChange={(e) => updateSelectedData({ label: e.target.value })}
/>
</label>
{selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate" ? (
<label className="wf-field">
<span>Prompt</span>
<textarea
rows={5}
value={String(selectedNode.data.config?.prompt ?? "")}
onChange={(e) => updateSelectedData({ config: { prompt: e.target.value } })}
/>
</label>
) : null}
{selectedNode.data.kind === "script" ? (
<label className="wf-field">
<span>Script name</span>
<input
value={String(selectedNode.data.config?.scriptName ?? "")}
onChange={(e) => updateSelectedData({ config: { scriptName: e.target.value } })}
/>
</label>
) : null}
{selectedNode.data.kind !== "merge" ? (
<label className="wf-field">
<span>Gate mode</span>
<select
value={String(selectedNode.data.config?.gateMode ?? (selectedNode.data.kind === "gate" ? "gate" : "advisory"))}
onChange={(e) => updateSelectedData({ config: { gateMode: e.target.value } })}
>
<option value="advisory">Advisory</option>
<option value="gate">Gate (blocks)</option>
</select>
</label>
) : (
<p className="wf-inspector-note">
Steps before this marker run pre-merge; steps after run post-merge.
</p>
)}
</aside>
)}
</div>
</div>
</div>
);
}
export function WorkflowNodeEditor({ isOpen, ...rest }: WorkflowNodeEditorProps) {
const modalRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, isOpen, "fusion:workflow-node-editor-size");
if (!isOpen) return null;
return (
<ReactFlowProvider>
<InnerEditor {...rest} modalRef={modalRef} />
</ReactFlowProvider>
);
}

View File

@@ -542,3 +542,25 @@
padding: 12px 14px; padding: 12px 14px;
} }
} }
.wfm-header-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.wfm-graph-editor-link {
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;
}
.wfm-graph-editor-link:hover {
background: var(--bg-tertiary);
}

View File

@@ -46,6 +46,8 @@ interface WorkflowStepManagerProps {
onClose: () => void; onClose: () => void;
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
projectId?: string; projectId?: string;
/** Optional: open the visual graph workflow editor. */
onOpenGraphEditor?: () => void;
} }
interface StepFormData { interface StepFormData {
@@ -127,7 +129,7 @@ function getCategoryClassName(category: string): string {
} }
} }
export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: WorkflowStepManagerProps) { export function WorkflowStepManager({ isOpen, onClose, addToast, projectId, onOpenGraphEditor }: WorkflowStepManagerProps) {
const [steps, setSteps] = useState<WorkflowStep[]>([]); const [steps, setSteps] = useState<WorkflowStep[]>([]);
const [templates, setTemplates] = useState<WorkflowStepTemplate[]>([]); const [templates, setTemplates] = useState<WorkflowStepTemplate[]>([]);
const [pluginTemplateOwners, setPluginTemplateOwners] = useState<Record<string, string>>({}); const [pluginTemplateOwners, setPluginTemplateOwners] = useState<Record<string, string>>({});
@@ -422,10 +424,17 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
{/* Header */} {/* Header */}
<div className="modal-header"> <div className="modal-header">
<h2>Workflow Steps</h2> <h2>Workflow Steps</h2>
<div className="wfm-header-actions">
{onOpenGraphEditor && (
<button className="wfm-graph-editor-link" onClick={onOpenGraphEditor}>
<LayoutGrid size={14} /> Graph editor
</button>
)}
<button className="modal-close" onClick={onClose} aria-label="Close"> <button className="modal-close" onClick={onClose} aria-label="Close">
&times; &times;
</button> </button>
</div> </div>
</div>
<div className="wfm-body"> <div className="wfm-body">
{loading ? ( {loading ? (

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import type { WorkflowDefinition } from "@fusion/core";
import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout } from "../workflow-flow-mapping";
vi.mock("../../api", () => ({
fetchWorkflows: vi.fn(),
createWorkflow: vi.fn(),
updateWorkflow: vi.fn(),
deleteWorkflow: vi.fn(),
compileWorkflow: vi.fn(),
}));
import { fetchWorkflows } from "../../api";
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
function def(): WorkflowDefinition {
return {
id: "WF-001",
name: "QA",
description: "",
ir: {
version: "v1",
name: "QA",
nodes: [
{ id: "start", kind: "start" },
{ id: "lint", kind: "gate", config: { name: "Lint", scriptName: "lint", gateMode: "gate" } },
{ id: "merge", kind: "prompt", config: { seam: "merge", name: "Merge boundary" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "lint", condition: "success" },
{ from: "lint", to: "merge", condition: "success" },
{ from: "merge", to: "end", condition: "success" },
],
},
layout: { start: { x: 0, y: 0 }, lint: { x: 120, y: 0 }, merge: { x: 240, y: 0 }, end: { x: 360, y: 0 } },
createdAt: "2026-06-03T00:00:00.000Z",
updatedAt: "2026-06-03T00:00:00.000Z",
};
}
describe("workflow-flow-mapping", () => {
it("round-trips IR through flow and back, preserving structure and layout", () => {
const original = def();
const flow = irToFlow(original);
expect(flow.nodes).toHaveLength(4);
expect(flow.nodes.find((n) => n.id === "lint")?.type).toBe("gate");
expect(flow.nodes.find((n) => n.id === "merge")?.type).toBe("merge");
expect(flow.nodes.find((n) => n.id === "start")?.position).toEqual({ x: 0, y: 0 });
const { ir, layout } = flowToIr(original.name, flow.nodes, flow.edges);
expect(ir.nodes.map((n) => n.id)).toEqual(["start", "lint", "merge", "end"]);
// merge marker maps back to a prompt node carrying the seam config.
const mergeNode = ir.nodes.find((n) => n.id === "merge");
expect(mergeNode?.kind).toBe("prompt");
expect(mergeNode?.config?.seam).toBe("merge");
expect(ir.edges).toHaveLength(3);
expect(layout.lint).toEqual({ x: 120, y: 0 });
});
it("emptyWorkflowIr seeds a connected start→end graph", () => {
const ir = emptyWorkflowIr("New");
expect(ir.nodes.map((n) => n.kind)).toEqual(["start", "end"]);
expect(ir.edges).toEqual([{ from: "start", to: "end", condition: "success" }]);
expect(emptyWorkflowLayout().start).toBeDefined();
});
});
describe("WorkflowNodeEditor", () => {
beforeEach(() => {
vi.mocked(fetchWorkflows).mockResolvedValue([]);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("renders the empty state when there are no workflows (no canvas)", async () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
expect(await screen.findByText("Workflows")).toBeInTheDocument();
await waitFor(() => expect(screen.getByText(/No workflows yet/i)).toBeInTheDocument());
expect(screen.getByText(/Select or create a workflow/i)).toBeInTheDocument();
});
it("renders nothing when closed", () => {
const { container } = render(<WorkflowNodeEditor isOpen={false} onClose={() => {}} addToast={() => {}} />);
expect(container).toBeEmptyDOMElement();
});
});

View File

@@ -0,0 +1,48 @@
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge } from "lucide-react";
/** Node kinds the editor can render. "merge" is the pre/post-merge seam marker. */
export type WorkflowEditorNodeKind = "start" | "end" | "prompt" | "script" | "gate" | "merge";
export interface WorkflowFlowNodeData {
kind: WorkflowEditorNodeKind;
label: string;
/** Mirrors the IR node config (prompt, scriptName, gateMode, model…). */
config?: Record<string, unknown>;
[key: string]: unknown;
}
const KIND_ICON: Record<WorkflowEditorNodeKind, typeof Play> = {
start: Play,
end: Flag,
prompt: MessageSquare,
script: Terminal,
gate: Shield,
merge: GitMerge,
};
function NodeShell({ data, kind }: { data: WorkflowFlowNodeData; kind: WorkflowEditorNodeKind }) {
const Icon = KIND_ICON[kind];
const showTarget = kind !== "start";
const showSource = kind !== "end";
return (
<div className={`wf-node wf-node-${kind}`} data-testid={`wf-node-${kind}`}>
{showTarget && <Handle type="target" position={Position.Left} />}
<span className="wf-node-icon">
<Icon size={14} aria-hidden />
</span>
<span className="wf-node-label">{data.label || kind}</span>
{kind === "gate" && <span className="wf-node-badge">gate</span>}
{showSource && <Handle type="source" position={Position.Right} />}
</div>
);
}
export const workflowNodeTypes = {
start: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="start" />,
end: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="end" />,
prompt: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="prompt" />,
script: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="script" />,
gate: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="gate" />,
merge: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="merge" />,
};

View File

@@ -0,0 +1,94 @@
import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react";
import type { WorkflowIr, WorkflowDefinition } from "@fusion/core";
import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
/** Resolve the editor node "type" for an IR node (merge seam → "merge"). */
function editorKind(node: WorkflowIr["nodes"][number]): WorkflowEditorNodeKind {
const seam = node.config?.seam;
if (seam === "merge") return "merge";
return node.kind;
}
function nodeLabel(node: WorkflowIr["nodes"][number]): string {
const name = node.config?.name;
if (typeof name === "string" && name.trim()) return name;
if (node.config?.seam === "merge") return "Merge boundary";
return node.id;
}
/** Build React Flow nodes/edges from a stored workflow definition. */
export function irToFlow(def: WorkflowDefinition): {
nodes: FlowNode<WorkflowFlowNodeData>[];
edges: FlowEdge[];
} {
const nodes = def.ir.nodes.map((node, index): FlowNode<WorkflowFlowNodeData> => {
const pos = def.layout?.[node.id];
return {
id: node.id,
type: editorKind(node),
position: pos ?? { x: 80 + index * 180, y: 120 },
data: { kind: editorKind(node), label: nodeLabel(node), config: { ...(node.config ?? {}) } },
deletable: node.kind !== "start" && node.kind !== "end",
};
});
const edges = def.ir.edges.map((edge, index): FlowEdge => {
const condition = edge.condition ?? "success";
return {
id: `e-${edge.from}-${edge.to}-${index}`,
source: edge.from,
target: edge.to,
label: condition,
data: { condition },
};
});
return { nodes, edges };
}
/** Project React Flow nodes/edges back into a WorkflowIr plus a layout map. */
export function flowToIr(
name: string,
nodes: FlowNode<WorkflowFlowNodeData>[],
edges: FlowEdge[],
): { ir: WorkflowIr; layout: Record<string, { x: number; y: number }> } {
const irNodes: WorkflowIr["nodes"] = nodes.map((node) => {
const data = node.data;
const config: Record<string, unknown> = { ...(data.config ?? {}) };
if (data.label) config.name = data.label;
if (data.kind === "merge") {
config.seam = "merge";
return { id: node.id, kind: "prompt", config };
}
return { id: node.id, kind: data.kind, config: Object.keys(config).length ? config : undefined };
});
const irEdges: WorkflowIr["edges"] = edges.map((edge) => {
const condition = (edge.data?.condition as string | undefined) ?? "success";
return { from: edge.source, to: edge.target, condition };
});
const layout = nodes.reduce<Record<string, { x: number; y: number }>>((acc, node) => {
acc[node.id] = { x: Math.round(node.position.x), y: Math.round(node.position.y) };
return acc;
}, {});
return { ir: { version: "v1", name, nodes: irNodes, edges: irEdges }, layout };
}
/** Seed graph for a brand-new workflow: start → end with room to insert steps. */
export function emptyWorkflowIr(name: string): WorkflowIr {
return {
version: "v1",
name,
nodes: [
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "end", condition: "success" }],
};
}
export function emptyWorkflowLayout(): Record<string, { x: number; y: number }> {
return { start: { x: 80, y: 140 }, end: { x: 460, y: 140 } };
}

View File

@@ -53,6 +53,7 @@ export interface ModalManager {
activityLogOpen: boolean; activityLogOpen: boolean;
gitManagerOpen: boolean; gitManagerOpen: boolean;
workflowStepsOpen: boolean; workflowStepsOpen: boolean;
workflowEditorOpen: boolean;
agentsOpen: boolean; agentsOpen: boolean;
scriptsOpen: boolean; scriptsOpen: boolean;
setupWizardOpen: boolean; setupWizardOpen: boolean;
@@ -117,6 +118,8 @@ export interface ModalManager {
openWorkflowSteps: () => void; openWorkflowSteps: () => void;
closeWorkflowSteps: () => void; closeWorkflowSteps: () => void;
openWorkflowEditor: () => void;
closeWorkflowEditor: () => void;
openAgents: () => void; openAgents: () => void;
closeAgents: () => void; closeAgents: () => void;
@@ -173,6 +176,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const [activityLogOpen, setActivityLogOpen] = useState(false); const [activityLogOpen, setActivityLogOpen] = useState(false);
const [gitManagerOpen, setGitManagerOpen] = useState(false); const [gitManagerOpen, setGitManagerOpen] = useState(false);
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false); const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
const [workflowEditorOpen, setWorkflowEditorOpen] = useState(false);
const [agentsOpen, setAgentsOpen] = useState(false); const [agentsOpen, setAgentsOpen] = useState(false);
const [scriptsOpen, setScriptsOpen] = useState(false); const [scriptsOpen, setScriptsOpen] = useState(false);
const [setupWizardOpen, setSetupWizardOpen] = useState(false); const [setupWizardOpen, setSetupWizardOpen] = useState(false);
@@ -191,6 +195,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
activityLogOpen || activityLogOpen ||
gitManagerOpen || gitManagerOpen ||
workflowStepsOpen || workflowStepsOpen ||
workflowEditorOpen ||
scriptsOpen || scriptsOpen ||
agentsOpen || agentsOpen ||
usageOpen || usageOpen ||
@@ -340,6 +345,8 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const openWorkflowSteps = useCallback(() => setWorkflowStepsOpen(true), []); const openWorkflowSteps = useCallback(() => setWorkflowStepsOpen(true), []);
const closeWorkflowSteps = useCallback(() => setWorkflowStepsOpen(false), []); const closeWorkflowSteps = useCallback(() => setWorkflowStepsOpen(false), []);
const openWorkflowEditor = useCallback(() => setWorkflowEditorOpen(true), []);
const closeWorkflowEditor = useCallback(() => setWorkflowEditorOpen(false), []);
const openAgents = useCallback(() => setAgentsOpen(true), []); const openAgents = useCallback(() => setAgentsOpen(true), []);
const closeAgents = useCallback(() => setAgentsOpen(false), []); const closeAgents = useCallback(() => setAgentsOpen(false), []);
@@ -406,6 +413,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
activityLogOpen, activityLogOpen,
gitManagerOpen, gitManagerOpen,
workflowStepsOpen, workflowStepsOpen,
workflowEditorOpen,
agentsOpen, agentsOpen,
scriptsOpen, scriptsOpen,
setupWizardOpen, setupWizardOpen,
@@ -450,6 +458,8 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
closeGitManager, closeGitManager,
openWorkflowSteps, openWorkflowSteps,
closeWorkflowSteps, closeWorkflowSteps,
openWorkflowEditor,
closeWorkflowEditor,
openAgents, openAgents,
closeAgents, closeAgents,
openScripts, openScripts,

View File

@@ -90,23 +90,24 @@
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.36.4", "@codemirror/view": "^6.36.4",
"@earendil-works/pi-coding-agent": "^0.78.0",
"@fusion-plugin-examples/cli-printing-press": "workspace:*",
"@fusion-plugin-examples/cursor-runtime": "workspace:*",
"@fusion-plugin-examples/dependency-graph": "workspace:*", "@fusion-plugin-examples/dependency-graph": "workspace:*",
"@fusion-plugin-examples/roadmap": "workspace:*", "@fusion-plugin-examples/droid-runtime": "workspace:*",
"@fusion-plugin-examples/hermes-runtime": "workspace:*", "@fusion-plugin-examples/hermes-runtime": "workspace:*",
"@fusion-plugin-examples/openclaw-runtime": "workspace:*", "@fusion-plugin-examples/openclaw-runtime": "workspace:*",
"@fusion-plugin-examples/droid-runtime": "workspace:*",
"@fusion-plugin-examples/cursor-runtime": "workspace:*",
"@fusion-plugin-examples/cli-printing-press": "workspace:*",
"@fusion-plugin-examples/paperclip-runtime": "workspace:*", "@fusion-plugin-examples/paperclip-runtime": "workspace:*",
"@fusion-plugin-examples/roadmap": "workspace:*",
"@fusion/core": "workspace:*", "@fusion/core": "workspace:*",
"@fusion/engine": "workspace:*", "@fusion/engine": "workspace:*",
"@earendil-works/pi-coding-agent": "^0.78.0",
"@types/multer": "^2.1.0", "@types/multer": "^2.1.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0", "@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.11.0",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"express": "^5.1.0", "express": "^5.1.0",
"ioredis": "^5.6.0", "ioredis": "^5.6.0",

View File

@@ -172,6 +172,10 @@ export default defineConfig({
return "vendor-codemirror"; return "vendor-codemirror";
} }
if (id.includes("/node_modules/@xyflow/")) {
return "vendor-reactflow";
}
return undefined; return undefined;
}, },
}, },

View File

@@ -171,6 +171,7 @@ const qualityAppComponentTests = [
"TaskForm", "TaskForm",
"TaskIdIntegrityBanner", "TaskIdIntegrityBanner",
"TrackingRepoSelect", "TrackingRepoSelect",
"WorkflowNodeEditor",
"WorkflowResultsTab", "WorkflowResultsTab",
"WorktrunkInstallApprovalDetails", "WorktrunkInstallApprovalDetails",
] as const; ] as const;

239
pnpm-lock.yaml generated
View File

@@ -43,10 +43,10 @@ importers:
dependencies: dependencies:
'@earendil-works/pi-ai': '@earendil-works/pi-ai':
specifier: ^0.78.0 specifier: ^0.78.0
version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-coding-agent': '@earendil-works/pi-coding-agent':
specifier: ^0.78.0 specifier: ^0.78.0
version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
dockerode: dockerode:
specifier: ^4.0.12 specifier: ^4.0.12
version: 4.0.12 version: 4.0.12
@@ -254,6 +254,9 @@ importers:
'@xterm/xterm': '@xterm/xterm':
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.5.0 version: 5.5.0
'@xyflow/react':
specifier: ^12.11.0
version: 12.11.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
archiver: archiver:
specifier: ^7.0.1 specifier: ^7.0.1
version: 7.0.1 version: 7.0.1
@@ -516,10 +519,10 @@ importers:
dependencies: dependencies:
'@earendil-works/pi-ai': '@earendil-works/pi-ai':
specifier: '*' specifier: '*'
version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
'@earendil-works/pi-coding-agent': '@earendil-works/pi-coding-agent':
specifier: '*' specifier: '*'
version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^25.5.2 specifier: ^25.5.2
@@ -2518,6 +2521,24 @@ packages:
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-drag@3.0.7':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-selection@3.0.11':
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
'@types/d3-transition@3.0.9':
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
'@types/d3-zoom@3.0.8':
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
'@types/debug@4.1.13': '@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
@@ -2804,6 +2825,22 @@ packages:
'@xterm/xterm@5.5.0': '@xterm/xterm@5.5.0':
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
'@xyflow/react@12.11.0':
resolution: {integrity: sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==}
peerDependencies:
'@types/react': '>=17'
'@types/react-dom': '>=17'
react: '>=17'
react-dom: '>=17'
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@xyflow/system@0.0.77':
resolution: {integrity: sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==}
abbrev@3.0.1: abbrev@3.0.1:
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
engines: {node: ^18.17.0 || >=20.5.0} engines: {node: ^18.17.0 || >=20.5.0}
@@ -3251,6 +3288,9 @@ packages:
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
engines: {node: '>=8'} engines: {node: '>=8'}
classcat@5.0.5:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
cli-boxes@3.0.0: cli-boxes@3.0.0:
resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3440,6 +3480,44 @@ packages:
curve25519-js@0.0.4: curve25519-js@0.0.4:
resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==} resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
data-uri-to-buffer@4.0.1: data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
@@ -6135,6 +6213,11 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
utf8-byte-length@1.0.5: utf8-byte-length@1.0.5:
resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
@@ -6419,6 +6502,21 @@ packages:
zod@4.3.6: zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
zwitch@2.0.4: zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -7158,9 +7256,9 @@ snapshots:
- ws - ws
- zod - zod
'@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
dependencies: dependencies:
'@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
ignore: 7.0.5 ignore: 7.0.5
typebox: 1.1.38 typebox: 1.1.38
yaml: 2.9.0 yaml: 2.9.0
@@ -7172,9 +7270,9 @@ snapshots:
- ws - ws
- zod - zod
'@earendil-works/pi-agent-core@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies: dependencies:
'@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
ignore: 7.0.5 ignore: 7.0.5
typebox: 1.1.38 typebox: 1.1.38
yaml: 2.9.0 yaml: 2.9.0
@@ -7234,16 +7332,16 @@ snapshots:
- ws - ws
- zod - zod
'@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': '@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
dependencies: dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@4.3.6) '@anthropic-ai/sdk': 0.91.1(zod@3.25.76)
'@aws-sdk/client-bedrock-runtime': 3.1048.0 '@aws-sdk/client-bedrock-runtime': 3.1048.0
'@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))
'@mistralai/mistralai': 2.2.1 '@mistralai/mistralai': 2.2.1
'@smithy/node-http-handler': 4.7.3 '@smithy/node-http-handler': 4.7.3
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6
openai: 6.26.0(ws@8.20.0)(zod@4.3.6) openai: 6.26.0(ws@8.20.0)(zod@3.25.76)
partial-json: 0.1.7 partial-json: 0.1.7
typebox: 1.1.38 typebox: 1.1.38
transitivePeerDependencies: transitivePeerDependencies:
@@ -7254,16 +7352,16 @@ snapshots:
- ws - ws
- zod - zod
'@earendil-works/pi-ai@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': '@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies: dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@anthropic-ai/sdk': 0.91.1(zod@4.3.6)
'@aws-sdk/client-bedrock-runtime': 3.1048.0 '@aws-sdk/client-bedrock-runtime': 3.1048.0
'@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))
'@mistralai/mistralai': 2.2.1 '@mistralai/mistralai': 2.2.1
'@smithy/node-http-handler': 4.7.3 '@smithy/node-http-handler': 4.7.3
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6
openai: 6.26.0(ws@8.20.0)(zod@3.25.76) openai: 6.26.0(ws@8.20.0)(zod@4.3.6)
partial-json: 0.1.7 partial-json: 0.1.7
typebox: 1.1.38 typebox: 1.1.38
transitivePeerDependencies: transitivePeerDependencies:
@@ -7343,10 +7441,10 @@ snapshots:
- ws - ws
- zod - zod
'@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
dependencies: dependencies:
'@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
'@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
'@earendil-works/pi-tui': 0.77.0 '@earendil-works/pi-tui': 0.77.0
'@silvia-odwyer/photon-node': 0.3.4 '@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2 chalk: 5.6.2
@@ -7372,11 +7470,11 @@ snapshots:
- ws - ws
- zod - zod
'@earendil-works/pi-coding-agent@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies: dependencies:
'@earendil-works/pi-agent-core': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-tui': 0.78.0 '@earendil-works/pi-tui': 0.77.0
'@silvia-odwyer/photon-node': 0.3.4 '@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2 chalk: 5.6.2
cross-spawn: 7.0.6 cross-spawn: 7.0.6
@@ -8527,6 +8625,27 @@ snapshots:
dependencies: dependencies:
'@types/node': 25.5.2 '@types/node': 25.5.2
'@types/d3-color@3.1.3': {}
'@types/d3-drag@3.0.7':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-selection@3.0.11': {}
'@types/d3-transition@3.0.9':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-zoom@3.0.8':
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/debug@4.1.13': '@types/debug@4.1.13':
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
@@ -8934,6 +9053,31 @@ snapshots:
'@xterm/xterm@5.5.0': {} '@xterm/xterm@5.5.0': {}
'@xyflow/react@12.11.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@xyflow/system': 0.0.77
classcat: 5.0.5
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
zustand: 4.5.7(@types/react@19.2.14)(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
transitivePeerDependencies:
- immer
'@xyflow/system@0.0.77':
dependencies:
'@types/d3-drag': 3.0.7
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-zoom: 3.0.0
abbrev@3.0.1: {} abbrev@3.0.1: {}
abort-controller@3.0.0: abort-controller@3.0.0:
@@ -9427,6 +9571,8 @@ snapshots:
ci-info@4.4.0: {} ci-info@4.4.0: {}
classcat@5.0.5: {}
cli-boxes@3.0.0: {} cli-boxes@3.0.0: {}
cli-cursor@3.1.0: cli-cursor@3.1.0:
@@ -9600,6 +9746,42 @@ snapshots:
curve25519-js@0.0.4: {} curve25519-js@0.0.4: {}
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-ease@3.0.1: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-selection@3.0.0: {}
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
d3-zoom@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@4.0.1: {}
data-urls@7.0.0: data-urls@7.0.0:
@@ -12831,6 +13013,10 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
use-sync-external-store@1.6.0(react@19.2.4):
dependencies:
react: 19.2.4
utf8-byte-length@1.0.5: {} utf8-byte-length@1.0.5: {}
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
@@ -13176,4 +13362,11 @@ snapshots:
zod@4.3.6: {} zod@4.3.6: {}
zustand@4.5.7(@types/react@19.2.14)(react@19.2.4):
dependencies:
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
react: 19.2.4
zwitch@2.0.4: {} zwitch@2.0.4: {}