Files
zeus-skills/ops/connectwise/cw-triage-workflow/SKILL.md
T

85 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 `<liveWorkflowId>` ('ConnectWise Triage v3.0 - LLM Tiers'; project `<projectId>` 'Tech Services', CW credential `<cwCredentialId>` 'ConnectWise PSA account', audit table `<auditTableId>` 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: '<cwCredentialId>', 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 >= <lowest fetched id>` AND `modelVersion = <live version>` — 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 <noteId>: "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 `<auditTableId>` 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 `<n8nCredentialId>`) — 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 `<lan-ip>` 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): <ID> (matched: <kw>)` 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.