Files

14 KiB
Raw Permalink Blame History

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 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.