--- name: cw-triage-workflow description: Use for ConnectWise triage n8n workflow builds. --- # ConnectWise Triage Workflow (n8n SDK) Context: SOLE live copy at the WORK n8n instance — workflow `` ('ConnectWise Triage v3.0 - LLM Tiers'; project `` 'Tech Services', CW credential `` 'ConnectWise PSA account', audit table `` cw_triage_audit, verified exec pinned). Lookup ids at runtime; never hardcode from history. 2026-10-07: a former live workflow id was found ARCHIVED; the current one is the only accessible/non-archived triage workflow on the work server — run that one. It publishes modelVersion v3.1-llm-observe. Home-lab copies all archived 2026-10-05 (v2.2 + v1 ids + 4 scratch) — the home lab is no longer authoritative for anything triage-related. Notes post as member `n8nvni` on work, `SBrown` on home lab. Related: `connectwise-n8n-automation` for CW API patterns. ## Running it (verified 2026-10-19, exec pinned) - **`execute_workflow(executionMode:'production')` FAILS — workflow has no published version.** Run it with `executionMode:'manual'` (current version, no inputs; single manual trigger). Manual runs ARE real runs — notes/audit rows write live. For a scheduled future it needs publish_workflow first. - **MCP server gotcha: the default n8n MCP server does NOT have this workflow** (`execute_workflow` returns 'Workflow not found'). Run it via the `n8n_work` server (mcp__n8n_work__*) — search_workflows confirms it there. - Typical full-pipe runtime ~8–65s (T1-only runs ~8–20s; a T3 security-review pass adds ~10s). Wait ~25–45s before the first status poll — polling a fresh run immediately returns 'running' every time and burns a round-trip. Recent manual runs: 1197 (7.7s T1-only, 3 posted), 1199 (19s incl. T3, 1 posted / 2 deduped), 1200 (8s T1-only, 3 posted). ## Proven SDK patterns (all verified live 2026-10-04) - CW fetch (custom getAll): node type `@mspcopilot/n8n-nodes-connectwise.connectWisePsa` v1, `resource: 'custom'`, `operation: 'getAll'`, `endpoint: '/service/tickets'`, `conditions: "closedFlag = false AND board/name = \"VistaNet Help Desk\""`, `orderBy: 'lastUpdated desc'`, `limit`, `fields: 'id,summary,status,name,company/name,board/name,_info'`. Credential ref: `credentials: { connectWisePsaApi: { id: '', name: 'ConnectWise PSA account' } }` (resolve the real id from the work n8n at runtime). - CW ticketNote create: same connector, `resource: 'ticketNote'`, `operation: 'create'`, `ticketType: 'service'`, `parentId: expr('{{ $json.ticketId }}')`, `text: expr("{{ $json.note }}")`, `internalAnalysisFlag: true`, `processNotifications: false` → posts an INTERNAL note (member = credential's member, e.g. SBrown).. - Audit dedupe: `n8n-nodes-base.dataTable` v1.1, `resource: 'row'`, `operation: 'rowNotExists'`, `dataTableId: { __rl: true, mode: 'name', value: 'cw_triage_audit' }`, `matchType: 'allConditions'`, filters conditions with `keyName/condition/keyValue` + `expr('…')`. Works — correctly suppresses both items when rows exist, passes both when table empty. - Table insert: same node, `operation: 'insert'`, `columns: { mappingMode: 'defineBelow', value: {...}, schema: [...] }`. Table must EXIST before create_workflow_from_code (build it with create_data_table; needs projectId; row-existence ops accept numeric keys for number columns). - Cross-node field refs: after a write node (note create), later nodes must read `$('Build Triage Draft').item.json.X`, not `$json.X` — validator warns on `$json.*` passthrough of write-node outputs. - Testing: `test_workflow` requires pinData. Pin ONLY the manual trigger (`'On Test': [{ json: {} }]`) to force every downstream node to execute for real. Pinning a data node (fetch) replaces its live output with fakes — notes/rows still write to real targets, so real tickets got stand-in text once; avoid. - Verify with `get_workflow_execution(workflowId, executionId, includeData: true)` for each runData node, then `get_data_table_rows(dataTableId, projectId)` read-back — never trust node status alone. - Full-graph `includeData` responses spill (>140KB) to a persisted-output file — parse the local file with Python instead of re-requesting the API. runData REPEATS node names once per parallel LLM fan-out item (e.g. 'T1 Primary' ×3); dedupe node names when summarizing what ran. - Filter the audit read-back on `ticketId >= ` AND `modelVersion = ` — one query separates new rows from prior-run suppressions, and every suppression should have a real row behind it. Pull per-ticket detail (notes read-back / note nodes) before summarizing: the audit row alone won't surface suggested assignees or T3 narratives. ## Scope gate (Scott, 2026-10-04) - Triage ONLY tickets with status `New` or `Tier 3 Assigned` on VistaNet Help Desk. Fetch conditions: `(status/name = 'New' OR status/name = 'Tier 3 Assigned') AND closedFlag = false AND board/name = 'VistaNet Help Desk'`. CW conditions accept single-quoted string literals; parens group OR before AND. - Verified exec pinned: In Progress tickets no longer enter the pipe; dedupe suppressed already-triaged tickets when limit was raised to 4, passing the next 2 in-scope through (matching audit rows + notes). - RESOLVED (2026-10-07, live in the sole triage workflow): TinMan auto-created tickets are EXCLUDED at fetch — conditions include `AND enteredBy != 'TinMan'`. Do not remove. ## v2.2 notes-aware classification (archived home-lab workflow, verified exec pinned) - Fetch last 10 notes per ticket via `endpoint: expr("{{ '/service/tickets/' + $json.id + '/notes' }}")`, `limit: 10, orderBy: 'id', descending: true`. Notes carry their own `ticketId` field — group on that in the Code node; `pairedItem` indexing is unreliable for ticket→notes fan-out. - Filter notes containing '[TinMan' (own notes) before classifying — otherwise the classifier reads its own drafts and dedupe churns. - Score summary and notes separately; note-sourced evidence beats summary-sourced (note-anchored → confidence 0.9, summary-only → 0.8, no cue → 'general' 0.55). Evidence line cites the note id, e.g. `note : "Upgrade Exchange Server to 2019 SE..."`. - On-prem vs M365 Exchange disambiguation lives in note content, not the summary line — summary 'Exchange Server' is ambiguous, note 'Upgrade Exchange Server to 2019 SE and migrate mailboxes' is on-prem. Regexes: ONPREM `/exchange (server|20\d\d|se\b)|on-?prem(ise)?s? exchange|exchange hybrid|mailbox migration|public folder/i` BEFORE M365 cues. - Audit table gained `classificationBasis` column (string); modelVersion `v2.1-observe` scopes dedupe separately from v1 — re-runs of the same version are suppressed, new versions post again. - Validator still warns INVALID_EXPRESSION_PATH on Code-node output fields (can't infer Code outputs) — benign, runtime proves it; don't chase it. ## v1 observe-mode contract - Notes ONLY (internalAnalysisFlag) + one audit row per ticket on the `` cw_triage_audit table. No board/status/routing writes. Note text always starts `[TinMan v1 OBSERVE]`. - Deterministic stand-in classifier (regex tripwire + keyword stage-1) is inside `Build Triage Draft` Code node — LLM tiers (qwen3:4b local / glm-5.3-flash cloud / kimi-k2.6 cloud security path, 5 confidence gates) wire in AFTER the pipe is proven. - Production switch = remove id filter, add 15-min schedule trigger, then gate flips per Scott's go. - Audit table columns: ticketId, ticketSummary, ticketStatus, stage1, confidence, tripwireHit, tripwireReason, team, notePosted, modelVersion, testedAt. ## Note prefix: Board Ops (2026-10-09 rebrand, verified exec pinned) - Note tag is `[Board Ops v3 OBSERVED]` (was `[TinMan v3 OBSERVE]`). CW member `n8nvni` display name IS "Board Ops", so notes appear in CW posted by Board Ops — matches the tag. - Self-note skip in Prepare Triage Items must be a DUAL-PREFIX regex: `const SELF_NOTE_RX = /[\\[]TinMan|[\\[]Board Ops/;` — old TinMan notes still exist on tickets and must stay excluded. A rename without widening the filter would feed own notes to the classifier. - Fetch condition `enteredBy != 'TinMan'` stays — that's the CW member identity, not note text. - Renames require a modelVersion bump (dedupe keys on ticketId+modelVersion): live modelVersion is now `v3.2-boardops-observe`. Matching audit rows + notes posted under it; old v3.1 notes left in place on the affected tickets. - Safe edits of long one-line jsCode: push via setNodeParameter with `\\b` etc. typed raw (matches how API serializes regexes); always read back full workflow and diff substrings before trusting it — reconstructing jsCode from chat memory drifts (lost a `.filter(Boolean)` once). ## v3.0 LLM cascade (test + live pair, 2026-10-05) - Tier map (proven exec pinned): **T1 = gpt-oss:20b via Ollama Cloud** (`lmChatOllama` v1, model `gpt-oss:20b`, options num_predict 350 / temperature 0.2, credential ``) — 3.3 s/ticket on the real T1 prompt, clean minified JSON. T2 = glm-5.3-flash, T3 = kimi-k2.6 (both cloud, same credential). Local qwen3:4b on a `` host = BACKUP chain only (multi-minute latency on M4000, unusable as primary). - Ollama Cloud catalog (verified by probe 1102-1107): tiny models DO NOT exist — qwen3:1.7b/0.6b, gemma3:1b all 404. Smallest is gpt-oss:20b (13.8 GB MoE); next nemotron-3-nano:30b measured 26.5 s — 8× slower. Don't re-probe. - Cascade: T1 → Evaluate → cloud-fail/parse-empty ⇒ 'Use Local T1?' IF (boolean expr `$json.localFallback`) → local qwen3:4b backup chain → Fold T1 Backup → Merge append → Needs T2 (conf<0.5/parse fail) → glm → Final Classification → Needs T3 (tripwire|security|urgency≥5) → kimi → Build Triage Draft → rowNotExists dedupe → CW note → audit row. - CRITICAL n8n chain-node pairing rule: `$('Node').item` after parallel LLM fan-in mis-pairs ALL items onto item #1 (exec pinned: 6 identical notes). ALWAYS zip by pairedItem: `const idx = row.pairedItem && typeof row.pairedItem.item === 'number' ? row.pairedItem.item : i; const base = $('Source').all(TREE_OUTPUT_INDEX)[idx].json;` — pass the branch output index to `.all(0/1)` for IF branches. - IF v2.2 string-equals conditions silently match NOTHING in some SDK-built nodes (exec pinned: routed all 10 items to false branch). Use boolean-expression conditions: leftValue `={{ $json.x === 'val' }}`, operator boolean/true. - Cleanup of CW notes: connector NATIVE delete (`resource: 'ticketNote', operation: 'delete', ticketType: 'service', parentId + id`) works; custom endpoint DELETE `/service/tickets/{id}/notes/{noteId}` 404s. Data table row delete: op name is `deleteRows` (NOT delete), filters matchType allConditions per keyName/condition/keyValue; no MCP row-delete tool exists — use a workflow. - Pinned test runs write REAL audit rows for FAKE pinned tickets — delete them after (deleteRows in a throwaway, then archive both probe workflows). A pinned-test note for fake tickets 999887/999888 was pinned (no CW write) but its audit row (fake ticket 999888) had to be removed. ## Suggested-assignee heuristic (v3.0, exec pinned) - CW member skills ARE populated (2026-10-04; six members carry skills — counts 3-11 each, roster in a local skills dump — while 15 members are skillless integration/admin accounts). Eligible engineering bench for suggestions: the four skill-carrying engineering members. - Build Triage Draft keyword-matches ticketSummary+llmSummary against BENCH regexes (one member per specialty class: sophos/firewall/vpn/network; veeam/backup/vmware/exchange server; desktop/printer/wireless/phones/m365; workstation hw/peripherals) and appends `Suggested assignee (skills heuristic, NOT an assignment): (matched: )` or falls back to team default. suggestedOwners array rides the item for future routing. - When CW skills change, edit the BENCH array in Build Triage Draft jsCode - static on purpose (no per-ticket CW skills API calls). ## Observe-dedupe re-escalation gap (2026-10-27, exec pinned) - Dedupe keys on `ticketId + modelVersion` and suppresses EVERYTHING already triaged under the live version — including tickets whose content escalated since their note was posted. A ticket re-scored as security-risk/urgency-5 on a later run posts no new note, so the on-ticket record can be stale and materially wrong while the run looks clean. - Run classification still processes suppressed tickets fully (fan-out through T2/T3); suppression happens only at the 'Not Yet Triaged?' node. A materially-changed re-score (tier up, new security verdict, urgency ≥4) is an escalation signal to surface to Scott with the T3 narrative — not noise. - Dedupe-suppressed ≠ silent failure. Confirm via the audit read-back filtered on fetched ticketIds + live modelVersion before claiming either: row exists → by-design suppression (report suppressed IDs with their prior triage from the old row); row missing → bug. - Force a fresh note on ONE ticket: deleteRows its audit row via a throwaway workflow, then re-execute — dedupe then passes only that ticket. Do NOT bump modelVersion to re-note a single ticket: the next run re-notes every already-triaged in-scope ticket. ## Lessons - Memored ticket IDs (119222, 121765) were stale (both `>Closed` by 2026-10-04) fetched as `id = X OR id = Y` successfully; for live tests use `closedFlag = false` + orderBy — never recall 'active tickets' from memory. - `create_workflow_from_code` takes `name` / `projectId` as TOOL args at top level (not `workflowName`); workflowName comes from first arg of workflow() in code. - One-off synthetic-tripwire tests belong to scratch workflows; delete them after verification so only the observe-test skeleton remains.