From b58fe1184c4d0aadb92883773f8fd876d6b7642d Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Wed, 17 Sep 2025 20:33:20 -0400 Subject: [PATCH] graph mutation service implementation --- .../GRAPH_MUTATION_SERVICE_DESIGN.md | 532 ++++++ .../graph/operations/GraphMutationError.ts | 14 + .../graph/operations/IGraphMutationService.ts | 148 ++ .../graph/operations/graphMutationService.ts | 1323 +++++++++++++ src/core/graph/operations/types.ts | 335 ++++ src/lib/litegraph/src/LGraphGroup.ts | 2 + .../litegraph/src/subgraph/SubgraphNode.ts | 2 + .../services/graphMutationService.test.ts | 1644 +++++++++++++++++ 8 files changed, 4000 insertions(+) create mode 100644 src/core/graph/operations/GRAPH_MUTATION_SERVICE_DESIGN.md create mode 100644 src/core/graph/operations/GraphMutationError.ts create mode 100644 src/core/graph/operations/IGraphMutationService.ts create mode 100644 src/core/graph/operations/graphMutationService.ts create mode 100644 src/core/graph/operations/types.ts create mode 100644 tests-ui/tests/services/graphMutationService.test.ts diff --git a/src/core/graph/operations/GRAPH_MUTATION_SERVICE_DESIGN.md b/src/core/graph/operations/GRAPH_MUTATION_SERVICE_DESIGN.md new file mode 100644 index 000000000..ea7e702a5 --- /dev/null +++ b/src/core/graph/operations/GRAPH_MUTATION_SERVICE_DESIGN.md @@ -0,0 +1,532 @@ +# GraphMutationService Design and Implementation + +## Overview + +GraphMutationService is the centralized service layer for all graph modification operations in ComfyUI Frontend. It provides a unified, command-based API for graph mutations with built-in error handling through the Result pattern, serving as the single entry point for all graph modification operations. + +## Project Background + +### Current System Analysis + +ComfyUI Frontend uses the LiteGraph library for graph operations, with main components including: + +1. **LGraph** (`src/lib/litegraph/src/LGraph.ts`) + - Core graph management class + - Provides basic operations like `add()`, `remove()` + - Supports `beforeChange()`/`afterChange()` transaction mechanism + +2. **LGraphNode** (`src/lib/litegraph/src/LGraphNode.ts`) + - Node class containing position, connections, and other properties + - Provides methods like `connect()`, `disconnectInput()`, `disconnectOutput()` + +3. **ChangeTracker** (`src/scripts/changeTracker.ts`) + - Existing undo/redo system + - Snapshot-based history tracking + - Supports up to 50 history states + +**Primary Goals:** +- Single entry point for all graph modifications via command pattern +- Built-in validation and error handling through Result pattern +- Transaction support for atomic operations +- Natural undo/redo through existing ChangeTracker +- Clean architecture for future extensibility (CRDT support ready) +- Comprehensive error context preservation + +## Architecture Patterns + +### Command Pattern +All operations are executed through a unified command interface: + +```typescript +interface GraphMutationOperation { + type: string // Operation type identifier + timestamp: number // For ordering and CRDT support + origin: CommandOrigin // Source of the command + params?: any // Operation-specific parameters +} +``` + +### Result Pattern +All operations return a discriminated union Result type instead of throwing exceptions: + +```typescript +type Result = + | { success: true; data: T } + | { success: false; error: E } +``` + +### Error Handling +Custom error class with rich context: + +```typescript +class GraphMutationError extends Error { + code: string + context: Record // Contains operation details and original error +} +``` + +### Interface-Based Architecture + +The GraphMutationService follows an **interface-based design pattern** with singleton state management: + +- **IGraphMutationService Interface**: Defines the complete contract for all graph operations +- **GraphMutationService Class**: Implements the interface with LiteGraph integration +- **Singleton State**: Shared clipboard and transaction state across components + +```typescript +interface IGraphMutationService { + // Central command dispatcher + applyOperation( + operation: GraphMutationOperation + ): Promise> + + // Direct operation methods (all return Result types) + createNode(params: createNodeParams): Promise> + removeNode(nodeId: NodeId): Promise> + // ... 40+ total operations + + // Undo/Redo + undo(): Promise> + redo(): Promise> +} +``` + +### Core Components + +```typescript +// Implementation Class +class GraphMutationService implements IGraphMutationService { + private workflowStore = useWorkflowStore() + private static readonly CLIPBOARD_KEY = 'litegrapheditor_clipboard' + + // Command dispatcher + async applyOperation( + operation: GraphMutationOperation + ): Promise> { + switch (operation.type) { + case 'createNode': + return await this.createNode(operation.params) + case 'removeNode': + return await this.removeNode(operation.params) + // ... handle all operation types + default: + return { + success: false, + error: new GraphMutationError('Unknown operation type', { + operation: operation.type + }) + } + } + } + + // All operations wrapped with error handling + async createNode(params: createNodeParams): Promise> { + try { + const graph = this.getGraph() + graph.beforeChange() + // ... perform operation + graph.afterChange() + return { success: true, data: nodeId } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to create node', { + operation: 'createNode', + params, + cause: error + }) + } + } + } +} + +// Singleton Hook +export const useGraphMutationService = (): IGraphMutationService => { + if (!graphMutationServiceInstance) { + graphMutationServiceInstance = new GraphMutationService() + } + return graphMutationServiceInstance +} +``` + +## Implemented Operations + +### Node Operations (8 operations) + +| Operation | Description | Result Type | +|-----------|-------------|-------------| +| `createNode` | Create a new node in the graph | `Result` | +| `removeNode` | Remove a node from the graph | `Result` | +| `updateNodeProperty` | Update a custom node property | `Result` | +| `updateNodeTitle` | Change the node's title | `Result` | +| `changeNodeMode` | Change execution mode (ALWAYS/BYPASS/etc) | `Result` | +| `cloneNode` | Create a copy of a node | `Result` | +| `bypassNode` | Set node to bypass mode | `Result` | +| `unbypassNode` | Remove bypass mode from node | `Result` | + +### Connection Operations (3 operations) + +| Operation | Description | Result Type | +|-----------|-------------|-------------| +| `connect` | Create a connection between nodes | `Result` | +| `disconnect` | Disconnect a node input/output slot | `Result` | +| `disconnectLink` | Disconnect by link ID | `Result` | + +### Group Operations (5 operations) + +| Operation | Description | Result Type | +|-----------|-------------|-------------| +| `createGroup` | Create a new node group | `Result` | +| `removeGroup` | Delete a group (nodes remain) | `Result` | +| `updateGroupTitle` | Change group title | `Result` | +| `addNodesToGroup` | Add nodes to group and auto-resize | `Result` | +| `recomputeGroupNodes` | Recalculate which nodes are in group | `Result` | + +### Clipboard Operations (3 operations) + +| Operation | Description | Result Type | +|-----------|-------------|-------------| +| `copyNodes` | Copy nodes to clipboard | `Result` | +| `cutNodes` | Cut nodes to clipboard | `Result` | +| `pasteNodes` | Paste nodes from clipboard | `Result` | + +### Reroute Operations (2 operations) + +| Operation | Description | Result Type | +|-----------|-------------|-------------| +| `addReroute` | Add a reroute point on a connection | `Result` | +| `removeReroute` | Remove a reroute point | `Result` | + +### Subgraph Operations (10 operations) + +| Operation | Description | Result Type | +|-----------|-------------|-------------| +| `createSubgraph` | Create a subgraph from selected items | `Result<{subgraph, node}, GraphMutationError>` | +| `unpackSubgraph` | Unpack a subgraph node back into regular nodes | `Result` | +| `addSubgraphNodeInput` | Add input slot to subgraph node | `Result` | +| `addSubgraphNodeOutput` | Add output slot to subgraph node | `Result` | +| `removeSubgraphNodeInput` | Remove input slot from subgraph node | `Result` | +| `removeSubgraphNodeOutput` | Remove output slot from subgraph node | `Result` | +| `addSubgraphInput` | Add an input to a subgraph | `Result` | +| `addSubgraphOutput` | Add an output to a subgraph | `Result` | +| `removeSubgraphInput` | Remove a subgraph input | `Result` | +| `removeSubgraphOutput` | Remove a subgraph output | `Result` | + +### Graph-level Operations (1 operation) + +| Operation | Description | Result Type | +|-----------|-------------|-------------| +| `clearGraph` | Clear all nodes and connections | `Result` | + +### History Operations (2 operations) + +| Operation | Description | Result Type | +|-----------|-------------|-------------| +| `undo` | Undo the last operation | `Result` | +| `redo` | Redo the previously undone operation | `Result` | + +## Usage Examples + +### Command Pattern Usage + +```typescript +import { useGraphMutationService, CommandOrigin } from '@/core/graph/operations' +import type { GraphMutationOperation } from '@/core/graph/operations/types' + +const service = useGraphMutationService() + +// Execute operations via command pattern +const operation: GraphMutationOperation = { + type: 'createNode', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + type: 'LoadImage', + title: 'My Image Loader', + properties: { seed: 12345 } + } +} + +const result = await service.applyOperation(operation) + +if (result.success) { + console.log('Node created with ID:', result.data) +} else { + console.error('Failed:', result.error.message) + console.error('Context:', result.error.context) +} +``` + +### Direct Method Usage with Result Pattern + +```typescript +// All methods return Result +const result = await service.createNode({ + type: 'LoadImage', + title: 'Image Loader' +}) + +if (result.success) { + const nodeId = result.data + + // Update node properties + const updateResult = await service.updateNodeProperty({ + nodeId, + property: 'seed', + value: 12345 + }) + + if (!updateResult.success) { + console.error('Update failed:', updateResult.error) + } +} else { + // Access detailed error context + const { operation, params, cause } = result.error.context + console.error(`Operation ${operation} failed:`, cause) +} +``` + +### Connection Management + +```typescript +// Create a connection +const connectOp: GraphMutationOperation = { + type: 'connect', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + sourceNodeId: node1Id, + sourceSlot: 0, + targetNodeId: node2Id, + targetSlot: 0 + } +} + +const result = await service.applyOperation(connectOp) + +if (result.success) { + const linkId = result.data + + // Later disconnect by link ID + const disconnectResult = await service.disconnectLink(linkId) + + if (!disconnectResult.success) { + console.error('Disconnect failed:', disconnectResult.error) + } +} +``` + +### Group Management + +```typescript +// Create a group via command +const createGroupOp: GraphMutationOperation = { + type: 'createGroup', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + title: 'Image Processing', + size: [400, 300], + color: '#335577' + } +} + +const groupResult = await service.applyOperation(createGroupOp) + +if (groupResult.success) { + const groupId = groupResult.data + + // Add nodes to group + const addNodesResult = await service.addNodesToGroup({ + groupId, + nodeIds: [node1Id, node2Id] + }) + + if (!addNodesResult.success) { + console.error('Failed to add nodes:', addNodesResult.error) + } +} +``` + +### Clipboard Operations + +```typescript +// Copy nodes +const copyResult = await service.copyNodes([node1Id, node2Id]) + +if (copyResult.success) { + // Paste at a different location + const pasteResult = await service.pasteNodes() + + if (pasteResult.success) { + console.log('Pasted nodes:', pasteResult.data) + } else { + console.error('Paste failed:', pasteResult.error) + } +} + +// Cut operation +const cutResult = await service.cutNodes([node3Id]) +// Original nodes marked for deletion after paste +``` + +### Error Context Preservation + +```typescript +const result = await service.updateNodeProperty({ + nodeId: 'invalid-node', + property: 'seed', + value: 12345 +}) + +if (!result.success) { + // Rich error context available + console.error('Error:', result.error.message) + console.error('Code:', result.error.code) + console.error('Operation:', result.error.context.operation) + console.error('Parameters:', result.error.context.params) + console.error('Original error:', result.error.context.cause) +} +``` + +### Subgraph Operations + +```typescript +// Create subgraph from selected items +const subgraphOp: GraphMutationOperation = { + type: 'createSubgraph', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + selectedItems: new Set([node1, node2, node3]) + } +} + +const result = await service.applyOperation(subgraphOp) + +if (result.success) { + const { subgraph, node } = result.data + + // Add I/O to subgraph + await service.addSubgraphInput({ + subgraphId: subgraph.id, + name: 'image', + type: 'IMAGE' + }) +} +``` + +### History Operations + +```typescript +// All operations are undoable +const result = await service.undo() + +if (result.success) { + console.log('Undo successful') +} else { + console.error('Undo failed:', result.error.message) + // Might fail if no history or change tracker unavailable +} + +// Redo +const redoResult = await service.redo() +``` + +## Implementation Details + +### Integration Points + +1. **LiteGraph Integration** + - Uses `app.graph` for graph access + - Calls `beforeChange()`/`afterChange()` for all mutations + - Integrates with existing LiteGraph node/connection APIs + +2. **ChangeTracker Integration** + - Maintains compatibility with existing undo/redo system + - Transactions wrapped with `beforeChange()`/`afterChange()` + - No longer calls `checkState()` directly (removed from new implementation) + +3. **Error Handling** + - All operations wrapped in try-catch blocks + - Errors converted to GraphMutationError with context + - Original errors preserved in context.cause + +## Technical Decisions + +### Why Command Pattern? +- **Uniformity**: Single entry point for all operations +- **Extensibility**: Easy to add new operations +- **CRDT Ready**: Commands include timestamp and origin for future sync +- **Testing**: Easy to test command dispatch and execution + +### Why Result Pattern? +- **Explicit Error Handling**: Forces consumers to handle errors +- **No Exceptions**: Predictable control flow +- **Rich Context**: Errors carry operation context +- **Type Safety**: TypeScript discriminated unions + +### Why GraphMutationError? +- **Context Preservation**: Maintains full operation context +- **Debugging**: Detailed information for troubleshooting +- **Standardization**: Consistent error structure +- **Traceability**: Links errors to specific operations + +## Related Files + +- **Interface Definition**: `src/core/graph/operations/IGraphMutationService.ts` +- **Implementation**: `src/core/graph/operations/graphMutationService.ts` +- **Types**: `src/core/graph/operations/types.ts` +- **Error Class**: `src/core/graph/operations/GraphMutationError.ts` +- **Tests**: `tests-ui/tests/services/graphMutationService.test.ts` +- **LiteGraph Core**: `src/lib/litegraph/src/LGraph.ts` +- **Node Implementation**: `src/lib/litegraph/src/LGraphNode.ts` +- **Change Tracking**: `src/scripts/changeTracker.ts` + +## Implementation Compatibility Notes + +### Critical Implementation Details to Maintain: + +1. **beforeChange/afterChange Pattern** + - All mutations MUST be wrapped with `graph.beforeChange()` and `graph.afterChange()` + - This enables undo/redo functionality through ChangeTracker + - Reference: Pattern used consistently throughout service + +2. **Node ID Management** + - Node IDs use NodeId type from schemas + - Custom IDs can be provided during creation (for workflow loading) + +3. **Clipboard Implementation** + - Uses localStorage with key 'litegrapheditor_clipboard' + - Maintains node connections during copy/paste + - Cut operation marks nodes for deletion after paste + +4. **Group Management** + - Groups auto-resize when adding nodes using `recomputeInsideNodes()` + - Visual operations call `graph.setDirtyCanvas(true, false)` + +5. **Error Handling** + - All operations return Result + - Never throw exceptions from public methods + - Preserve original error in context.cause + +6. **Subgraph Support** + - Uses instanceof checks for SubgraphNode detection + - Iterates through graph._nodes to find subgraphs + +## Migration Strategy + +1. Replace direct graph method calls with service operations +2. Update error handling from try-catch to Result pattern checking +3. Convert operation calls to use command pattern where beneficial +4. Leverage error context for better debugging +5. Ensure all operations maintain existing beforeChange/afterChange patterns + +## Important Notes + +1. **Always use GraphMutationService** - Never call graph methods directly +2. **Handle Result types** - Check success before using data +3. **Preserve error context** - Log full error context for debugging +4. **Command pattern ready** - Can easily add CRDT sync in future +5. **Performance** - Result pattern and command recording have minimal overhead +6. **Type safety** - Use TypeScript types for all operations \ No newline at end of file diff --git a/src/core/graph/operations/GraphMutationError.ts b/src/core/graph/operations/GraphMutationError.ts new file mode 100644 index 000000000..aea2fb3bd --- /dev/null +++ b/src/core/graph/operations/GraphMutationError.ts @@ -0,0 +1,14 @@ +export class GraphMutationError extends Error { + public readonly code: string + public readonly context: Record + + constructor( + message: string, + context: Record, + code = 'GRAPH_MUTATION_ERROR' + ) { + super(message) + this.code = code + this.context = context + } +} diff --git a/src/core/graph/operations/IGraphMutationService.ts b/src/core/graph/operations/IGraphMutationService.ts new file mode 100644 index 000000000..19979e786 --- /dev/null +++ b/src/core/graph/operations/IGraphMutationService.ts @@ -0,0 +1,148 @@ +import type { GraphMutationError } from '@/core/graph/operations/GraphMutationError' +import type { + AddNodeInputParams, + AddNodeOutputParams, + AddNodesToGroupParams, + AddRerouteParams, + ChangeNodeModeParams, + ConnectParams, + CreateGroupParams, + CreateNodeParams, + CreateSubgraphParams, + DisconnectParams, + GraphMutationOperation, + NodeInputSlotParams, + Result, + SubgraphIndexParams, + SubgraphNameTypeParams, + UpdateGroupTitleParams, + UpdateNodePropertyParams, + UpdateNodeTitleParams +} from '@/core/graph/operations/types' +import type { GroupId } from '@/lib/litegraph/src/LGraphGroup' +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import type { LinkId } from '@/lib/litegraph/src/LLink' +import type { RerouteId } from '@/lib/litegraph/src/Reroute' +import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' + +export interface IGraphMutationService { + applyOperation( + operation: GraphMutationOperation + ): Promise> + + createNode( + params: CreateNodeParams + ): Promise> + + getNodeById(nodeId: NodeId): LGraphNode + + removeNode(nodeId: NodeId): Promise> + + updateNodeProperty( + params: UpdateNodePropertyParams + ): Promise> + + updateNodeTitle( + params: UpdateNodeTitleParams + ): Promise> + + changeNodeMode( + params: ChangeNodeModeParams + ): Promise> + + cloneNode(nodeId: NodeId): Promise> + + connect(params: ConnectParams): Promise> + + disconnect( + params: DisconnectParams + ): Promise> + + disconnectLink(linkId: LinkId): Promise> + + createGroup( + params: CreateGroupParams + ): Promise> + + removeGroup(groupId: GroupId): Promise> + + updateGroupTitle( + params: UpdateGroupTitleParams + ): Promise> + + addNodesToGroup( + params: AddNodesToGroupParams + ): Promise> + + recomputeGroupNodes( + groupId: GroupId + ): Promise> + + addReroute( + params: AddRerouteParams + ): Promise> + + removeReroute(rerouteId: RerouteId): Promise> + + copyNodes(nodeIds: NodeId[]): Promise> + + cutNodes(nodeIds: NodeId[]): Promise> + + pasteNodes(): Promise> + + addSubgraphNodeInput( + params: AddNodeInputParams + ): Promise> + + addSubgraphNodeOutput( + params: AddNodeOutputParams + ): Promise> + + removeSubgraphNodeInput( + params: NodeInputSlotParams + ): Promise> + + removeSubgraphNodeOutput( + params: NodeInputSlotParams + ): Promise> + + createSubgraph(params: CreateSubgraphParams): Promise< + Result< + { + subgraph: any + node: any + }, + GraphMutationError + > + > + + unpackSubgraph( + subgraphNodeId: NodeId + ): Promise> + + addSubgraphInput( + params: SubgraphNameTypeParams + ): Promise> + + addSubgraphOutput( + params: SubgraphNameTypeParams + ): Promise> + + removeSubgraphInput( + params: SubgraphIndexParams + ): Promise> + + removeSubgraphOutput( + params: SubgraphIndexParams + ): Promise> + + clearGraph(): Promise> + + bypassNode(nodeId: NodeId): Promise> + + unbypassNode(nodeId: NodeId): Promise> + + undo(): Promise> + + redo(): Promise> +} diff --git a/src/core/graph/operations/graphMutationService.ts b/src/core/graph/operations/graphMutationService.ts new file mode 100644 index 000000000..3ba335e60 --- /dev/null +++ b/src/core/graph/operations/graphMutationService.ts @@ -0,0 +1,1323 @@ +import { GraphMutationError } from '@/core/graph/operations/GraphMutationError' +import type { IGraphMutationService } from '@/core/graph/operations/IGraphMutationService' +import type { + AddNodeInputParams, + AddNodeOutputParams, + AddNodesToGroupParams, + AddRerouteParams, + ChangeNodeModeParams, + ConnectParams, + CreateGroupParams, + CreateNodeParams, + CreateSubgraphParams, + DisconnectParams, + GraphMutationOperation, + NodeInputSlotParams, + Result, + SubgraphIndexParams, + SubgraphNameTypeParams, + UpdateGroupTitleParams, + UpdateNodePropertyParams, + UpdateNodeTitleParams +} from '@/core/graph/operations/types' +import type { Subgraph } from '@/lib/litegraph/src/LGraph' +import type { GroupId } from '@/lib/litegraph/src/LGraphGroup' +import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup' +import type { LinkId } from '@/lib/litegraph/src/LLink' +import type { RerouteId } from '@/lib/litegraph/src/Reroute' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { LGraphEventMode, LiteGraph } from '@/lib/litegraph/src/litegraph' +import type { SubgraphId } from '@/lib/litegraph/src/subgraph/SubgraphNode' +import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' +import { app } from '@/scripts/app' + +export class GraphMutationService implements IGraphMutationService { + private workflowStore = useWorkflowStore() + + private static readonly CLIPBOARD_KEY = 'litegrapheditor_clipboard' + + warnDirectAccess(context: string) { + console.warn( + `Direct LiteGraph access detected in ${context}. ` + + `Consider using GraphMutationService for better compatibility. ` + + `Direct access will be deprecated in a future version` + ) + } + + private getGraph() { + return app.graph + } + + private getChangeTracker() { + return this.workflowStore.activeWorkflow?.changeTracker + } + + async applyOperation( + operation: GraphMutationOperation + ): Promise> { + switch (operation.type) { + case 'createNode': + return await this.createNode(operation.params) + case 'removeNode': + return await this.removeNode(operation.params) + case 'updateNodeProperty': + return await this.updateNodeProperty(operation.params) + case 'updateNodeTitle': + return await this.updateNodeTitle(operation.params) + case 'changeNodeMode': + return await this.changeNodeMode(operation.params) + case 'cloneNode': + return await this.cloneNode(operation.params) + case 'bypassNode': + return await this.bypassNode(operation.params) + case 'unbypassNode': + return await this.unbypassNode(operation.params) + case 'connect': + return await this.connect(operation.params) + case 'disconnect': + return await this.disconnect(operation.params) + case 'disconnectLink': + return await this.disconnectLink(operation.params) + case 'createGroup': + return await this.createGroup(operation.params) + case 'removeGroup': + return await this.removeGroup(operation.params) + case 'updateGroupTitle': + return await this.updateGroupTitle(operation.params) + case 'addNodesToGroup': + return await this.addNodesToGroup(operation.params) + case 'recomputeGroupNodes': + return await this.recomputeGroupNodes(operation.params) + case 'addReroute': + return await this.addReroute(operation.params) + case 'removeReroute': + return await this.removeReroute(operation.params) + case 'copyNodes': + return await this.copyNodes(operation.nodeIds) + case 'cutNodes': + return await this.cutNodes(operation.params) + case 'pasteNodes': + return await this.pasteNodes() + case 'createSubgraph': + return await this.createSubgraph(operation.params) + case 'unpackSubgraph': + return await this.unpackSubgraph(operation.params) + case 'addSubgraphNodeInput': + return await this.addSubgraphNodeInput(operation.params) + case 'addSubgraphNodeOutput': + return await this.addSubgraphNodeOutput(operation.params) + case 'removeSubgraphNodeInput': + return await this.removeSubgraphNodeInput(operation.params) + case 'removeSubgraphNodeOutput': + return await this.removeSubgraphNodeOutput(operation.params) + case 'addSubgraphInput': + return await this.addSubgraphInput(operation.params) + case 'addSubgraphOutput': + return await this.addSubgraphOutput(operation.params) + case 'removeSubgraphInput': + return await this.removeSubgraphInput(operation.params) + case 'removeSubgraphOutput': + return await this.removeSubgraphOutput(operation.params) + case 'clearGraph': + return await this.clearGraph() + case 'undo': + return await this.undo() + case 'redo': + return await this.redo() + default: { + const unknownOp = operation as any + console.warn('Unknown operation type:', unknownOp) + return { + success: false, + error: new GraphMutationError('Unknown operation type', { + operation: unknownOp.type, + cause: new Error( + `Operation type '${unknownOp.type}' is not implemented` + ) + }) + } + } + } + } + + async createNode( + params: CreateNodeParams + ): Promise> { + try { + const { type, properties, title, id } = params + const graph = this.getGraph() + + const node = LiteGraph.createNode(type) + + if (!node) { + throw new Error(`Failed to create node of type: ${type}`) + } + + // Set custom ID if provided (for loading workflows) + if (id !== undefined) { + node.id = id + } + + if (title) { + node.title = title + } + + if (properties) { + Object.assign(node.properties || {}, properties) + } + + graph.beforeChange() + + const addedNode = graph.add(node) + if (!addedNode) { + throw new Error('Failed to add node to graph') + } + + graph.afterChange() + + return { success: true, data: addedNode.id as NodeId } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to add node', { + operation: 'addNode', + params: params, + cause: error + }) + } + } + } + + getNodeById(nodeId: NodeId): LGraphNode { + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + return node + } + + async removeNode(nodeId: NodeId): Promise> { + try { + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + // Note: We don't need to call beforeChange/afterChange here because + // graph.remove() already handles these internally (see LGraph.ts:927 and :982). + // The remove method includes proper transaction boundaries and calls + // beforeChange at the start and afterChange at the end of the operation. + graph.remove(node) + + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to remove node', { + operation: 'removeNode', + params: nodeId, + cause: error + }) + } + } + } + + async updateNodeProperty( + params: UpdateNodePropertyParams + ): Promise> { + try { + const graph = this.getGraph() + const node = graph.getNodeById(params.nodeId) + + if (!node) { + throw new Error(`Node with id ${params.nodeId} not found`) + } + + graph.beforeChange() + node.setProperty(params.property, params.value) + graph.afterChange() + + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to update node property', { + operation: 'updateNodeProperty', + params: params, + cause: error + }) + } + } + } + + async updateNodeTitle( + params: UpdateNodeTitleParams + ): Promise> { + try { + const graph = this.getGraph() + const node = graph.getNodeById(params.nodeId) + + if (!node) { + throw new Error(`Node with id ${params.nodeId} not found`) + } + + graph.beforeChange() + node.title = params.title + graph.afterChange() + + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to update node property', { + operation: 'updateNodeTitle', + params: params, + cause: error + }) + } + } + } + + async changeNodeMode( + params: ChangeNodeModeParams + ): Promise> { + try { + const graph = this.getGraph() + const node = graph.getNodeById(params.nodeId) + + if (!node) { + throw new Error(`Node with id ${params.nodeId} not found`) + } + + graph.beforeChange() + const success = node.changeMode(params.mode) + if (!success) { + throw new Error(`Failed to change node mode to ${params.mode}`) + } + graph.afterChange() + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to update node property', { + operation: 'changeNodeMode', + params: params, + cause: error + }) + } + } + } + + async cloneNode(nodeId: NodeId): Promise> { + try { + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + graph.beforeChange() + + const clonedNode = node.clone() + if (!clonedNode) { + throw new Error('Failed to clone node') + } + + const addedNode = graph.add(clonedNode) + if (!addedNode) { + throw new Error('Failed to add cloned node to graph') + } + + graph.afterChange() + + return { success: true, data: addedNode.id as NodeId } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to clone node', { + operation: 'cloneNode', + params: nodeId, + cause: error + }) + } + } + } + + async connect( + params: ConnectParams + ): Promise> { + try { + const { sourceNodeId, sourceSlot, targetNodeId, targetSlot } = params + const graph = this.getGraph() + + const sourceNode = graph.getNodeById(sourceNodeId) + const targetNode = graph.getNodeById(targetNodeId) + + if (!sourceNode) { + throw new Error(`Source node with id ${sourceNodeId} not found`) + } + if (!targetNode) { + throw new Error(`Target node with id ${targetNodeId} not found`) + } + + // Note: We wrap the connect call with beforeChange/afterChange even though + // node.connect() may call beforeChange internally in some cases (e.g., when + // disconnecting EVENT type outputs). This ensures consistent transaction + // boundaries for all connection operations. The nested beforeChange calls + // are handled properly by the graph's transaction system. + graph.beforeChange() + + const link = sourceNode.connect( + sourceSlot, + targetNode as LGraphNode, + targetSlot + ) + + if (!link) { + throw new Error('Failed to create connection') + } + + graph.afterChange() + + return { success: true, data: link.id as LinkId } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to clone node', { + operation: 'connect', + params: params, + cause: error + }) + } + } + } + + async disconnect( + params: DisconnectParams + ): Promise> { + try { + const graph = this.getGraph() + const node = graph.getNodeById(params.nodeId) + + if (!node) { + throw new Error(`Node with id ${params.nodeId} not found`) + } + + graph.beforeChange() + + let result: boolean + if (params.slotType === 'input') { + result = node.disconnectInput(params.slot) + } else { + if (params.targetNodeId) { + const targetNode = graph.getNodeById(params.targetNodeId) + if (!targetNode) { + throw new Error( + `Target node with id ${params.targetNodeId} not found` + ) + } + result = node.disconnectOutput(params.slot, targetNode as LGraphNode) + } else { + result = node.disconnectOutput(params.slot) + } + } + + graph.afterChange() + + return { success: true, data: result } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to clone node', { + operation: 'disconnect', + params: params, + cause: error + }) + } + } + } + + async disconnectLink( + linkId: LinkId + ): Promise> { + try { + const graph = this.getGraph() + + graph.beforeChange() + graph.removeLink(linkId) + graph.afterChange() + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to disconnect link', { + operation: 'disconnectLink', + params: linkId, + cause: error + }) + } + } + } + + async createGroup( + params: CreateGroupParams + ): Promise> { + try { + const graph = this.getGraph() + + const group = new LGraphGroup(params.title || 'Group') + + if (params.size) { + group.size[0] = params.size[0] + group.size[1] = params.size[1] + } + + if (params.color) { + group.color = params.color + } + + if (params.fontSize) { + group.font_size = params.fontSize + } + + graph.beforeChange() + graph.add(group) + graph.afterChange() + + return { success: true, data: group.id as GroupId } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to create group', { + operation: 'createGroup', + params: params, + cause: error + }) + } + } + } + + async removeGroup( + groupId: GroupId + ): Promise> { + try { + const graph = this.getGraph() + const group = graph._groups.find((g) => g.id === groupId) + + if (!group) { + throw new Error(`Group with id ${groupId} not found`) + } + + graph.beforeChange() + graph.remove(group) + graph.afterChange() + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to remove group', { + operation: 'removeGroup', + params: groupId, + cause: error + }) + } + } + } + + async updateGroupTitle( + params: UpdateGroupTitleParams + ): Promise> { + try { + const graph = this.getGraph() + const group = graph._groups.find((g) => g.id === params.groupId) + + if (!group) { + throw new Error(`Group with id ${params.groupId} not found`) + } + + graph.beforeChange() + group.title = params.title + graph.afterChange() + graph.setDirtyCanvas(true, false) + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to update group title', { + operation: 'updateGroupTitle', + params: params, + cause: error + }) + } + } + } + + async addNodesToGroup( + params: AddNodesToGroupParams + ): Promise> { + try { + const graph = this.getGraph() + const group = graph._groups.find((g) => g.id === params.groupId) + + if (!group) { + throw new Error(`Group with id ${params.groupId} not found`) + } + + const nodes: LGraphNode[] = [] + for (const nodeId of params.nodeIds) { + const node = graph.getNodeById(nodeId) + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + nodes.push(node) + } + + graph.beforeChange() + group.addNodes(nodes) + group.recomputeInsideNodes() + graph.afterChange() + graph.setDirtyCanvas(true, false) + + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to add nodes to group', { + operation: 'addNodesToGroup', + params: params, + cause: error + }) + } + } + } + + async recomputeGroupNodes( + groupId: GroupId + ): Promise> { + try { + const graph = this.getGraph() + const group = graph._groups.find((g) => g.id === groupId) + + if (!group) { + throw new Error(`Group with id ${groupId} not found`) + } + + graph.beforeChange() + group.recomputeInsideNodes() + graph.afterChange() + + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to recompute group nodes', { + operation: 'recomputeGroupNodes', + params: groupId, + cause: error + }) + } + } + } + + async addReroute( + params: AddRerouteParams + ): Promise> { + try { + const { pos, linkId, parentRerouteId } = params + const graph = this.getGraph() + + let beforeSegment: any = null + + if (linkId) { + beforeSegment = graph._links.get(linkId) + if (!beforeSegment) { + throw new Error(`Link with id ${linkId} not found`) + } + } else if (parentRerouteId) { + beforeSegment = graph.reroutes.get(parentRerouteId) + if (!beforeSegment) { + throw new Error(`Reroute with id ${parentRerouteId} not found`) + } + } else { + throw new Error('Either linkId or parentRerouteId must be provided') + } + + graph.beforeChange() + const reroute = graph.createReroute(pos, beforeSegment) + graph.afterChange() + graph.setDirtyCanvas(true, false) + + return { success: true, data: reroute.id as RerouteId } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to add reroute', { + operation: 'addReroute', + params: params, + cause: error + }) + } + } + } + + async removeReroute( + rerouteId: RerouteId + ): Promise> { + try { + const graph = this.getGraph() + + if (!graph.reroutes.has(rerouteId)) { + throw new Error(`Reroute with id ${rerouteId} not found`) + } + + graph.beforeChange() + graph.removeReroute(rerouteId) + graph.afterChange() + graph.setDirtyCanvas(true, false) + + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to remove reroute', { + operation: 'removeReroute', + params: rerouteId, + cause: error + }) + } + } + } + + async copyNodes( + nodeIds: NodeId[] + ): Promise> { + try { + if (!nodeIds.length) { + throw new Error('No nodes to copy') + } + + const graph = this.getGraph() + const clipboardData: any = { + nodes: [], + links: [] + } + + for (const nodeId of nodeIds) { + const node = graph.getNodeById(nodeId) + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + if (node.clonable === false) continue + + const cloned = node.clone() + if (!cloned) { + console.warn('Failed to clone node:', node.type) + continue + } + + const serialized = cloned.serialize() + serialized.id = node.id + clipboardData.nodes.push(serialized) + } + + for (const nodeId of nodeIds) { + const node = graph.getNodeById(nodeId) + if (!node || !node.inputs) continue + + for (const input of node.inputs) { + if (input.link == null) continue + + const link = graph._links.get(input.link) + if (!link) continue + + if (nodeIds.includes(link.origin_id as NodeId)) { + clipboardData.links.push({ + id: link.id, + origin_id: link.origin_id, + origin_slot: link.origin_slot, + target_id: link.target_id, + target_slot: link.target_slot, + type: link.type + }) + } + } + } + + localStorage.setItem( + GraphMutationService.CLIPBOARD_KEY, + JSON.stringify(clipboardData) + ) + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to copy nodes', { + operation: 'copyNodes', + params: nodeIds, + cause: error + }) + } + } + } + + async cutNodes(nodeIds: NodeId[]): Promise> { + try { + if (!nodeIds.length) { + throw new Error('No nodes to cut') + } + + await this.copyNodes(nodeIds) + + const data = localStorage.getItem(GraphMutationService.CLIPBOARD_KEY) + if (data) { + const clipboardData = JSON.parse(data) + clipboardData.isCut = true + clipboardData.originalIds = nodeIds + localStorage.setItem( + GraphMutationService.CLIPBOARD_KEY, + JSON.stringify(clipboardData) + ) + } + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to cut nodes', { + operation: 'cutNodes', + params: nodeIds, + cause: error + }) + } + } + } + + async pasteNodes(): Promise> { + try { + // No params to validate for paste operation + const data = localStorage.getItem(GraphMutationService.CLIPBOARD_KEY) + if (!data) { + throw new Error('Clipboard is empty') + } + + const clipboardData = JSON.parse(data) + if (!clipboardData.nodes || clipboardData.nodes.length === 0) { + throw new Error('Clipboard is empty') + } + + const graph = this.getGraph() + const newNodeIds: NodeId[] = [] + const nodeIdMap = new Map() + + graph.beforeChange() + + for (const nodeData of clipboardData.nodes) { + const node = LiteGraph.createNode(nodeData.type) + if (!node) { + console.warn(`Failed to create node of type: ${nodeData.type}`) + continue + } + + const oldId = nodeData.id + node.configure(nodeData) + + const addedNode = graph.add(node) + if (!addedNode) { + console.warn('Failed to add node to graph') + continue + } + + const newNodeId = addedNode.id as NodeId + newNodeIds.push(newNodeId) + nodeIdMap.set(oldId, newNodeId) + } + + if (clipboardData.links) { + for (const linkData of clipboardData.links) { + const sourceNewId = nodeIdMap.get(linkData.origin_id) + const targetNewId = nodeIdMap.get(linkData.target_id) + + if (sourceNewId && targetNewId) { + const sourceNode = graph.getNodeById(sourceNewId) + const targetNode = graph.getNodeById(targetNewId) + + if (sourceNode && targetNode) { + sourceNode.connect( + linkData.origin_slot, + targetNode as LGraphNode, + linkData.target_slot + ) + } + } + } + } + + if (clipboardData.isCut && clipboardData.originalIds) { + for (const nodeId of clipboardData.originalIds) { + const node = graph.getNodeById(nodeId) + if (node) { + graph.remove(node) + } + } + localStorage.removeItem(GraphMutationService.CLIPBOARD_KEY) + } + + graph.afterChange() + + return { success: true, data: newNodeIds } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to paste nodes', { + operation: 'pasteNodes', + cause: error + }) + } + } + } + + async addSubgraphNodeInput( + params: AddNodeInputParams + ): Promise> { + try { + const graph = this.getGraph() + const node = graph.getNodeById(params.nodeId) + + if (!node) { + throw new Error(`Node with id ${params.nodeId} not found`) + } + + graph.beforeChange() + node.addInput(params.name, params.type, params.extra_info) + const slotIndex = node.inputs ? node.inputs.length - 1 : 0 + graph.afterChange() + + return { success: true, data: slotIndex } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to add subgraph node input', { + operation: 'addSubgraphNodeInput', + params: params, + cause: error + }) + } + } + } + + async addSubgraphNodeOutput( + params: AddNodeOutputParams + ): Promise> { + try { + const graph = this.getGraph() + const node = graph.getNodeById(params.nodeId) + + if (!node) { + throw new Error(`Node with id ${params.nodeId} not found`) + } + + graph.beforeChange() + node.addOutput(params.name, params.type, params.extra_info) + const slotIndex = node.outputs ? node.outputs.length - 1 : 0 + graph.afterChange() + + return { success: true, data: slotIndex } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to add subgraph node output', { + operation: 'addSubgraphNodeOutput', + params: params, + cause: error + }) + } + } + } + + async removeSubgraphNodeInput( + params: NodeInputSlotParams + ): Promise> { + try { + const graph = this.getGraph() + const node = graph.getNodeById(params.nodeId) + + if (!node) { + throw new Error(`Node with id ${params.nodeId} not found`) + } + + if (!node.inputs || params.slot >= node.inputs.length) { + throw new Error(`Input slot ${params.slot} not found on node`) + } + + graph.beforeChange() + node.removeInput(params.slot) + graph.afterChange() + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to remove subgraph node input', { + operation: 'removeSubgraphNodeInput', + params: params, + cause: error + }) + } + } + } + + async removeSubgraphNodeOutput( + params: NodeInputSlotParams + ): Promise> { + try { + const graph = this.getGraph() + const node = graph.getNodeById(params.nodeId) + + if (!node) { + throw new Error(`Node with id ${params.nodeId} not found`) + } + + if (!node.outputs || params.slot >= node.outputs.length) { + throw new Error(`Output slot ${params.slot} not found on node`) + } + + graph.beforeChange() + node.removeOutput(params.slot) + graph.afterChange() + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to remove subgraph node output', { + operation: 'removeSubgraphNodeOutput', + params: params, + cause: error + }) + } + } + } + + async createSubgraph(params: CreateSubgraphParams): Promise< + Result< + { + subgraph: any + node: any + }, + GraphMutationError + > + > { + try { + const graph = this.getGraph() + + if (!params.selectedItems || params.selectedItems.size === 0) { + throw new Error('Cannot create subgraph: no items selected') + } + + graph.beforeChange() + const result = graph.convertToSubgraph(params.selectedItems) + if (!result) { + throw new Error('Failed to create subgraph') + } + graph.afterChange() + return { success: true, data: result } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to create subgraph', { + operation: 'createSubgraph', + params: params, + cause: error + }) + } + } + } + + async unpackSubgraph( + subgraphNodeId: NodeId + ): Promise> { + try { + const graph = this.getGraph() + const node = graph.getNodeById(subgraphNodeId) + + if (!node) { + throw new Error(`Node with id ${subgraphNodeId} not found`) + } + + if (!node.isSubgraphNode?.() && !(node as any).subgraph) { + throw new Error('Node is not a subgraph node') + } + + graph.beforeChange() + graph.unpackSubgraph(node as any) + graph.afterChange() + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to unpack subgraph', { + operation: 'unpackSubgraph', + params: subgraphNodeId, + cause: error + }) + } + } + } + + private getSubgraph(subgraphId: SubgraphId): Subgraph | undefined { + const graph = this.getGraph() + + for (const node of graph._nodes) { + if (node instanceof SubgraphNode && node.subgraph.id === subgraphId) { + return node.subgraph + } + } + + return undefined + } + + async addSubgraphInput( + params: SubgraphNameTypeParams + ): Promise> { + try { + const subgraph = this.getSubgraph(params.subgraphId) + if (!subgraph) { + throw new Error(`Subgraph with id ${params.subgraphId} not found`) + } + + const graph = this.getGraph() + graph.beforeChange() + subgraph.addInput(params.name, params.type) + graph.afterChange() + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to add subgraph input', { + operation: 'addSubgraphInput', + params: params, + cause: error + }) + } + } + } + + async addSubgraphOutput( + params: SubgraphNameTypeParams + ): Promise> { + try { + const subgraph = this.getSubgraph(params.subgraphId) + if (!subgraph) { + throw new Error(`Subgraph with id ${params.subgraphId} not found`) + } + + const graph = this.getGraph() + graph.beforeChange() + subgraph.addOutput(params.name, params.type) + graph.afterChange() + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to add subgraph output', { + operation: 'addSubgraphOutput', + params: params, + cause: error + }) + } + } + } + + async removeSubgraphInput( + params: SubgraphIndexParams + ): Promise> { + try { + const subgraph = this.getSubgraph(params.subgraphId) + if (!subgraph) { + throw new Error(`Subgraph with id ${params.subgraphId} not found`) + } + + if (!subgraph.inputs[params.index]) { + throw new Error(`Input at index ${params.index} not found in subgraph`) + } + + const graph = this.getGraph() + graph.beforeChange() + subgraph.removeInput(subgraph.inputs[params.index]) + graph.afterChange() + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to remove subgraph input', { + operation: 'removeSubgraphInput', + params: params, + cause: error + }) + } + } + } + + async removeSubgraphOutput( + params: SubgraphIndexParams + ): Promise> { + try { + const subgraph = this.getSubgraph(params.subgraphId) + if (!subgraph) { + throw new Error(`Subgraph with id ${params.subgraphId} not found`) + } + + if (!subgraph.outputs[params.index]) { + throw new Error(`Output at index ${params.index} not found in subgraph`) + } + + const graph = this.getGraph() + graph.beforeChange() + subgraph.removeOutput(subgraph.outputs[params.index]) + graph.afterChange() + + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to remove subgraph output', { + operation: 'removeSubgraphOutput', + params: params, + cause: error + }) + } + } + } + + async clearGraph(): Promise> { + try { + // No params to validate for clear operation + const graph = this.getGraph() + + graph.beforeChange() + graph.clear() + graph.afterChange() + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to clear graph', { + operation: 'clearGraph', + cause: error + }) + } + } + } + + async bypassNode(nodeId: NodeId): Promise> { + try { + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + graph.beforeChange() + node.mode = LGraphEventMode.BYPASS + graph.afterChange() + graph.setDirtyCanvas(true, false) + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to bypass node', { + operation: 'bypassNode', + params: nodeId, + cause: error + }) + } + } + } + + async unbypassNode( + nodeId: NodeId + ): Promise> { + try { + const graph = this.getGraph() + const node = graph.getNodeById(nodeId) + + if (!node) { + throw new Error(`Node with id ${nodeId} not found`) + } + + graph.beforeChange() + node.mode = LGraphEventMode.ALWAYS + graph.afterChange() + graph.setDirtyCanvas(true, false) + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to unbypass node', { + operation: 'unbypassNode', + params: nodeId, + cause: error + }) + } + } + } + + async undo(): Promise> { + try { + // No params to validate for undo operation + const tracker = this.getChangeTracker() + if (!tracker) { + throw new Error('No active workflow or change tracker') + } + + await tracker.undo() + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to undo', { + operation: 'undo', + cause: error + }) + } + } + } + + async redo(): Promise> { + try { + // No params to validate for redo operation + const tracker = this.getChangeTracker() + if (!tracker) { + throw new Error('No active workflow or change tracker') + } + + await tracker.redo() + return { success: true, data: undefined } + } catch (error) { + return { + success: false, + error: new GraphMutationError('Failed to redo', { + operation: 'redo', + cause: error + }) + } + } + } +} + +let graphMutationServiceInstance: GraphMutationService | null = null + +export const useGraphMutationService = (): IGraphMutationService => { + if (!graphMutationServiceInstance) { + graphMutationServiceInstance = new GraphMutationService() + } + + return graphMutationServiceInstance +} diff --git a/src/core/graph/operations/types.ts b/src/core/graph/operations/types.ts new file mode 100644 index 000000000..828067a27 --- /dev/null +++ b/src/core/graph/operations/types.ts @@ -0,0 +1,335 @@ +/** + * Graph Mutation Command System - Type Definitions + * + * Defines command types for graph mutation operations with CRDT support. + * Each command represents an atomic operation that can be applied, undone, and synchronized. + */ +import type { GroupId } from '@/lib/litegraph/src/LGraphGroup' +import type { LinkId } from '@/lib/litegraph/src/LLink' +import type { RerouteId } from '@/lib/litegraph/src/Reroute' +import type { SubgraphId } from '@/lib/litegraph/src/subgraph/SubgraphNode' +import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' + +export type Result = + | { success: true; data: T } + | { success: false; error: E } + +export interface CreateNodeParams { + type: string + properties?: Record + title?: string + id?: NodeId // Support custom ID for loading workflows +} + +export interface UpdateNodePropertyParams { + nodeId: NodeId + property: string + value: any +} + +export interface UpdateNodeTitleParams { + nodeId: NodeId + title: string +} + +export interface ChangeNodeModeParams { + nodeId: NodeId + mode: number +} + +export interface ConnectParams { + sourceNodeId: NodeId + sourceSlot: number | string + targetNodeId: NodeId + targetSlot: number | string +} + +export interface DisconnectParams { + nodeId: NodeId + slot: number | string + slotType: 'input' | 'output' + targetNodeId?: NodeId +} + +export interface CreateGroupParams { + title?: string + size?: [number, number] + color?: string + fontSize?: number +} + +export interface UpdateGroupTitleParams { + groupId: GroupId + title: string +} + +export interface AddNodesToGroupParams { + groupId: GroupId + nodeIds: NodeId[] +} + +export interface AddRerouteParams { + pos: [number, number] + linkId?: LinkId + parentRerouteId?: RerouteId +} + +export interface AddNodeInputParams { + nodeId: NodeId + name: string + type: string + extra_info?: Record +} + +export interface AddNodeOutputParams { + nodeId: NodeId + name: string + type: string + extra_info?: Record +} + +export interface CreateSubgraphParams { + selectedItems: Set +} + +export interface NodeInputSlotParams { + nodeId: NodeId + slot: number +} + +export interface SubgraphNameTypeParams { + subgraphId: SubgraphId + name: string + type: string +} + +export interface SubgraphIndexParams { + subgraphId: SubgraphId + index: number +} + +export enum CommandOrigin { + Local = 'local' +} + +export type GraphMutationOperation = + | createNodeCommand + | RemoveNodeCommand + | UpdateNodePropertyCommand + | UpdateNodeTitleCommand + | ChangeNodeModeCommand + | CloneNodeCommand + | BypassNodeCommand + | UnbypassNodeCommand + | ConnectCommand + | DisconnectCommand + | DisconnectLinkCommand + | CreateGroupCommand + | RemoveGroupCommand + | UpdateGroupTitleCommand + | AddNodesToGroupCommand + | RecomputeGroupNodesCommand + | AddRerouteCommand + | RemoveRerouteCommand + | CopyNodesCommand + | CutNodesCommand + | PasteNodesCommand + | CreateSubgraphCommand + | UnpackSubgraphCommand + | AddSubgraphNodeInputCommand + | AddSubgraphNodeOutputCommand + | RemoveSubgraphNodeInputCommand + | RemoveSubgraphNodeOutputCommand + | AddSubgraphInputCommand + | AddSubgraphOutputCommand + | RemoveSubgraphInputCommand + | RemoveSubgraphOutputCommand + | ClearGraphCommand + | bypassNodeCommand + | unbypassNodeCommand + | undoCommand + | redoCommand + +interface GraphOpBase { + /** Timestamp for ordering commands */ + timestamp: number + /** Origin of the command */ + origin: CommandOrigin +} + +export interface createNodeCommand extends GraphOpBase { + type: 'createNode' + params: CreateNodeParams +} + +export interface RemoveNodeCommand extends GraphOpBase { + type: 'removeNode' + params: NodeId +} + +export interface UpdateNodePropertyCommand extends GraphOpBase { + type: 'updateNodeProperty' + params: UpdateNodePropertyParams +} + +export interface UpdateNodeTitleCommand extends GraphOpBase { + type: 'updateNodeTitle' + params: UpdateNodeTitleParams +} + +export interface ChangeNodeModeCommand extends GraphOpBase { + type: 'changeNodeMode' + params: ChangeNodeModeParams +} + +export interface CloneNodeCommand extends GraphOpBase { + type: 'cloneNode' + params: NodeId +} + +export interface BypassNodeCommand extends GraphOpBase { + type: 'bypassNode' + params: NodeId +} + +export interface UnbypassNodeCommand extends GraphOpBase { + type: 'unbypassNode' + params: NodeId +} + +export interface ConnectCommand extends GraphOpBase { + type: 'connect' + params: ConnectParams +} + +export interface DisconnectCommand extends GraphOpBase { + type: 'disconnect' + params: DisconnectParams +} + +export interface DisconnectLinkCommand extends GraphOpBase { + type: 'disconnectLink' + params: LinkId +} + +export interface CreateGroupCommand extends GraphOpBase { + type: 'createGroup' + params: CreateGroupParams +} + +export interface RemoveGroupCommand extends GraphOpBase { + type: 'removeGroup' + params: GroupId +} + +export interface UpdateGroupTitleCommand extends GraphOpBase { + type: 'updateGroupTitle' + params: UpdateGroupTitleParams +} + +export interface AddNodesToGroupCommand extends GraphOpBase { + type: 'addNodesToGroup' + params: AddNodesToGroupParams +} + +export interface RecomputeGroupNodesCommand extends GraphOpBase { + type: 'recomputeGroupNodes' + params: GroupId +} + +// Reroute Commands +export interface AddRerouteCommand extends GraphOpBase { + type: 'addReroute' + params: AddRerouteParams +} + +export interface RemoveRerouteCommand extends GraphOpBase { + type: 'removeReroute' + params: RerouteId +} + +export interface CopyNodesCommand extends GraphOpBase { + type: 'copyNodes' + nodeIds: NodeId[] +} + +export interface CutNodesCommand extends GraphOpBase { + type: 'cutNodes' + params: NodeId[] +} + +export interface PasteNodesCommand extends GraphOpBase { + type: 'pasteNodes' +} + +export interface CreateSubgraphCommand extends GraphOpBase { + type: 'createSubgraph' + params: CreateSubgraphParams +} + +export interface UnpackSubgraphCommand extends GraphOpBase { + type: 'unpackSubgraph' + params: NodeId +} + +export interface AddSubgraphNodeInputCommand extends GraphOpBase { + type: 'addSubgraphNodeInput' + params: AddNodeInputParams +} + +export interface AddSubgraphNodeOutputCommand extends GraphOpBase { + type: 'addSubgraphNodeOutput' + params: AddNodeOutputParams +} + +export interface RemoveSubgraphNodeInputCommand extends GraphOpBase { + type: 'removeSubgraphNodeInput' + params: NodeInputSlotParams +} + +export interface RemoveSubgraphNodeOutputCommand extends GraphOpBase { + type: 'removeSubgraphNodeOutput' + params: NodeInputSlotParams +} + +export interface AddSubgraphInputCommand extends GraphOpBase { + type: 'addSubgraphInput' + params: SubgraphNameTypeParams +} + +export interface AddSubgraphOutputCommand extends GraphOpBase { + type: 'addSubgraphOutput' + params: SubgraphNameTypeParams +} + +export interface RemoveSubgraphInputCommand extends GraphOpBase { + type: 'removeSubgraphInput' + params: SubgraphIndexParams +} + +export interface RemoveSubgraphOutputCommand extends GraphOpBase { + type: 'removeSubgraphOutput' + params: SubgraphIndexParams +} + +export interface ClearGraphCommand extends GraphOpBase { + type: 'clearGraph' +} + +export interface bypassNodeCommand extends GraphOpBase { + type: 'bypassNode' + params: NodeId +} + +export interface unbypassNodeCommand extends GraphOpBase { + type: 'unbypassNode' + params: NodeId +} + +export interface undoCommand extends GraphOpBase { + type: 'undo' +} + +export interface redoCommand extends GraphOpBase { + type: 'redo' +} diff --git a/src/lib/litegraph/src/LGraphGroup.ts b/src/lib/litegraph/src/LGraphGroup.ts index f00f302e6..9f8cc6952 100644 --- a/src/lib/litegraph/src/LGraphGroup.ts +++ b/src/lib/litegraph/src/LGraphGroup.ts @@ -24,6 +24,8 @@ import { } from './measure' import type { ISerialisedGroup } from './types/serialisation' +export type GroupId = number + export interface IGraphGroupFlags extends Record { pinned?: true } diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index d5d91aa33..5ec7c44e4 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -31,6 +31,8 @@ import { } from './ExecutableNodeDTO' import type { SubgraphInput } from './SubgraphInput' +export type SubgraphId = string + /** * An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph. */ diff --git a/tests-ui/tests/services/graphMutationService.test.ts b/tests-ui/tests/services/graphMutationService.test.ts new file mode 100644 index 000000000..f6826ee19 --- /dev/null +++ b/tests-ui/tests/services/graphMutationService.test.ts @@ -0,0 +1,1644 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { GraphMutationError } from '@/core/graph/operations/GraphMutationError' +import type { IGraphMutationService } from '@/core/graph/operations/IGraphMutationService' +import { + GraphMutationService, + useGraphMutationService +} from '@/core/graph/operations/graphMutationService' +import { + CommandOrigin, + type GraphMutationOperation +} from '@/core/graph/operations/types' + +const mockGraph = vi.hoisted(() => ({ + beforeChange: vi.fn(), + afterChange: vi.fn(), + add: vi.fn(), + remove: vi.fn(), + getNodeById: vi.fn(), + removeLink: vi.fn(), + clear: vi.fn(), + setDirtyCanvas: vi.fn(), + _links: new Map(), + _groups: [] as any[], + _nodes: [] as any[], + reroutes: new Map(), + createReroute: vi.fn(), + removeReroute: vi.fn(), + convertToSubgraph: vi.fn(), + unpackSubgraph: vi.fn(), + version: '1.0.0', + config: {} +})) + +const mockApp = vi.hoisted(() => ({ + graph: null as any, + changeTracker: null as any +})) + +Object.defineProperty(mockApp, 'graph', { + writable: true, + value: null +}) +Object.defineProperty(mockApp, 'changeTracker', { + writable: true, + value: null +}) + +const mockWorkflowStore = vi.hoisted(() => ({ + activeWorkflow: { + changeTracker: { + checkState: vi.fn(), + undo: vi.fn(), + redo: vi.fn() + } + } +})) + +const mockLiteGraph = vi.hoisted(() => ({ + createNode: vi.fn(), + uuidv4: vi.fn(() => 'mock-uuid-' + Math.random()) +})) + +const mockLGraphNode = vi.hoisted(() => ({ + connect: vi.fn(), + disconnectInput: vi.fn(), + disconnectOutput: vi.fn(), + setProperty: vi.fn(), + changeMode: vi.fn(), + clone: vi.fn(), + serialize: vi.fn(), + configure: vi.fn(), + addInput: vi.fn(), + addOutput: vi.fn(), + removeInput: vi.fn(), + removeOutput: vi.fn() +})) + +const mockLGraphGroup = vi.hoisted(() => { + let idCounter = 1000 + return class MockLGraphGroup { + id = idCounter++ + title = 'Group' + pos = [0, 0] + size = [200, 200] + color = '#335577' + font_size = 14 + + constructor(title?: string) { + if (title) this.title = title + } + + move = vi.fn() + resize = vi.fn(() => true) + addNodes = vi.fn() + recomputeInsideNodes = vi.fn() + } +}) + +vi.mock('@/scripts/app', () => ({ + app: mockApp +})) + +mockApp.graph = mockGraph +mockApp.changeTracker = mockWorkflowStore.activeWorkflow.changeTracker + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: vi.fn(() => mockWorkflowStore) +})) + +vi.mock('@/lib/litegraph/src/litegraph', () => ({ + LiteGraph: mockLiteGraph, + LGraphNode: mockLGraphNode, + LGraphEventMode: { + ALWAYS: 0, + BYPASS: 4 + } +})) + +vi.mock('@/lib/litegraph/src/LGraphGroup', () => ({ + LGraphGroup: mockLGraphGroup +})) + +vi.mock('@/lib/litegraph/src/LGraph', () => ({ + Subgraph: vi.fn().mockImplementation((graph, data) => ({ + id: data.id, + addInput: vi.fn(), + addOutput: vi.fn(), + removeInput: vi.fn(), + removeOutput: vi.fn(), + inputs: [], + outputs: [], + graph: graph + })) +})) + +const MockSubgraphNode = vi.hoisted(() => { + return class MockSubgraphNode { + constructor(graph: any, subgraph: any, data: any) { + this.id = data?.id + this.subgraph = subgraph + this.graph = graph + } + id: any + subgraph: any + graph: any + } +}) + +vi.mock('@/lib/litegraph/src/subgraph/SubgraphNode', () => ({ + SubgraphNode: MockSubgraphNode +})) + +describe('GraphMutationService', () => { + let service: IGraphMutationService + let mockNode: any + let mockLink: any + + beforeEach(() => { + vi.clearAllMocks() + + service = new GraphMutationService() + + mockNode = { + id: 'node-1', + pos: [100, 100], + title: 'Test Node', + properties: {}, + outputs: [], + inputs: [], + ...mockLGraphNode + } + + mockLink = { + id: '123', + origin_id: 'node-1', + origin_slot: 0, + target_id: 'node-2', + target_slot: 0 + } + + mockGraph.getNodeById.mockImplementation((id: string) => { + if (id === 'node-1' || id === 'node-2') return mockNode + return null + }) + + mockGraph.add.mockReturnValue(mockNode) + mockGraph._links.set(123, mockLink) + + mockLiteGraph.createNode.mockReturnValue(mockNode) + mockNode.connect.mockReturnValue(mockLink) + mockNode.clone.mockReturnValue({ ...mockNode, id: 'cloned-node' }) + mockNode.serialize.mockReturnValue({ + type: 'TestNode', + id: 'node-1', + pos: [100, 100] + }) + }) + + describe('initialization', () => { + it('should implement IGraphMutationService interface', () => { + expect(service).toHaveProperty('applyOperation') + expect(service).toHaveProperty('createNode') + expect(service).toHaveProperty('removeNode') + expect(service).toHaveProperty('connect') + expect(service).toHaveProperty('disconnect') + expect(service).toHaveProperty('undo') + expect(service).toHaveProperty('redo') + expect(typeof service.applyOperation).toBe('function') + expect(typeof service.createNode).toBe('function') + expect(typeof service.removeNode).toBe('function') + expect(typeof service.connect).toBe('function') + }) + + it('should have singleton behavior through useGraphMutationService', () => { + const instance1 = useGraphMutationService() + const instance2 = useGraphMutationService() + + expect(instance1).toBe(instance2) + }) + }) + + describe('command pattern operations', () => { + describe('applyOperation', () => { + it('should apply createNode command and return Result', async () => { + const operation: GraphMutationOperation = { + type: 'createNode', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + type: 'LoadImage', + title: 'My Image Loader', + properties: { seed: 12345 } + } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toBe('node-1') + } + expect(mockLiteGraph.createNode).toHaveBeenCalledWith('LoadImage') + expect(mockGraph.beforeChange).toHaveBeenCalled() + expect(mockGraph.add).toHaveBeenCalledWith(mockNode) + expect(mockGraph.afterChange).toHaveBeenCalled() + }) + + it('should return GraphMutationError on failure', async () => { + mockLiteGraph.createNode.mockReturnValue(null) + + const operation: GraphMutationOperation = { + type: 'createNode', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + type: 'InvalidType' + } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toBeInstanceOf(GraphMutationError) + expect(result.error.message).toBe('Failed to add node') + expect(result.error.context).toMatchObject({ + operation: 'addNode', + params: operation.params + }) + expect(result.error.context.cause).toBeDefined() + } + }) + + it('should handle unknown operation type', async () => { + const operation: any = { + type: 'unknownOperation', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: {} + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toBeInstanceOf(GraphMutationError) + expect(result.error.message).toBe('Unknown operation type') + expect(result.error.code).toBe('GRAPH_MUTATION_ERROR') + expect(result.error.context.operation).toBe('unknownOperation') + } + }) + }) + + describe('Result pattern', () => { + it('should return success Result with data', async () => { + const result = await service.createNode({ type: 'TestNode' }) + + expect(result).toMatchObject({ + success: true, + data: 'node-1' + }) + }) + + it('should return failure Result with GraphMutationError', async () => { + mockLiteGraph.createNode.mockReturnValue(null) + + const result = await service.createNode({ type: 'InvalidType' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toBeInstanceOf(GraphMutationError) + expect(result.error.message).toBe('Failed to add node') + expect(result.error.context.cause).toBeInstanceOf(Error) + } + }) + + it('should preserve error context in Result', async () => { + mockGraph.add.mockReturnValue(null) + + const params = { type: 'TestNode', properties: { test: 123 } } + const result = await service.createNode(params) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.context).toMatchObject({ + operation: 'addNode', + params: params + }) + } + }) + }) + + describe('GraphMutationError', () => { + it('should create error with proper code and context', async () => { + mockGraph.getNodeById.mockReturnValue(null) + + const result = await service.removeNode('nonexistent' as any) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.code).toBe('GRAPH_MUTATION_ERROR') + expect(result.error.message).toBe('Failed to remove node') + expect(result.error.context).toMatchObject({ + operation: 'removeNode', + params: 'nonexistent' + }) + } + }) + + it('should preserve original error as cause', async () => { + const originalError = new Error('Test error') + mockLiteGraph.createNode.mockImplementation(() => { + throw originalError + }) + + const result = await service.createNode({ type: 'TestNode' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.context.cause).toBe(originalError) + } + }) + }) + }) + + describe('node operations via commands', () => { + describe('removeNode command', () => { + it('should remove node and return success Result', async () => { + const operation: GraphMutationOperation = { + type: 'removeNode', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: 'node-1' as any + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect(mockGraph.remove).toHaveBeenCalledWith(mockNode) + }) + + it('should return error Result when node not found', async () => { + mockGraph.getNodeById.mockReturnValue(null) + + const operation: GraphMutationOperation = { + type: 'removeNode', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: 'nonexistent' as any + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.message).toBe('Failed to remove node') + } + }) + }) + + describe('updateNodeProperty command', () => { + it('should update property via command', async () => { + const operation: GraphMutationOperation = { + type: 'updateNodeProperty', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + nodeId: 'node-1' as any, + property: 'seed', + value: 54321 + } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect(mockNode.setProperty).toHaveBeenCalledWith('seed', 54321) + }) + }) + + describe('updateNodeTitle command', () => { + it('should update title and handle transaction boundaries', async () => { + const operation: GraphMutationOperation = { + type: 'updateNodeTitle', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + nodeId: 'node-1' as any, + title: 'New Title' + } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect(mockNode.title).toBe('New Title') + expect(mockGraph.beforeChange).toHaveBeenCalledBefore( + mockGraph.afterChange as any + ) + }) + }) + + describe('changeNodeMode command', () => { + it('should change mode via command', async () => { + mockNode.changeMode.mockReturnValue(true) + + const operation: GraphMutationOperation = { + type: 'changeNodeMode', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + nodeId: 'node-1' as any, + mode: 4 + } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect(mockNode.changeMode).toHaveBeenCalledWith(4) + }) + + it('should return error when mode change fails', async () => { + mockNode.changeMode.mockReturnValue(false) + + const operation: GraphMutationOperation = { + type: 'changeNodeMode', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + nodeId: 'node-1' as any, + mode: 999 + } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.message).toBe('Failed to update node property') + expect(result.error.context.cause.message).toContain('999') + } + }) + }) + + describe('cloneNode command', () => { + it('should clone node via command', async () => { + const operation: GraphMutationOperation = { + type: 'cloneNode', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: 'node-1' as any + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toBe('node-1') + } + expect(mockNode.clone).toHaveBeenCalled() + }) + + it('should handle clone failure with proper error', async () => { + mockNode.clone.mockReturnValue(null) + + const operation: GraphMutationOperation = { + type: 'cloneNode', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: 'node-1' as any + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.context.operation).toBe('cloneNode') + } + }) + }) + }) + + describe('connection operations via commands', () => { + describe('connect command', () => { + it('should create connection and return LinkId', async () => { + const operation: GraphMutationOperation = { + type: 'connect', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + sourceNodeId: 'node-1' as any, + sourceSlot: 0, + targetNodeId: 'node-2' as any, + targetSlot: 1 + } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toBe('123') + } + expect(mockNode.connect).toHaveBeenCalledWith(0, mockNode, 1) + }) + + it('should return error when nodes not found', async () => { + mockGraph.getNodeById.mockImplementation((id: string) => + id === 'node-1' ? null : mockNode + ) + + const operation: GraphMutationOperation = { + type: 'connect', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + sourceNodeId: 'node-1' as any, + sourceSlot: 0, + targetNodeId: 'node-2' as any, + targetSlot: 1 + } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.message).toBe('Failed to clone node') + expect(result.error.context.cause.message).toContain('node-1') + } + }) + }) + + describe('disconnect command', () => { + it('should disconnect node slot', async () => { + mockNode.disconnectInput.mockReturnValue(true) + + const operation: GraphMutationOperation = { + type: 'disconnect', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + nodeId: 'node-1' as any, + slot: 0, + slotType: 'input' + } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toBe(true) + } + expect(mockNode.disconnectInput).toHaveBeenCalledWith(0) + }) + }) + + describe('disconnectLink command', () => { + it('should disconnect specific link', async () => { + const operation: GraphMutationOperation = { + type: 'disconnectLink', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: 123 as any + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect(mockGraph.removeLink).toHaveBeenCalledWith(123) + }) + }) + }) + + describe('group operations via commands', () => { + let mockGroup: any + + beforeEach(() => { + mockGroup = new mockLGraphGroup('Test Group') + mockGroup.id = 999 + mockGraph._groups = [mockGroup] as any + }) + + describe('createGroup command', () => { + it('should create group with parameters', async () => { + const operation: GraphMutationOperation = { + type: 'createGroup', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + title: 'My Group', + size: [300, 250] as [number, number], + color: '#ff0000', + fontSize: 16 + } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + if (result.success) { + expect(typeof result.data).toBe('number') + expect(result.data).toBeGreaterThanOrEqual(1000) + } + }) + }) + + describe('removeGroup command', () => { + it('should remove group and return success', async () => { + const operation: GraphMutationOperation = { + type: 'removeGroup', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: mockGroup.id + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect(mockGraph.remove).toHaveBeenCalledWith(mockGroup) + }) + + it('should return error when group not found', async () => { + const operation: GraphMutationOperation = { + type: 'removeGroup', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: 123456 as any + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.message).toBe('Failed to remove group') + } + }) + }) + + describe('addNodesToGroup command', () => { + it('should add nodes to group via command', async () => { + const operation: GraphMutationOperation = { + type: 'addNodesToGroup', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + groupId: mockGroup.id, + nodeIds: ['node-1' as any, 'node-2' as any] + } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect(mockGroup.addNodes).toHaveBeenCalledWith([mockNode, mockNode]) + }) + }) + }) + + describe('command timestamp and origin', () => { + it('should validate timestamp exists on all commands', async () => { + const timestamp = Date.now() + const operation: GraphMutationOperation = { + type: 'createNode', + timestamp: timestamp, + origin: CommandOrigin.Local, + params: { type: 'TestNode' } + } + + await service.applyOperation(operation) + + expect(operation.timestamp).toBe(timestamp) + expect(operation.timestamp).toBeLessThanOrEqual(Date.now()) + }) + + it('should validate origin field on commands', async () => { + const operation: GraphMutationOperation = { + type: 'createNode', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { type: 'TestNode' } + } + + await service.applyOperation(operation) + + expect(operation.origin).toBe(CommandOrigin.Local) + }) + }) + + describe('clipboard operations via commands', () => { + beforeEach(() => { + localStorage.clear() + }) + + describe('copyNodes command', () => { + it('should copy nodes and return success Result', async () => { + const clonedNode = { + serialize: vi.fn(() => ({ + id: 'node-1', + type: 'TestNode', + pos: [0, 0] + })) + } + mockNode.clone = vi.fn(() => clonedNode) + + const operation: GraphMutationOperation = { + type: 'copyNodes', + timestamp: Date.now(), + origin: CommandOrigin.Local, + nodeIds: ['node-1' as any] + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + const storedData = localStorage.getItem('litegrapheditor_clipboard') + expect(storedData).not.toBeNull() + }) + + it('should return error for empty node list', async () => { + const operation: GraphMutationOperation = { + type: 'copyNodes', + timestamp: Date.now(), + origin: CommandOrigin.Local, + nodeIds: [] + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.context.cause.message).toContain('No nodes') + } + }) + }) + + describe('cutNodes command', () => { + it('should cut nodes via command', async () => { + const clonedNode = { + serialize: vi.fn(() => ({ + id: 'node-1', + type: 'TestNode', + pos: [0, 0] + })) + } + mockNode.clone = vi.fn(() => clonedNode) + + const operation: GraphMutationOperation = { + type: 'cutNodes', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: ['node-1' as any] + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + const storedData = localStorage.getItem('litegrapheditor_clipboard') + const clipboardData = JSON.parse(storedData!) + expect(clipboardData.isCut).toBe(true) + }) + }) + + describe('pasteNodes command', () => { + beforeEach(async () => { + const clipboardData = { + nodes: [ + { + id: 'node-1', + type: 'TestNode', + pos: [100, 100] + } + ], + links: [], + isCut: false + } + localStorage.setItem( + 'litegrapheditor_clipboard', + JSON.stringify(clipboardData) + ) + + const newNode = { ...mockNode, id: 'new-node-1', configure: vi.fn() } + mockLiteGraph.createNode.mockReturnValue(newNode) + mockGraph.add.mockReturnValue(newNode) + }) + + it('should paste nodes and return node IDs', async () => { + const operation: GraphMutationOperation = { + type: 'pasteNodes', + timestamp: Date.now(), + origin: CommandOrigin.Local + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + if (result.success) { + expect(Array.isArray(result.data)).toBe(true) + expect(result.data).toHaveLength(1) + } + }) + + it('should return error for empty clipboard', async () => { + localStorage.clear() + + const operation: GraphMutationOperation = { + type: 'pasteNodes', + timestamp: Date.now(), + origin: CommandOrigin.Local + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.context.cause.message).toContain('Clipboard') + } + }) + }) + }) + + describe('undo/redo operations via commands', () => { + it('should execute undo command', async () => { + const operation: GraphMutationOperation = { + type: 'undo', + timestamp: Date.now(), + origin: CommandOrigin.Local + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect( + mockWorkflowStore.activeWorkflow.changeTracker.undo + ).toHaveBeenCalled() + }) + + it('should execute redo command', async () => { + const operation: GraphMutationOperation = { + type: 'redo', + timestamp: Date.now(), + origin: CommandOrigin.Local + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect( + mockWorkflowStore.activeWorkflow.changeTracker.redo + ).toHaveBeenCalled() + }) + + it('should return error when change tracker missing', async () => { + const originalActiveWorkflow = mockWorkflowStore.activeWorkflow + mockWorkflowStore.activeWorkflow = null as any + + const operation: GraphMutationOperation = { + type: 'undo', + timestamp: Date.now(), + origin: CommandOrigin.Local + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.context.cause.message).toContain('workflow') + } + + mockWorkflowStore.activeWorkflow = originalActiveWorkflow + }) + }) + + describe('graph-level operations via commands', () => { + describe('clearGraph command', () => { + it('should clear graph via command', async () => { + const operation: GraphMutationOperation = { + type: 'clearGraph', + timestamp: Date.now(), + origin: CommandOrigin.Local + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect(mockGraph.clear).toHaveBeenCalled() + expect(mockGraph.beforeChange).toHaveBeenCalled() + expect(mockGraph.afterChange).toHaveBeenCalled() + }) + }) + + describe('bypass commands', () => { + it('should bypass node via command', async () => { + const operation: GraphMutationOperation = { + type: 'bypassNode', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: 'node-1' as any + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect(mockNode.mode).toBe(4) + expect(mockGraph.setDirtyCanvas).toHaveBeenCalledWith(true, false) + }) + + it('should unbypass node via command', async () => { + const operation: GraphMutationOperation = { + type: 'unbypassNode', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: 'node-1' as any + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect(mockNode.mode).toBe(0) + expect(mockGraph.setDirtyCanvas).toHaveBeenCalledWith(true, false) + }) + }) + }) + + describe('error propagation and context', () => { + it('should wrap thrown errors in GraphMutationError', async () => { + const originalError = new Error('Node creation failed') + mockLiteGraph.createNode.mockImplementation(() => { + throw originalError + }) + + const operation: GraphMutationOperation = { + type: 'createNode', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { type: 'FailNode' } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toBeInstanceOf(GraphMutationError) + expect(result.error.context.cause).toBe(originalError) + expect(result.error.context.operation).toBe('addNode') + } + }) + + it('should preserve full error context', async () => { + mockGraph.getNodeById.mockReturnValue(null) + + const params = { + nodeId: 'test-node' as any, + property: 'testProp', + value: 'testValue' + } + + const operation: GraphMutationOperation = { + type: 'updateNodeProperty', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.context).toMatchObject({ + operation: 'updateNodeProperty', + params: params + }) + expect(result.error.context.cause).toBeDefined() + } + }) + + it('should handle async errors properly', async () => { + mockWorkflowStore.activeWorkflow.changeTracker.undo.mockRejectedValue( + new Error('Undo failed') + ) + + const operation: GraphMutationOperation = { + type: 'undo', + timestamp: Date.now(), + origin: CommandOrigin.Local + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.message).toBe('Failed to undo') + expect(result.error.context.cause.message).toBe('Undo failed') + } + }) + }) + + describe('subgraph operations via commands', () => { + let mockSubgraphNode: any + let mockSubgraph: any + + beforeEach(() => { + mockSubgraph = { + id: 'subgraph-1', + nodes: [], + groups: [], + reroutes: new Map(), + inputs: [], + outputs: [], + addInput: vi.fn(), + addOutput: vi.fn(), + removeInput: vi.fn(), + removeOutput: vi.fn() + } + + mockSubgraphNode = new MockSubgraphNode(mockGraph, mockSubgraph, { + id: 'subgraph-node-1' + }) + mockSubgraphNode.type = 'subgraph' + mockSubgraphNode.isSubgraphNode = vi.fn(() => true) + }) + + describe('createSubgraph command', () => { + it('should create subgraph via command', async () => { + const selectedItems = new Set([mockNode]) + const expectedResult = { + subgraph: mockSubgraph, + node: mockSubgraphNode + } + + mockGraph.convertToSubgraph.mockReturnValue(expectedResult) + + const operation: GraphMutationOperation = { + type: 'createSubgraph', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { selectedItems } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toBe(expectedResult) + } + }) + + it('should return error when no items selected', async () => { + const operation: GraphMutationOperation = { + type: 'createSubgraph', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { selectedItems: new Set() } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.context.cause.message).toContain('no items') + } + }) + }) + + describe('unpackSubgraph command', () => { + it('should unpack subgraph via command', async () => { + mockGraph.getNodeById.mockReturnValue(mockSubgraphNode) + mockGraph.unpackSubgraph.mockImplementation(() => {}) + + const operation: GraphMutationOperation = { + type: 'unpackSubgraph', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: 'subgraph-node-1' as any + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect(mockGraph.unpackSubgraph).toHaveBeenCalledWith(mockSubgraphNode) + }) + + it('should return error for non-subgraph node', async () => { + const regularNode = { + ...mockNode, + isSubgraphNode: undefined, + subgraph: undefined + } + mockGraph.getNodeById.mockReturnValue(regularNode) + + const operation: GraphMutationOperation = { + type: 'unpackSubgraph', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: 'node-1' as any + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.context.cause.message).toContain('not a subgraph') + } + }) + }) + + describe('subgraph input/output commands', () => { + beforeEach(() => { + mockGraph._nodes = [mockSubgraphNode] + }) + + it('should add subgraph input via command', async () => { + const operation: GraphMutationOperation = { + type: 'addSubgraphInput', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + subgraphId: 'subgraph-1' as any, + name: 'input1', + type: 'number' + } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect(mockSubgraph.addInput).toHaveBeenCalledWith('input1', 'number') + }) + + it('should remove subgraph output via command', async () => { + mockSubgraph.outputs = ['output1'] + + const operation: GraphMutationOperation = { + type: 'removeSubgraphOutput', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + subgraphId: 'subgraph-1' as any, + index: 0 + } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect(mockSubgraph.removeOutput).toHaveBeenCalledWith('output1') + }) + }) + }) + + describe('reroute operations via commands', () => { + describe('addReroute command', () => { + it('should add reroute to link', async () => { + const mockReroute = { id: 'reroute-1' } + mockGraph.createReroute.mockReturnValue(mockReroute) + mockGraph._links.set(123, mockLink) + + const operation: GraphMutationOperation = { + type: 'addReroute', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + pos: [100, 100], + linkId: 123 as any + } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toBe('reroute-1') + } + }) + + it('should return error when link not found', async () => { + mockGraph._links.clear() + + const operation: GraphMutationOperation = { + type: 'addReroute', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: { + pos: [100, 100], + linkId: 999 as any + } + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.context.cause.message).toContain('999') + } + }) + }) + + describe('removeReroute command', () => { + it('should remove reroute', async () => { + mockGraph.reroutes.set('reroute-1' as any, {}) + + const operation: GraphMutationOperation = { + type: 'removeReroute', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: 'reroute-1' as any + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect(mockGraph.removeReroute).toHaveBeenCalledWith('reroute-1') + }) + }) + }) + + describe('edge cases', () => { + describe('canvas null scenarios', () => { + it('should handle operations when canvas is not initialized', async () => { + mockGraph.setDirtyCanvas.mockImplementation(() => { + throw new Error('Canvas not initialized') + }) + + const operation: GraphMutationOperation = { + type: 'bypassNode', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: 'node-1' as any + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.context.cause.message).toContain( + 'Canvas not initialized' + ) + } + }) + + it('should handle group operations without canvas', async () => { + mockGraph.setDirtyCanvas.mockImplementation(() => { + throw new Error('Canvas is null') + }) + + const mockGroup = new mockLGraphGroup('Test Group') + mockGroup.id = 999 + mockGraph._groups = [mockGroup] as any + + const result = await service.updateGroupTitle({ + groupId: mockGroup.id, + title: 'New Title' + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toBeInstanceOf(GraphMutationError) + expect(result.error.message).toBe('Failed to update group title') + expect(result.error.context.cause.message).toBe('Canvas is null') + } + }) + + it('should handle reroute operations without canvas', async () => { + mockGraph.setDirtyCanvas.mockImplementation(() => { + console.warn('Canvas not available') + }) + + mockGraph.reroutes.set('reroute-1' as any, {}) + + const operation: GraphMutationOperation = { + type: 'removeReroute', + timestamp: Date.now(), + origin: CommandOrigin.Local, + params: 'reroute-1' as any + } + + const result = await service.applyOperation(operation) + + expect(result.success).toBe(true) + expect(mockGraph.removeReroute).toHaveBeenCalledWith('reroute-1') + }) + }) + + describe('subgraph context switching', () => { + let parentGraph: any + let subgraphContext: any + let mockSubgraphNode: any + let mockSubgraph: any + + beforeEach(() => { + parentGraph = { + beforeChange: vi.fn(), + afterChange: vi.fn(), + add: vi.fn(), + remove: vi.fn(), + getNodeById: vi.fn(), + removeLink: vi.fn(), + clear: vi.fn(), + setDirtyCanvas: vi.fn(), + _links: new Map(), + _groups: [] as any[], + _nodes: [] as any[], + reroutes: new Map(), + createReroute: vi.fn(), + removeReroute: vi.fn(), + convertToSubgraph: vi.fn(), + unpackSubgraph: vi.fn(), + version: '1.0.0', + config: {} + } + + mockSubgraph = { + id: 'subgraph-1', + nodes: [], + groups: [], + reroutes: new Map(), + inputs: [], + outputs: [], + addInput: vi.fn(), + addOutput: vi.fn(), + removeInput: vi.fn(), + removeOutput: vi.fn(), + _nodes: [] as any[] + } + + mockSubgraphNode = new MockSubgraphNode(parentGraph, mockSubgraph, { + id: 'subgraph-node-1' + }) + mockSubgraphNode.type = 'subgraph' + mockSubgraphNode.isSubgraphNode = vi.fn(() => true) + + subgraphContext = { + beforeChange: vi.fn(), + afterChange: vi.fn(), + add: vi.fn(), + remove: vi.fn(), + getNodeById: vi.fn(), + removeLink: vi.fn(), + clear: vi.fn(), + setDirtyCanvas: vi.fn(), + _links: new Map(), + _groups: [] as any[], + _nodes: [] as any[], + reroutes: new Map(), + createReroute: vi.fn(), + removeReroute: vi.fn(), + convertToSubgraph: vi.fn(), + unpackSubgraph: vi.fn(), + version: '1.0.0', + config: {}, + _parent_graph: parentGraph, + _is_subgraph: true + } + + parentGraph._nodes = [mockSubgraphNode] + }) + + it('should handle mutations during subgraph navigation', async () => { + const originalGraph = mockApp.graph + mockApp.graph = subgraphContext + + const nodeInSubgraph = { ...mockNode, id: 'subgraph-inner-node' } + mockLiteGraph.createNode.mockReturnValue(nodeInSubgraph) + subgraphContext.add.mockReturnValue(nodeInSubgraph) + + const result = await service.createNode({ + type: 'TestNode', + title: 'Node in Subgraph' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toBe('subgraph-inner-node') + } + + expect(subgraphContext.add).toHaveBeenCalledWith(nodeInSubgraph) + expect(parentGraph.add).not.toHaveBeenCalled() + + mockApp.graph = originalGraph + }) + + it('should handle widget configuration in subgraph context', async () => { + const originalGraph = mockApp.graph + mockApp.graph = subgraphContext + + const nodeWithWidgets = { + ...mockNode, + id: 'widget-node', + widgets: [ + { type: 'number', name: 'seed', value: 123 }, + { type: 'combo', name: 'sampler', value: 'euler' } + ] + } + + mockLiteGraph.createNode.mockReturnValue(nodeWithWidgets) + subgraphContext.add.mockReturnValue(nodeWithWidgets) + subgraphContext.getNodeById.mockReturnValue(nodeWithWidgets) + + const createResult = await service.createNode({ + type: 'KSampler', + properties: { seed: 456 } + }) + + expect(createResult.success).toBe(true) + + const updateResult = await service.updateNodeProperty({ + nodeId: 'widget-node' as any, + property: 'seed', + value: 789 + }) + + expect(updateResult.success).toBe(true) + expect(nodeWithWidgets.setProperty).toHaveBeenCalledWith('seed', 789) + + mockApp.graph = originalGraph + }) + + it('should switch between parent and subgraph contexts correctly', async () => { + const originalGraph = mockApp.graph + + mockApp.graph = parentGraph + parentGraph.getNodeById.mockReturnValue(mockSubgraphNode) + parentGraph._nodes = [mockSubgraphNode] + + const getSubgraphPrivate = (service as any).getSubgraph.bind(service) + const foundSubgraph = getSubgraphPrivate('subgraph-1') + expect(foundSubgraph).toBe(mockSubgraph) + + mockApp.graph = subgraphContext + + const innerNode = { ...mockNode, id: 'inner-node' } + subgraphContext.getNodeById.mockReturnValue(innerNode) + + const result = await service.removeNode('inner-node' as any) + expect(result.success).toBe(true) + expect(subgraphContext.remove).toHaveBeenCalledWith(innerNode) + + mockApp.graph = originalGraph + }) + }) + + describe('link ID preservation', () => { + it('should preserve link IDs across subgraph operations', async () => { + const originalLinkId = 'link-123' + const sourceNode = { + ...mockNode, + id: 'source-1', + outputs: [{ links: [originalLinkId] }] + } + const targetNode = { + ...mockNode, + id: 'target-1', + inputs: [{ link: originalLinkId }] + } + + mockGraph.getNodeById.mockImplementation((id: string) => { + if (id === 'source-1') return sourceNode + if (id === 'target-1') return targetNode + return null + }) + + const originalLink = { + id: originalLinkId, + origin_id: 'source-1', + origin_slot: 0, + target_id: 'target-1', + target_slot: 0, + type: 'IMAGE' + } + mockGraph._links.set(originalLinkId, originalLink) + + const selectedItems = new Set([sourceNode, targetNode]) + const subgraph = { + id: 'test-subgraph', + nodes: [], + _links: new Map(), + addNode: vi.fn(), + addLink: vi.fn() + } + + const subgraphNode = { + id: 'subgraph-node-1', + type: 'subgraph', + subgraph: subgraph, + inputs: [], + outputs: [] + } + + mockGraph.convertToSubgraph.mockReturnValue({ + subgraph: subgraph, + node: subgraphNode + }) + + const createResult = await service.createSubgraph({ selectedItems }) + + expect(createResult.success).toBe(true) + if (createResult.success) { + expect(createResult.data.subgraph).toBe(subgraph) + expect(createResult.data.node).toBe(subgraphNode) + } + + mockGraph.getNodeById.mockReturnValue(subgraphNode) + mockGraph.unpackSubgraph.mockImplementation(() => { + const newSourceNode = { ...sourceNode, id: 'source-2' } + const newTargetNode = { ...targetNode, id: 'target-2' } + + const newLink = { + id: 'new-link-789', + origin_id: newSourceNode.id, + origin_slot: 0, + target_id: newTargetNode.id, + target_slot: 0, + type: originalLink.type + } + + mockGraph._nodes.push(newSourceNode, newTargetNode) + mockGraph._links.set('new-link-789', newLink) + + newSourceNode.outputs[0].links = ['new-link-789'] + newTargetNode.inputs[0].link = 'new-link-789' + }) + + const unpackResult = await service.unpackSubgraph( + 'subgraph-node-1' as any + ) + + expect(unpackResult.success).toBe(true) + expect(mockGraph.unpackSubgraph).toHaveBeenCalledWith(subgraphNode) + + const newLink = mockGraph._links.get('new-link-789') + expect(newLink).toBeDefined() + expect(newLink.type).toBe(originalLink.type) + }) + + it('should maintain link integrity when copying subgraph nodes', async () => { + const subgraphNode = { + ...mockNode, + id: 'subgraph-1', + type: 'subgraph', + isSubgraphNode: vi.fn(() => true), + subgraph: { + nodes: [ + { id: 'internal-1', outputs: [{ links: ['internal-link-1'] }] }, + { id: 'internal-2', inputs: [{ link: 'internal-link-1' }] } + ], + _links: new Map([ + [ + 'internal-link-1', + { + id: 'internal-link-1', + origin_id: 'internal-1', + target_id: 'internal-2' + } + ] + ]) + } + } + + mockGraph.getNodeById.mockReturnValue(subgraphNode) + + const clonedSubgraph = { + ...subgraphNode, + id: 'subgraph-2', + subgraph: { + nodes: [ + { id: 'internal-3', outputs: [{ links: ['internal-link-2'] }] }, + { id: 'internal-4', inputs: [{ link: 'internal-link-2' }] } + ], + _links: new Map([ + [ + 'internal-link-2', + { + id: 'internal-link-2', + origin_id: 'internal-3', + target_id: 'internal-4' + } + ] + ]) + } + } + + subgraphNode.clone = vi.fn(() => clonedSubgraph) + mockGraph.add.mockReturnValue(clonedSubgraph) + + const result = await service.cloneNode('subgraph-1' as any) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toBe('subgraph-2') + } + + expect(clonedSubgraph.subgraph._links.size).toBe(1) + const clonedLink = clonedSubgraph.subgraph._links.get('internal-link-2') + expect(clonedLink).toBeDefined() + expect(clonedLink.id).not.toBe('internal-link-1') + expect(clonedLink.origin_id).toBe('internal-3') + expect(clonedLink.target_id).toBe('internal-4') + }) + }) + }) +})