diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 09f9168..fea6f0a 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -4,7 +4,7 @@ - **v1.0 Docker Control via Telegram** — Phases 1-5 (shipped 2026-02-02) -> [Archive](milestones/v1.0-ROADMAP.md) - **v1.1 n8n Integration & Polish** — Phases 6-9 (shipped 2026-02-04) -> [Archive](milestones/v1.1-ROADMAP.md) -- **v1.2 Modularization & Polish** — Phases 10-13 (planned) +- **v1.2 Modularization & Polish** — Phases 10-13 + 10.1 (planned) --- @@ -39,6 +39,24 @@ Plans: --- +### Phase 10.1: Better Logging and Log Management (INSERTED) + +**Goal:** Improve logging capabilities and log management features + +**Dependencies:** Phase 10 (modularization complete) + +**Requirements:** TBD + +**Plans:** 0 plans + +Plans: +- [ ] TBD (run /gsd:plan-phase 10.1 to break down) + +**Success Criteria:** +1. [To be defined during planning] + +--- + ### Phase 11: Update All & Callback Limits **Goal:** Add "update all" functionality and fix callback data limits for batch selection @@ -111,11 +129,12 @@ Plans: | 8 | Inline Keyboard Infrastructure | v1.1 | Complete | | 9 | Batch Operations | v1.1 | Complete | | 10 | Workflow Modularization | v1.2 | Planned | +| 10.1 | Better Logging and Log Management | v1.2 | Pending (INSERTED) | | 11 | Update All & Callback Limits | v1.2 | Pending | | 12 | Polish & Audit | v1.2 | Pending | | 13 | Documentation Overhaul | v1.2 | Pending | -**v1.2 Coverage:** 12 requirements mapped across 4 phases +**v1.2 Coverage:** 12+ requirements mapped across 5 phases --- *Updated: 2026-02-04 after Phase 10 planning* diff --git a/.planning/STATE.md b/.planning/STATE.md index 525b323..5f7a1ad 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -16,10 +16,11 @@ v1.1: [██████████] 100% SHIPPED v1.2: [██ ] 20% -Phase 10: Workflow Modularization [████████ ] 80% (4/5 plans) -Phase 11: Update All & Callback Limits [ ] Pending -Phase 12: Polish & Audit [ ] Pending -Phase 13: Documentation Overhaul [ ] Pending +Phase 10: Workflow Modularization [████████ ] 80% (4/5 plans) +Phase 10.1: Better Logging & Log Mgmt [ ] Pending (INSERTED) +Phase 11: Update All & Callback Limits [ ] Pending +Phase 12: Polish & Audit [ ] Pending +Phase 13: Documentation Overhaul [ ] Pending ``` ## Phase 10 Status @@ -53,5 +54,9 @@ Phase 13: Documentation Overhaul [ ] Pending `/gsd:execute-phase 10` — Will execute remaining plan 10-05 +## Roadmap Evolution + +- Phase 10.1 inserted after Phase 10: Better Logging and Log Management (URGENT) + --- *Auto-maintained by GSD workflow* diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..6fc4a59 --- /dev/null +++ b/DEPLOYMENT_GUIDE.md @@ -0,0 +1,233 @@ +# Deployment Guide - Plan 10-05 + +## Overview +This guide covers deploying the modularized workflow structure with 3 sub-workflows: +- Container Update (existing) +- Container Actions (existing) +- Container Logs (NEW) + +## Current Status +- Main workflow: 209 → 199 nodes (-10 nodes, -4.8%) +- Sub-workflows: 3 total +- Node count above target (120-150) but significantly improved + +## Deployment Steps + +### 1. Import Container Logs Sub-workflow + +```bash +# Import n8n-container-logs.json to n8n +# Via UI: Settings → Import → Select n8n-container-logs.json +# Or via n8n CLI if available +``` + +After import, note the workflow ID assigned by n8n. + +### 2. Update Main Workflow with Logs Sub-workflow ID + +```bash +# Edit n8n-workflow.json +# Find "Execute Text Logs" and "Execute Inline Logs" nodes +# Update their workflowId.value to the actual ID from step 1 + +# Example: +# "workflowId": { +# "__rl": true, +# "mode": "list", +# "value": "ACTUAL_ID_HERE" +# } +``` + +Or use this script: + +```python +import json +import sys + +if len(sys.argv) < 2: + print("Usage: python update_logs_id.py ") + sys.exit(1) + +logs_id = sys.argv[1] + +with open('n8n-workflow.json', 'r') as f: + workflow = json.load(f) + +# Update Execute Text Logs +for node in workflow['nodes']: + if node['name'] in ['Execute Text Logs', 'Execute Inline Logs']: + node['parameters']['workflowId']['value'] = logs_id + print(f"Updated {node['name']} to use {logs_id}") + +with open('n8n-workflow.json', 'w') as f: + json.dump(workflow, f, indent=2) + +print("✓ Updated main workflow with logs sub-workflow ID") +``` + +### 3. Import/Update Main Workflow + +```bash +# Import updated n8n-workflow.json to n8n +# This will update the existing main workflow +``` + +### 4. Verify Sub-workflows are Active + +In n8n UI, ensure all 3 sub-workflows are set to "Active": +- Container Update +- Container Actions +- Container Logs + +### 5. Test All Paths + +#### Text Commands: +``` +# Test to bot +status +start plex +stop plex +restart plex +update plex +logs plex +logs plex 100 +``` + +#### Inline Keyboard: +1. Send "status" to get container list +2. Click on a container +3. Test buttons: + - Start/Stop/Restart + - Update + - Logs + - Back to List + +#### Batch Operations: +1. Send "status" to get container list +2. Enable batch mode (toggle button) +3. Select multiple containers +4. Click "Start Selected" / "Stop Selected" / "Restart Selected" +5. Verify progress messages update +6. Verify final summary + +#### Batch Update: +1. Send command: `update all` +2. Confirm the update +3. Verify progress messages +4. Verify all containers updated + +## Verification Checklist + +- [ ] Container Logs sub-workflow imported and active +- [ ] Main workflow updated with correct logs workflow ID +- [ ] All sub-workflows marked as Active in n8n +- [ ] Text commands work (status, start, stop, restart, update, logs) +- [ ] Inline keyboard flows work (all buttons functional) +- [ ] Batch selection and execution works +- [ ] Batch update all works with progress tracking +- [ ] Error messages display correctly +- [ ] No Docker API errors in n8n logs + +## Architecture + +``` +Main Workflow (n8n-workflow.json) - 199 nodes +├── Telegram Trigger + Auth +├── Command Parsing & Routing +├── Container List & Status Display +├── Batch Selection UI +├── Confirmation Dialogs +└── Sub-workflow Orchestration + ├── Container Update (7AvTzLtKXM2hZTio92_mC) + │ └── Used by: Execute Text Update, Execute Callback Update, Execute Batch Update + ├── Container Actions (fYSZS5PkH0VSEaT5) + │ └── Used by: Execute Container Action, Execute Inline Action, + │ Execute Confirmed Stop Action, Execute Batch Action Sub-workflow + └── Container Logs (NEW - get ID after import) + └── Used by: Execute Text Logs, Execute Inline Logs +``` + +## Sub-workflow Input Contracts + +### Container Update +```json +{ + "containerId": "string", + "containerName": "string", + "chatId": "number", + "messageId": "number", + "responseMode": "text|inline" +} +``` + +### Container Actions +```json +{ + "containerId": "string", + "containerName": "string", + "action": "start|stop|restart", + "chatId": "number", + "messageId": "number", + "responseMode": "text|inline" +} +``` + +### Container Logs +```json +{ + "containerId": "string (optional if containerName provided)", + "containerName": "string", + "lineCount": "number (default 50, max 1000)", + "chatId": "number", + "messageId": "number", + "responseMode": "text|inline" +} +``` + +## Troubleshooting + +### "Workflow not found" errors +- Verify sub-workflow IDs in main workflow match actual IDs in n8n +- Ensure sub-workflows are set to Active + +### Batch operations not working +- Check that "Prepare Batch Update/Action Input" nodes have correct logic +- Verify Execute Batch Update/Action nodes use correct workflow IDs +- Check "Handle Batch Update/Action Result Sub" processes results correctly + +### Logs not displaying +- Verify Container Logs workflow ID is correct in main workflow +- Check Docker socket proxy is accessible from n8n +- Verify container names are being normalized correctly + +## Future Optimization Opportunities + +The current node count (199) is above the target range (120-150). Additional optimization could include: + +1. **Consolidate batch confirmation flows** (~15 nodes) + - "Build Batch Commands" path (old execute-all-at-once batch) + - Could be refactored to use the same loop-based approach + +2. **Streamline UI/keyboard generation** (~20 nodes) + - Many "Build *" and "Format *" nodes for keyboards + - Could use shared helper sub-workflow + +3. **Combine similar routers** (~5-10 nodes) + - Multiple IF/Switch nodes for similar logic + - Could be consolidated + +Total potential reduction: ~40-45 nodes → target of ~155-160 nodes + +However, these optimizations require deeper refactoring and testing. The current state achieves the primary goals: +- ✓ No duplicate update logic (single + batch use same sub-workflow) +- ✓ No duplicate action logic (single + batch use same sub-workflow) +- ✓ No duplicate logs logic (text + inline use same sub-workflow) +- ✓ All container operations modularized +- ✓ Easier maintenance and debugging + +## Notes + +- The main workflow still contains batch selection UI, which is intentionally kept in main workflow for better user experience +- Confirmation dialogs remain inline for simplicity +- The old "Build Batch Commands" flow (execute-all-at-once) is separate from the new progress-loop batch execution +- Both batch approaches coexist for backward compatibility diff --git a/refactor_workflow.py b/refactor_workflow.py new file mode 100644 index 0000000..74228b2 --- /dev/null +++ b/refactor_workflow.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +Refactor n8n workflow to use sub-workflows for batch operations and logs. +Task 1: Wire batch update to Container Update sub-workflow +""" + +import json +import copy +import sys + +def load_workflow(filename): + with open(filename, 'r') as f: + return json.load(f) + +def save_workflow(workflow, filename): + with open(filename, 'w') as f: + json.dump(workflow, f, indent=2) + +def find_node_by_name(workflow, name): + for node in workflow['nodes']: + if node['name'] == name: + return node + return None + +def remove_node(workflow, node_name): + """Remove a node and all its connections""" + # Remove from nodes list + workflow['nodes'] = [n for n in workflow['nodes'] if n['name'] != node_name] + + # Remove from connections (as source) + if node_name in workflow['connections']: + del workflow['connections'][node_name] + + # Remove from connections (as target) + for source, outputs in list(workflow['connections'].items()): + for output_key, connections in list(outputs.items()): + workflow['connections'][source][output_key] = [ + conn for conn in connections if conn['node'] != node_name + ] + # Clean up empty output keys + if not workflow['connections'][source][output_key]: + del workflow['connections'][source][output_key] + # Clean up empty source nodes + if not workflow['connections'][source]: + del workflow['connections'][source] + +def create_execute_workflow_node(name, workflow_id, position, parameters=None): + """Create an Execute Workflow node with proper n8n 1.2 format""" + node = { + "parameters": { + "workflowId": { + "__rl": True, + "mode": "list", + "value": workflow_id + }, + "options": {} + }, + "id": f"auto-generated-{name.replace(' ', '-').lower()}", + "name": name, + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.2, + "position": position + } + + if parameters: + node['parameters'].update(parameters) + + return node + +def trace_flow_from_node(workflow, start_node_name, max_depth=10): + """Trace the flow starting from a node""" + flow = [] + current = start_node_name + depth = 0 + + while current and depth < max_depth: + flow.append(current) + + # Find next node + if current in workflow['connections']: + outputs = workflow['connections'][current] + if 'main' in outputs and len(outputs['main']) > 0 and len(outputs['main'][0]) > 0: + current = outputs['main'][0][0]['node'] + depth += 1 + else: + break + else: + break + + return flow + +def main(): + print("Loading workflow...") + workflow = load_workflow('n8n-workflow.json') + + print(f"Current node count: {len(workflow['nodes'])}") + + # Task 1: Refactor batch update to use Container Update sub-workflow + print("\n=== TASK 1: Wire batch update to Container Update sub-workflow ===") + + # Find the batch update flow + batch_prep = find_node_by_name(workflow, 'Prepare Update All Batch') + if not batch_prep: + print("ERROR: Could not find 'Prepare Update All Batch' node") + sys.exit(1) + + print(f"Found batch update entry point at position {batch_prep['position']}") + + # Trace what happens after batch prep + flow = trace_flow_from_node(workflow, 'Prepare Update All Batch') + print(f"Current batch update flow: {' -> '.join(flow)}") + + # The current flow appears to be: + # Prepare Update All Batch -> Send Batch Start Message -> ... (Docker operations) + # We need to replace the Docker operations with a loop that calls the sub-workflow + + # However, looking at the node list, I notice "Prepare Update All Batch" is the only + # batch update node. This suggests the batch update might be handled differently. + # Let me check if there's already loop/split logic + + # Find all nodes that might be part of batch update + batch_nodes = [] + for node in workflow['nodes']: + if 'batch' in node['name'].lower() and 'update' in node['name'].lower(): + batch_nodes.append(node['name']) + + print(f"\nBatch update related nodes: {batch_nodes}") + + # Check if there's already a loop/split node in the flow + loop_found = False + for node_name in flow: + node = find_node_by_name(workflow, node_name) + if node and 'split' in node['type'].lower(): + loop_found = True + print(f"Found loop/split node: {node_name}") + + if not loop_found: + print("No loop node found - batch update may be using inline iteration") + + # Actually, based on the analysis, "Prepare Update All Batch" might already prepare + # data for individual updates. Let me check what Execute Sub-workflow nodes exist + print("\n=== Checking for existing Execute Workflow nodes ===") + exec_nodes = [] + for node in workflow['nodes']: + if node['type'] == 'n8n-nodes-base.executeWorkflow': + exec_nodes.append({ + 'name': node['name'], + 'workflow_id': node['parameters'].get('workflowId', {}).get('value', 'N/A') + }) + + print(f"Found {len(exec_nodes)} Execute Workflow nodes:") + for en in exec_nodes: + print(f" - {en['name']}: {en['workflow_id']}") + + # Save analysis for now + print("\nAnalysis complete. Next: implement refactoring...") + + # For now, let's save the workflow unchanged and return analysis + print(f"\nFinal node count: {len(workflow['nodes'])}") + + return workflow, exec_nodes, flow + +if __name__ == '__main__': + workflow, exec_nodes, flow = main() diff --git a/task1_batch_update.py b/task1_batch_update.py new file mode 100644 index 0000000..ad21ec9 --- /dev/null +++ b/task1_batch_update.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +Task 1: Wire batch update to Container Update sub-workflow +""" + +import json +import uuid + +# Workflow IDs from STATE.md +CONTAINER_UPDATE_WF_ID = "7AvTzLtKXM2hZTio92_mC" +CONTAINER_ACTIONS_WF_ID = "fYSZS5PkH0VSEaT5" + +def load_workflow(): + with open('n8n-workflow.json', 'r') as f: + return json.load(f) + +def save_workflow(workflow): + with open('n8n-workflow.json', 'w') as f: + json.dump(workflow, f, indent=2) + print(f"Saved workflow with {len(workflow['nodes'])} nodes") + +def find_node(workflow, name): + for node in workflow['nodes']: + if node['name'] == name: + return node + return None + +def create_execute_workflow_node(name, workflow_id, position): + """Create an Execute Workflow node with n8n 1.2 format""" + return { + "parameters": { + "workflowId": { + "__rl": True, + "mode": "list", + "value": workflow_id + }, + "options": {} + }, + "id": str(uuid.uuid4()), + "name": name, + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.2, + "position": position + } + +def create_code_node(name, code, position): + """Create a Code node""" + return { + "parameters": { + "jsCode": code + }, + "id": str(uuid.uuid4()), + "name": name, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": position + } + +def main(): + print("Loading workflow...") + workflow = load_workflow() + initial_count = len(workflow['nodes']) + print(f"Initial node count: {initial_count}") + + # Find Route Batch Loop Action + route_node = find_node(workflow, "Route Batch Loop Action") + if not route_node: + print("ERROR: Could not find Route Batch Loop Action") + return + + print(f"\nFound Route Batch Loop Action at {route_node['position']}") + + # The Route Batch Loop Action has 4 outputs: + # 0: update (currently empty) + # 1: start + # 2: stop + # 3: restart + + # We need to add nodes for the update path + # Position: Route is at [3760, -500], so place new nodes at x=4000+ + + # 1. Create "Prepare Batch Update Input" code node + # This prepares the input for the sub-workflow + prepare_code = '''// Prepare input for Container Update sub-workflow +const data = $json; +const container = data.container; + +// Extract container info +const containerId = container.id || container.Id || ''; +const containerName = container.name || container.Name || ''; + +return { + json: { + containerId: containerId, + containerName: containerName, + chatId: data.chatId, + messageId: data.progressMessageId || 0, + responseMode: "inline" + } +};''' + + prepare_node = create_code_node( + "Prepare Batch Update Input", + prepare_code, + [4000, -800] + ) + + # 2. Create "Execute Batch Update" execute workflow node + execute_node = create_execute_workflow_node( + "Execute Batch Update", + CONTAINER_UPDATE_WF_ID, + [4220, -800] + ) + + # 3. Create "Handle Batch Update Result" code node + # This processes the result from sub-workflow and prepares for next iteration + handle_code = '''// Handle update result from sub-workflow +const data = $('Build Progress Message').item.json; +const result = $json; + +// Update counters based on result +let successCount = data.successCount || 0; +let failureCount = data.failureCount || 0; +let warningCount = data.warningCount || 0; + +if (result.success) { + successCount++; +} else { + failureCount++; +} + +// Add to results array +const results = data.results || []; +results.push({ + container: data.containerName, + action: 'update', + success: result.success, + message: result.message || '' +}); + +return { + json: { + ...data, + successCount: successCount, + failureCount: failureCount, + warningCount: warningCount, + results: results + } +};''' + + handle_node = create_code_node( + "Handle Batch Update Result", + handle_code, + [4440, -800] + ) + + # Add nodes to workflow + print("\nAdding new nodes:") + print(f" - {prepare_node['name']}") + print(f" - {execute_node['name']}") + print(f" - {handle_node['name']}") + + workflow['nodes'].extend([prepare_node, execute_node, handle_node]) + + # Add connections + print("\nAdding connections:") + + # Route Batch Loop Action (output 0: update) -> Prepare Batch Update Input + if 'Route Batch Loop Action' not in workflow['connections']: + workflow['connections']['Route Batch Loop Action'] = {'main': [[], [], [], []]} + + workflow['connections']['Route Batch Loop Action']['main'][0] = [{ + "node": "Prepare Batch Update Input", + "type": "main", + "index": 0 + }] + print(" - Route Batch Loop Action [update] -> Prepare Batch Update Input") + + # Prepare Batch Update Input -> Execute Batch Update + workflow['connections']['Prepare Batch Update Input'] = { + 'main': [[{ + "node": "Execute Batch Update", + "type": "main", + "index": 0 + }]] + } + print(" - Prepare Batch Update Input -> Execute Batch Update") + + # Execute Batch Update -> Handle Batch Update Result + workflow['connections']['Execute Batch Update'] = { + 'main': [[{ + "node": "Handle Batch Update Result", + "type": "main", + "index": 0 + }]] + } + print(" - Execute Batch Update -> Handle Batch Update Result") + + # Handle Batch Update Result -> Prepare Next Iteration (same as other actions) + workflow['connections']['Handle Batch Update Result'] = { + 'main': [[{ + "node": "Prepare Next Iteration", + "type": "main", + "index": 0 + }]] + } + print(" - Handle Batch Update Result -> Prepare Next Iteration") + + # Save + final_count = len(workflow['nodes']) + print(f"\nNode count: {initial_count} -> {final_count} ({final_count - initial_count:+d})") + + save_workflow(workflow) + print("\n✓ Task 1 complete: Batch update now uses Container Update sub-workflow") + +if __name__ == '__main__': + main() diff --git a/task2_batch_actions.py b/task2_batch_actions.py new file mode 100644 index 0000000..bf8336e --- /dev/null +++ b/task2_batch_actions.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +Task 2: Wire batch actions to Container Actions sub-workflow +""" + +import json +import uuid + +CONTAINER_ACTIONS_WF_ID = "fYSZS5PkH0VSEaT5" + +def load_workflow(): + with open('n8n-workflow.json', 'r') as f: + return json.load(f) + +def save_workflow(workflow): + with open('n8n-workflow.json', 'w') as f: + json.dump(workflow, f, indent=2) + print(f"Saved workflow with {len(workflow['nodes'])} nodes") + +def find_node(workflow, name): + for node in workflow['nodes']: + if node['name'] == name: + return node + return None + +def remove_node(workflow, node_name): + """Remove a node and all its connections""" + # Remove from nodes list + workflow['nodes'] = [n for n in workflow['nodes'] if n['name'] != node_name] + + # Remove from connections (as source) + if node_name in workflow['connections']: + del workflow['connections'][node_name] + + # Remove from connections (as target) + for source, outputs in list(workflow['connections'].items()): + for output_key, connections in list(outputs.items()): + workflow['connections'][source][output_key] = [ + [conn for conn in conn_list if conn.get('node') != node_name] + for conn_list in connections + ] + +def create_execute_workflow_node(name, workflow_id, position): + """Create an Execute Workflow node with n8n 1.2 format""" + return { + "parameters": { + "workflowId": { + "__rl": True, + "mode": "list", + "value": workflow_id + }, + "options": {} + }, + "id": str(uuid.uuid4()), + "name": name, + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.2, + "position": position + } + +def create_code_node(name, code, position): + """Create a Code node""" + return { + "parameters": { + "jsCode": code + }, + "id": str(uuid.uuid4()), + "name": name, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": position + } + +def main(): + print("Loading workflow...") + workflow = load_workflow() + initial_count = len(workflow['nodes']) + print(f"Initial node count: {initial_count}") + + # Current batch action flow: + # Route Batch Loop Action (outputs 1,2,3) -> Build Batch Action Command -> + # Execute Batch Container Action -> Check Batch Action Result -> ... + + # We need to replace this with: + # Route Batch Loop Action -> Prepare Batch Action Input -> Execute Batch Action -> + # Handle Batch Action Result + + # 1. Create "Prepare Batch Action Input" code node + prepare_code = '''// Prepare input for Container Actions sub-workflow +const data = $json; +const container = data.container; +const action = data.action; + +// Extract container info +const containerId = container.id || container.Id || ''; +const containerName = container.name || container.Name || ''; + +return { + json: { + containerId: containerId, + containerName: containerName, + action: action, + chatId: data.chatId, + messageId: data.progressMessageId || 0, + responseMode: "inline" + } +};''' + + prepare_node = create_code_node( + "Prepare Batch Action Input", + prepare_code, + [4000, -200] + ) + + # 2. Create "Execute Batch Action" execute workflow node + execute_node = create_execute_workflow_node( + "Execute Batch Action Sub-workflow", + CONTAINER_ACTIONS_WF_ID, + [4220, -200] + ) + + # 3. Create "Handle Batch Action Result" code node + handle_code = '''// Handle action result from sub-workflow +const data = $('Build Progress Message').item.json; +const result = $json; + +// Update counters based on result +let successCount = data.successCount || 0; +let failureCount = data.failureCount || 0; +let warningCount = data.warningCount || 0; + +if (result.success) { + successCount++; +} else { + failureCount++; +} + +// Add to results array +const results = data.results || []; +results.push({ + container: data.containerName, + action: data.action, + success: result.success, + message: result.message || '' +}); + +return { + json: { + ...data, + successCount: successCount, + failureCount: failureCount, + warningCount: warningCount, + results: results + } +};''' + + handle_node = create_code_node( + "Handle Batch Action Result Sub", + handle_code, + [4440, -200] + ) + + # Add nodes to workflow + print("\nAdding new nodes:") + print(f" - {prepare_node['name']}") + print(f" - {execute_node['name']}") + print(f" - {handle_node['name']}") + + workflow['nodes'].extend([prepare_node, execute_node, handle_node]) + + # Update connections from Route Batch Loop Action + # Outputs 1, 2, 3 (start, stop, restart) should go to Prepare Batch Action Input + print("\nUpdating connections:") + + for i in [1, 2, 3]: + workflow['connections']['Route Batch Loop Action']['main'][i] = [{ + "node": "Prepare Batch Action Input", + "type": "main", + "index": 0 + }] + print(" - Route Batch Loop Action [start/stop/restart] -> Prepare Batch Action Input") + + # Add new connections + workflow['connections']['Prepare Batch Action Input'] = { + 'main': [[{ + "node": "Execute Batch Action Sub-workflow", + "type": "main", + "index": 0 + }]] + } + print(" - Prepare Batch Action Input -> Execute Batch Action Sub-workflow") + + workflow['connections']['Execute Batch Action Sub-workflow'] = { + 'main': [[{ + "node": "Handle Batch Action Result Sub", + "type": "main", + "index": 0 + }]] + } + print(" - Execute Batch Action Sub-workflow -> Handle Batch Action Result Sub") + + workflow['connections']['Handle Batch Action Result Sub'] = { + 'main': [[{ + "node": "Prepare Next Iteration", + "type": "main", + "index": 0 + }]] + } + print(" - Handle Batch Action Result Sub -> Prepare Next Iteration") + + # Now remove the old nodes that are no longer needed + print("\nRemoving obsolete nodes:") + nodes_to_remove = [ + "Build Batch Action Command", + "Execute Batch Container Action", + "Check Batch Action Result", + "Needs Action Call", + "Execute Batch Action 2", + "Parse Batch Action 2", + "Handle Action Result" + ] + + removed_count = 0 + for node_name in nodes_to_remove: + if find_node(workflow, node_name): + print(f" - Removing: {node_name}") + remove_node(workflow, node_name) + removed_count += 1 + + # Save + final_count = len(workflow['nodes']) + print(f"\nNode count: {initial_count} -> {final_count} ({final_count - initial_count:+d})") + print(f"Removed: {removed_count} nodes") + print(f"Added: 3 nodes") + print(f"Net change: {final_count - initial_count:+d} nodes") + + save_workflow(workflow) + print("\n✓ Task 2 complete: Batch actions now use Container Actions sub-workflow") + +if __name__ == '__main__': + main() diff --git a/task3_logs_subworkflow.py b/task3_logs_subworkflow.py new file mode 100644 index 0000000..7cf1d8f --- /dev/null +++ b/task3_logs_subworkflow.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +""" +Task 3: Extract logs flow to sub-workflow +""" + +import json +import uuid + +def create_logs_subworkflow(): + """Create n8n-container-logs.json sub-workflow""" + + # Create workflow structure + workflow = { + "name": "Container Logs", + "nodes": [], + "connections": {}, + "active": True, + "settings": { + "executionOrder": "v1" + }, + "versionId": str(uuid.uuid4()), + "meta": { + "instanceId": "unraid-docker-manager" + }, + "tags": [] + } + + # 1. Execute Workflow Trigger (entry point) + trigger = { + "parameters": {}, + "id": str(uuid.uuid4()), + "name": "Execute Workflow Trigger", + "type": "n8n-nodes-base.executeWorkflowTrigger", + "typeVersion": 1, + "position": [240, 300] + } + + # 2. Parse Input - validates and extracts input parameters + parse_input_code = '''// Parse and validate input +const input = $json; + +// Get container identifier (ID or name) +const containerId = input.containerId || ''; +const containerName = input.containerName || ''; +const lineCount = input.lineCount || 50; +const chatId = input.chatId; +const messageId = input.messageId || 0; +const responseMode = input.responseMode || 'text'; + +if (!containerId && !containerName) { + throw new Error('Either containerId or containerName required'); +} + +if (!chatId) { + throw new Error('chatId required'); +} + +return { + json: { + containerId: containerId, + containerName: containerName, + lineCount: Math.min(Math.max(parseInt(lineCount), 1), 1000), + chatId: chatId, + messageId: messageId, + responseMode: responseMode + } +};''' + + parse_input = { + "parameters": { + "jsCode": parse_input_code + }, + "id": str(uuid.uuid4()), + "name": "Parse Input", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [460, 300] + } + + # 3. Get Container ID (if only name provided) + get_container_code = '''// Get container ID if needed +const data = $json; + +// If we already have container ID, pass through +if (data.containerId) { + return { + json: { + ...data, + useDirectId: true + } + }; +} + +// Otherwise, need to query Docker to find by name +return { + json: { + ...data, + useDirectId: false, + dockerCommand: 'curl -s --max-time 5 "http://docker-socket-proxy:2375/v1.47/containers/json?all=1"' + } +};''' + + get_container = { + "parameters": { + "jsCode": get_container_code + }, + "id": str(uuid.uuid4()), + "name": "Check Container ID", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [680, 300] + } + + # 4. Route based on whether we need to query + route_node = { + "parameters": { + "rules": { + "values": [ + { + "id": "has-id", + "conditions": { + "options": { + "caseSensitive": True, + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "check-direct", + "leftValue": "={{ $json.useDirectId }}", + "rightValue": "true", + "operator": { + "type": "boolean", + "operation": "true" + } + } + ], + "combinator": "and" + }, + "renameOutput": True, + "outputKey": "direct" + } + ] + }, + "options": { + "fallbackOutput": "extra" + } + }, + "id": str(uuid.uuid4()), + "name": "Route ID Check", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.2, + "position": [900, 300] + } + + # 5. Query Docker (if needed) + query_docker = { + "parameters": { + "command": "={{ $json.dockerCommand }}" + }, + "id": str(uuid.uuid4()), + "name": "Query Docker", + "type": "n8n-nodes-base.executeCommand", + "typeVersion": 1, + "position": [1120, 400] + } + + # 6. Find Container by Name + find_container_code = '''// Find container by name +const dockerOutput = $input.item.json.stdout; +const data = $('Check Container ID').item.json; +const containerName = data.containerName.toLowerCase(); + +// Parse Docker response +let containers; +try { + containers = JSON.parse(dockerOutput); +} catch (e) { + throw new Error('Failed to parse Docker response'); +} + +// Normalize name function +function normalizeName(name) { + return name + .replace(/^\//, '') + .replace(/^(linuxserver[-_]|binhex[-_])/i, '') + .toLowerCase(); +} + +// Find exact match +const container = containers.find(c => normalizeName(c.Names[0]) === containerName); + +if (!container) { + throw new Error(`Container "${containerName}" not found`); +} + +return { + json: { + ...data, + containerId: container.Id, + containerName: normalizeName(container.Names[0]) + } +};''' + + find_container = { + "parameters": { + "jsCode": find_container_code + }, + "id": str(uuid.uuid4()), + "name": "Find Container", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1340, 400] + } + + # 7. Build Logs Command + build_command_code = '''// Build Docker logs command +const data = $json; +const containerId = data.containerId; +const lineCount = data.lineCount; + +const cmd = `curl -s --max-time 10 "http://docker-socket-proxy:2375/v1.47/containers/${containerId}/logs?stdout=1&stderr=1&tail=${lineCount}×tamps=1"`; + +return { + json: { + ...data, + logsCommand: cmd + } +};''' + + build_command = { + "parameters": { + "jsCode": build_command_code + }, + "id": str(uuid.uuid4()), + "name": "Build Logs Command", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1560, 300] + } + + # 8. Execute Logs Command + execute_logs = { + "parameters": { + "command": "={{ $json.logsCommand }}" + }, + "id": str(uuid.uuid4()), + "name": "Execute Logs", + "type": "n8n-nodes-base.executeCommand", + "typeVersion": 1, + "position": [1780, 300] + } + + # 9. Format Logs Output + format_logs_code = '''// Format logs output for Telegram +const rawOutput = $input.item.json.stdout || ''; +const data = $('Build Logs Command').item.json; + +// HTML escape function +function escapeHtml(text) { + return text.replace(/&/g, '&').replace(//g, '>'); +} + +// Handle empty logs +if (!rawOutput || rawOutput.trim() === '') { + return { + json: { + success: true, + message: `No logs available for ${data.containerName}`, + containerName: data.containerName, + lineCount: 0 + } + }; +} + +// Strip Docker binary headers and process lines +const lines = rawOutput.split('\\n') + .filter(line => line.length > 0) + .map(line => { + // Check if line starts with binary header (8-byte Docker stream header) + if (line.length > 8 && line.charCodeAt(0) <= 2) { + return line.substring(8); + } + return line; + }) + .join('\\n'); + +// Truncate for Telegram (4096 char limit, leave room for header) +const maxLen = 3800; +const truncated = lines.length > maxLen + ? lines.substring(0, maxLen) + '\\n... (truncated)' + : lines; + +// Escape HTML entities +const escaped = escapeHtml(truncated); + +const lineCount = lines.split('\\n').length; +const header = `Logs for ${data.containerName} (last ${lineCount} lines):\\n\\n`; +const formatted = header + '
' + escaped + '
'; + +return { + json: { + success: true, + message: formatted, + containerName: data.containerName, + lineCount: lineCount + } +};''' + + format_logs = { + "parameters": { + "jsCode": format_logs_code + }, + "id": str(uuid.uuid4()), + "name": "Format Logs", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [2000, 300] + } + + # Add all nodes + workflow['nodes'] = [ + trigger, + parse_input, + get_container, + route_node, + query_docker, + find_container, + build_command, + execute_logs, + format_logs + ] + + # Add connections + workflow['connections'] = { + "Execute Workflow Trigger": { + "main": [[{"node": "Parse Input", "type": "main", "index": 0}]] + }, + "Parse Input": { + "main": [[{"node": "Check Container ID", "type": "main", "index": 0}]] + }, + "Check Container ID": { + "main": [[{"node": "Route ID Check", "type": "main", "index": 0}]] + }, + "Route ID Check": { + "main": [ + [{"node": "Build Logs Command", "type": "main", "index": 0}], # direct path + [{"node": "Query Docker", "type": "main", "index": 0}] # query path + ] + }, + "Query Docker": { + "main": [[{"node": "Find Container", "type": "main", "index": 0}]] + }, + "Find Container": { + "main": [[{"node": "Build Logs Command", "type": "main", "index": 0}]] + }, + "Build Logs Command": { + "main": [[{"node": "Execute Logs", "type": "main", "index": 0}]] + }, + "Execute Logs": { + "main": [[{"node": "Format Logs", "type": "main", "index": 0}]] + } + } + + return workflow + +def save_logs_workflow(workflow): + with open('n8n-container-logs.json', 'w') as f: + json.dump(workflow, f, indent=2) + print(f"Created n8n-container-logs.json with {len(workflow['nodes'])} nodes") + +if __name__ == '__main__': + print("Creating Container Logs sub-workflow...") + workflow = create_logs_subworkflow() + save_logs_workflow(workflow) + print("✓ Container Logs sub-workflow created") diff --git a/task3_update_main.py b/task3_update_main.py new file mode 100644 index 0000000..82eafcc --- /dev/null +++ b/task3_update_main.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +""" +Task 3 Part 2: Update main workflow to use Container Logs sub-workflow +""" + +import json +import uuid + +# This will be the ID assigned when we import the workflow to n8n +# For now, use a placeholder - we'll need to update this after import +CONTAINER_LOGS_WF_ID = "PLACEHOLDER_LOGS_ID" + +def load_workflow(): + with open('n8n-workflow.json', 'r') as f: + return json.load(f) + +def save_workflow(workflow): + with open('n8n-workflow.json', 'w') as f: + json.dump(workflow, f, indent=2) + print(f"Saved workflow with {len(workflow['nodes'])} nodes") + +def find_node(workflow, name): + for node in workflow['nodes']: + if node['name'] == name: + return node + return None + +def remove_node(workflow, node_name): + """Remove a node and all its connections""" + workflow['nodes'] = [n for n in workflow['nodes'] if n['name'] != node_name] + + if node_name in workflow['connections']: + del workflow['connections'][node_name] + + for source, outputs in list(workflow['connections'].items()): + for output_key, connections in list(outputs.items()): + workflow['connections'][source][output_key] = [ + [conn for conn in conn_list if conn.get('node') != node_name] + for conn_list in connections + ] + +def create_code_node(name, code, position): + return { + "parameters": { + "jsCode": code + }, + "id": str(uuid.uuid4()), + "name": name, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": position + } + +def create_execute_workflow_node(name, workflow_id, position): + return { + "parameters": { + "workflowId": { + "__rl": True, + "mode": "list", + "value": workflow_id + }, + "options": {} + }, + "id": str(uuid.uuid4()), + "name": name, + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.2, + "position": position + } + +def main(): + print("Loading workflow...") + workflow = load_workflow() + initial_count = len(workflow['nodes']) + print(f"Initial node count: {initial_count}") + + # For TEXT logs command path: + # Current: Keyword Router -> Parse Logs Command -> Docker List for Logs -> + # Match Logs Container -> Check Logs Match Count -> (various paths) -> + # Build Logs Command -> Execute Logs -> Format Logs -> Send Logs Response + # + # New: Keyword Router -> Prepare Text Logs Input -> Execute Logs Sub-workflow -> + # Send Logs Response + + # For INLINE logs action path: + # Current: Prepare Logs Action -> Get Container For Logs -> Build Logs Action Command -> + # Execute Logs Action -> Format Logs Action Result -> Send Logs Result + # + # New: Prepare Logs Action -> Execute Logs Sub-workflow -> Send Logs Result + + # 1. Create "Prepare Text Logs Input" node + text_input_code = '''// Prepare input for Container Logs sub-workflow (text command) +const data = $json; + +// Check if there's an error from Parse Logs Command +if (data.error) { + return { + json: { + error: true, + chatId: data.chatId, + text: data.text + } + }; +} + +return { + json: { + containerName: data.containerQuery, + lineCount: data.lines, + chatId: data.chatId, + messageId: data.messageId || 0, + responseMode: "text" + } +};''' + + text_input_node = create_code_node( + "Prepare Text Logs Input", + text_input_code, + [1120, 600] + ) + + # 2. Create "Execute Text Logs" sub-workflow node + exec_text_logs = create_execute_workflow_node( + "Execute Text Logs", + CONTAINER_LOGS_WF_ID, + [1340, 600] + ) + + # 3. Create "Prepare Inline Logs Input" node (renamed from Prepare Logs Action) + inline_input_code = '''// Prepare input for Container Logs sub-workflow (inline action) +const data = $('Parse Callback Data').item.json; + +return { + json: { + containerName: data.containerName, + lineCount: 30, + chatId: data.chatId, + messageId: data.messageId, + responseMode: "inline" + } +};''' + + inline_input_node = create_code_node( + "Prepare Inline Logs Input", + inline_input_code, + [1780, 1300] + ) + + # 4. Create "Execute Inline Logs" sub-workflow node + exec_inline_logs = create_execute_workflow_node( + "Execute Inline Logs", + CONTAINER_LOGS_WF_ID, + [2000, 1300] + ) + + # 5. Create "Format Inline Logs Result" - adds keyboard for inline + inline_format_code = '''// Format logs result for inline keyboard display +const result = $json; +const data = $('Prepare Inline Logs Input').item.json; + +// Get container state (need to fetch from Docker) +// For now, build basic keyboard +const containerName = result.containerName; + +// Build inline keyboard +const keyboard = [ + [ + { text: '🔄 Refresh Logs', callback_data: `action:logs:${containerName}` }, + { text: '⬆️ Update', callback_data: `action:update:${containerName}` } + ], + [ + { text: '◀️ Back to List', callback_data: 'list:0' } + ] +]; + +return { + json: { + chatId: data.chatId, + messageId: data.messageId, + text: result.message, + reply_markup: { inline_keyboard: keyboard } + } +};''' + + inline_format_node = create_code_node( + "Format Inline Logs Result", + inline_format_code, + [2220, 1300] + ) + + # Add new nodes + print("\nAdding new nodes:") + workflow['nodes'].extend([ + text_input_node, + exec_text_logs, + inline_input_node, + exec_inline_logs, + inline_format_node + ]) + print(f" - {text_input_node['name']}") + print(f" - {exec_text_logs['name']}") + print(f" - {inline_input_node['name']}") + print(f" - {exec_inline_logs['name']}") + print(f" - {inline_format_node['name']}") + + # Update connections + print("\nUpdating connections:") + + # Text path: Keyword Router -> Parse Logs Command -> Prepare Text Logs Input + # (Keep Parse Logs Command for error handling) + workflow['connections']['Parse Logs Command'] = { + 'main': [[{ + "node": "Prepare Text Logs Input", + "type": "main", + "index": 0 + }]] + } + print(" - Parse Logs Command -> Prepare Text Logs Input") + + # Prepare Text Logs Input -> Execute Text Logs + workflow['connections']['Prepare Text Logs Input'] = { + 'main': [[{ + "node": "Execute Text Logs", + "type": "main", + "index": 0 + }]] + } + print(" - Prepare Text Logs Input -> Execute Text Logs") + + # Execute Text Logs -> Send Logs Response + workflow['connections']['Execute Text Logs'] = { + 'main': [[{ + "node": "Send Logs Response", + "type": "main", + "index": 0 + }]] + } + print(" - Execute Text Logs -> Send Logs Response") + + # Update Send Logs Response to use result.message + send_logs_node = find_node(workflow, "Send Logs Response") + if send_logs_node and 'parameters' in send_logs_node: + send_logs_node['parameters']['text'] = "={{ $json.message }}" + + # Inline path: Action Router -> Prepare Inline Logs Input + # Find what routes to logs action + for source, outputs in workflow['connections'].items(): + for output_key, connections in outputs.items(): + for i, conn_list in enumerate(connections): + for j, conn in enumerate(conn_list): + if conn.get('node') == 'Prepare Logs Action': + workflow['connections'][source][output_key][i][j]['node'] = 'Prepare Inline Logs Input' + print(f" - {source} -> Prepare Inline Logs Input (was Prepare Logs Action)") + + # Prepare Inline Logs Input -> Execute Inline Logs + workflow['connections']['Prepare Inline Logs Input'] = { + 'main': [[{ + "node": "Execute Inline Logs", + "type": "main", + "index": 0 + }]] + } + print(" - Prepare Inline Logs Input -> Execute Inline Logs") + + # Execute Inline Logs -> Format Inline Logs Result + workflow['connections']['Execute Inline Logs'] = { + 'main': [[{ + "node": "Format Inline Logs Result", + "type": "main", + "index": 0 + }]] + } + print(" - Execute Inline Logs -> Format Inline Logs Result") + + # Format Inline Logs Result -> Send Logs Result + workflow['connections']['Format Inline Logs Result'] = { + 'main': [[{ + "node": "Send Logs Result", + "type": "main", + "index": 0 + }]] + } + print(" - Format Inline Logs Result -> Send Logs Result") + + # Remove obsolete nodes + print("\nRemoving obsolete nodes:") + nodes_to_remove = [ + "Docker List for Logs", + "Match Logs Container", + "Check Logs Match Count", + "Build Logs Command", + "Execute Logs", + "Format Logs", + "Send Logs Error", + "Format Logs No Match", + "Format Logs Multiple", + "Prepare Logs Action", + "Get Container For Logs", + "Build Logs Action Command", + "Execute Logs Action", + "Format Logs Action Result" + ] + + removed_count = 0 + for node_name in nodes_to_remove: + if find_node(workflow, node_name): + print(f" - Removing: {node_name}") + remove_node(workflow, node_name) + removed_count += 1 + + # Keep Parse Logs Command for initial parsing and error handling + + # Save + final_count = len(workflow['nodes']) + print(f"\nNode count: {initial_count} -> {final_count} ({final_count - initial_count:+d})") + print(f"Removed: {removed_count} nodes") + print(f"Added: 5 nodes") + print(f"Net change: {final_count - initial_count:+d} nodes") + + save_workflow(workflow) + print("\n✓ Task 3 complete: Logs flow now uses Container Logs sub-workflow") + print("\nNOTE: You must import n8n-container-logs.json to n8n and update") + print(" the CONTAINER_LOGS_WF_ID in this script, then re-run.") + +if __name__ == '__main__': + main() diff --git a/task4_cleanup.py b/task4_cleanup.py new file mode 100644 index 0000000..5146fc8 --- /dev/null +++ b/task4_cleanup.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +Task 4: Clean up and verify workflow +""" + +import json + +def load_workflow(): + with open('n8n-workflow.json', 'r') as f: + return json.load(f) + +def save_workflow(workflow): + with open('n8n-workflow.json', 'w') as f: + json.dump(workflow, f, indent=2) + print(f"Saved workflow with {len(workflow['nodes'])} nodes") + +def find_orphaned_nodes(workflow): + """Find nodes that are not connected to anything""" + connected_nodes = set() + + # Add all nodes that are targets of connections + for source, outputs in workflow['connections'].items(): + connected_nodes.add(source) # Source is connected + for output_key, connections in outputs.items(): + for conn_list in connections: + for conn in conn_list: + if 'node' in conn: + connected_nodes.add(conn['node']) + + # Find trigger node (should always be connected conceptually) + for node in workflow['nodes']: + if 'trigger' in node['type'].lower(): + connected_nodes.add(node['name']) + + # Find nodes that exist but aren't connected + all_nodes = {node['name'] for node in workflow['nodes']} + orphaned = all_nodes - connected_nodes + + return orphaned + +def verify_workflow_structure(workflow): + """Verify workflow has proper structure""" + issues = [] + + # Check for trigger + has_trigger = any('trigger' in node['type'].lower() for node in workflow['nodes']) + if not has_trigger: + issues.append("WARNING: No trigger node found") + + # Check for broken connections (references to non-existent nodes) + all_node_names = {node['name'] for node in workflow['nodes']} + for source, outputs in workflow['connections'].items(): + if source not in all_node_names: + issues.append(f"ERROR: Connection source '{source}' does not exist") + + for output_key, connections in outputs.items(): + for conn_list in connections: + for conn in conn_list: + target = conn.get('node') + if target and target not in all_node_names: + issues.append(f"ERROR: Connection target '{target}' (from {source}) does not exist") + + return issues + +def analyze_node_types(workflow): + """Count nodes by type""" + type_counts = {} + for node in workflow['nodes']: + node_type = node['type'] + type_counts[node_type] = type_counts.get(node_type, 0) + 1 + return type_counts + +def main(): + print("Loading workflow...") + workflow = load_workflow() + initial_count = len(workflow['nodes']) + print(f"Current node count: {initial_count}") + + # Find orphaned nodes + print("\n=== Checking for orphaned nodes ===") + orphaned = find_orphaned_nodes(workflow) + if orphaned: + print(f"Found {len(orphaned)} orphaned nodes:") + for node in orphaned: + print(f" - {node}") + + # Option to remove orphaned nodes + print("\nRemoving orphaned nodes...") + workflow['nodes'] = [n for n in workflow['nodes'] if n['name'] not in orphaned] + + # Clean up any connections from orphaned nodes + for node in orphaned: + if node in workflow['connections']: + del workflow['connections'][node] + + removed_count = len(orphaned) + else: + print("No orphaned nodes found ✓") + removed_count = 0 + + # Verify structure + print("\n=== Verifying workflow structure ===") + issues = verify_workflow_structure(workflow) + if issues: + print("Issues found:") + for issue in issues: + print(f" - {issue}") + else: + print("Workflow structure is valid ✓") + + # Analyze node types + print("\n=== Node composition ===") + type_counts = analyze_node_types(workflow) + for node_type, count in sorted(type_counts.items(), key=lambda x: -x[1]): + short_type = node_type.replace('n8n-nodes-base.', '') + print(f" {count:3d} {short_type}") + + # Count Execute Workflow nodes (sub-workflow calls) + exec_wf_count = type_counts.get('n8n-nodes-base.executeWorkflow', 0) + print(f"\n Total Execute Workflow (sub-workflow calls): {exec_wf_count}") + + # Save if we made changes + final_count = len(workflow['nodes']) + if removed_count > 0: + print(f"\nNode count: {initial_count} -> {final_count} ({final_count - initial_count:+d})") + save_workflow(workflow) + else: + print(f"\nNo changes made. Final node count: {final_count}") + + # Check target + print("\n=== Target Assessment ===") + target_min = 120 + target_max = 150 + if target_min <= final_count <= target_max: + print(f"✓ Node count {final_count} is within target range ({target_min}-{target_max})") + elif final_count < target_min: + print(f"✓ Node count {final_count} is BELOW target (even better!)") + else: + print(f"⚠ Node count {final_count} is above target range ({target_min}-{target_max})") + print(f" Over target by: {final_count - target_max} nodes") + + print("\n✓ Task 4 cleanup complete") + +if __name__ == '__main__': + main()