14 KiB
name, description
| name | description |
|---|---|
| cw-triage-workflow | 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 withexecutionMode:'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_workflowreturns 'Workflow not found'). Run it via then8n_workserver (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.connectWisePsav1,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.dataTablev1.1,resource: 'row',operation: 'rowNotExists',dataTableId: { __rl: true, mode: 'name', value: 'cw_triage_audit' },matchType: 'allConditions', filters conditions withkeyName/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_workflowrequires 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, thenget_data_table_rows(dataTableId, projectId)read-back — never trust node status alone. - Full-graph
includeDataresponses 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>ANDmodelVersion = <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
NeworTier 3 Assignedon 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 ownticketIdfield — group on that in the Code node;pairedItemindexing 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/iBEFORE M365 cues. - Audit table gained
classificationBasiscolumn (string); modelVersionv2.1-observescopes 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 DraftCode 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 membern8nvnidisplay 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
\\betc. 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 (
lmChatOllamav1, modelgpt-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').itemafter 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 isdeleteRows(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 + modelVersionand 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
>Closedby 2026-10-04) fetched asid = X OR id = Ysuccessfully; for live tests useclosedFlag = false+ orderBy — never recall 'active tickets' from memory. create_workflow_from_codetakesname/projectIdas TOOL args at top level (notworkflowName); 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.