@zibby/workflow-templates 0.4.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -53,8 +53,7 @@ export const TEMPLATES = {
53
53
  'Composition: character centered, slight forward lean, dynamic motion lines. Mood: cheerful, playful, fast.',
54
54
  'NO text, NO browser UI screenshots, NO outline wireframes.',
55
55
  ].join('\n'),
56
- category: 'Testing',
57
- tags: ['testing', 'playwright', 'e2e', 'browser'],
56
+ tags: ['Testing'],
58
57
  capabilities: [
59
58
  'Preflight LLM extracts assertions from a plain-English spec',
60
59
  'Live Playwright execution with screenshots + DOM at every step',
@@ -106,8 +105,7 @@ export const TEMPLATES = {
106
105
  'Composition: brackets centered, subtle drop shadow on the canvas. Mood: high-end, technical, confident — like the cover of a developer tool launch.',
107
106
  'NO text, NO outline wireframes, NO flat sticker style — this one is DEEP and 3D-rendered.',
108
107
  ].join('\n'),
109
- category: 'Engineering',
110
- tags: ['code-analysis', 'jira', 'github', 'test-generation'],
108
+ tags: ['Code Review', 'Testing'],
111
109
  capabilities: [
112
110
  'Clone repos + snapshot git baseline so changes are diff-able',
113
111
  'LLM gate: skip code-gen when ticket can\'t be implemented as-is',
@@ -150,8 +148,7 @@ export const TEMPLATES = {
150
148
  'Mood: friendly, approachable, slightly handmade. Like a children\'s book illustration applied to a developer tool.',
151
149
  'NO text, NO photo-realism, NO sleek 3D render — this one is hand-drawn and warm.',
152
150
  ].join('\n'),
153
- category: 'Testing',
154
- tags: ['testing', 'test-generation', 'pull-request', 'review'],
151
+ tags: ['Code Review', 'Testing'],
155
152
  capabilities: [
156
153
  'Skips ticket analysis — feed it the diff directly',
157
154
  'LLM explores the codebase to ground test steps in real components',
@@ -195,8 +192,7 @@ export const TEMPLATES = {
195
192
  'Mood: focused, energetic, signal-not-noise — the canonical Slack-flavored notification worker.',
196
193
  'NO text, NO letters, NO photo-realism, NO sleek 3D render, NO literal Slack wordmark — the colored pinwheel shape is allowed as the brand reference.',
197
194
  ].join('\n'),
198
- category: 'Notifications',
199
- tags: ['slack', 'notification', 'alert', 'child-workflow'],
195
+ tags: ['Notifications', 'child-workflow'],
200
196
  capabilities: [
201
197
  'Severity-coded Block Kit message (low/medium/high/critical)',
202
198
  'Code snippet + action button + caller mentions',
@@ -239,8 +235,7 @@ export const TEMPLATES = {
239
235
  'Mood: focused, professional, signal-not-noise.',
240
236
  'NO Lark / Feishu logo trademark, NO text, NO photo-realism.',
241
237
  ].join('\n'),
242
- category: 'Notifications',
243
- tags: ['lark', 'feishu', 'notification', 'alert', 'child-workflow'],
238
+ tags: ['Notifications', 'child-workflow'],
244
239
  capabilities: [
245
240
  'Severity-coded Lark Interactive Card',
246
241
  'Auto-detects receive_id_type from id prefix (chat_id / open_id / email)',
@@ -279,16 +274,14 @@ export const TEMPLATES = {
279
274
  slug: 'notify-notion',
280
275
  tagline: 'Reusable Notion archiver — durable record for any workflow.',
281
276
  iconPrompt: [
282
- 'Hand-painted gouache illustration with soft brushwork and gentle painterly texture, in the same family as the sentry-triage and generate-test-cases marketplace icons but with its own distinct character.',
283
- 'Subject: a friendly anthropomorphic notebook-document mascot a small rounded notebook character with two big smiling eyes and a rosy blush, its open pages showing three painted horizontal ink-lines and a tiny checkmark in the corner. A soft halo of two or three little pastel page-flutter sparkles dance around it, suggesting a freshly-written entry being archived.',
284
- 'Background: a pale neutral Notion-flavored off-white gradient warm cream at the top blending into a soft dove-grey at the base (#F7F3EC #E8E4DA), with a single faint paper-grain texture and a couple of small floating pastel ink-spot dots for friendliness.',
285
- 'Centered composition with the notebook character as the focal point in the lower-center, sparkles arcing across the upper third; plenty of breathing room so the silhouette reads at 64×64 in the marketplace grid.',
286
- 'Mood is calm, archival, gently studious the friendly notebook companion that keeps a tidy record, NOT corporate productivity or wall-of-text database.',
287
- 'Soft rounded square 1024×1024 canvas with a subtle paper-grain texture.',
288
- 'NO text, NO logo or trademarked marks, NO photo-realism, NO sleek 3D render, NO literal Notion trademark.',
277
+ 'A premium, hi-fi app icon for "Notify Notion" a workflow node that publishes reports to a Notion database. The real Notion brand mark will be composited on top in a post-process step; this prompt generates the BACKGROUND ONLY, with a clear empty area for the overlay.',
278
+ 'Visual style: 3D-rendered hero object floating in space, in the style of Apple Vision Pro icons, Linear\'s changelog hero illustrations, or a Stripe product render. Glossy, dimensional, with subtle reflections and a soft rim-light. Same family as the code-analysis marketplace icon.',
279
+ 'Subject: a single 3D-rendered page-document hero object made of glossy frosted glass / brushed silver metal, captured in head-on or near-head-on perspective (NOT three-quarter — keep the page face flat to the camera so the logo overlay sits cleanly). The page surface is COMPLETELY EMPTY — no lines, no text, no icons, no markings of any kind. The right edge curls slightly forward like a fresh page being filed, but the front face stays clean. A tiny cyan-teal glow accent sits in the upper-right corner of the page as a "freshly archived" signal.',
280
+ 'Background: a deep midnight-navy gradient (#0F172A at the top, #1E1B4B at the bottom), with a single soft cyan glow behind the page and a few faint star-like specks scattered across the canvas. Square format, 1024×1024.',
281
+ 'Composition: page centered, page face takes ~60-70% of the canvas and is empty/blank so a logo overlay can sit naturally on its surface. Subtle drop shadow. Mood: high-end, durable, archival — premium devtool aesthetic.',
282
+ 'CRITICAL: the page front face must be COMPLETELY CLEAN AND EMPTY. NO N, NO letters, NO horizontal text lines, NO checkmark, NO icons, NO embossing, NO etching, NO decoration on the page surface. Just blank glossy material. NO Notion logo, NO Notion wordmark, NO trademarked marks. NO text anywhere. NO outline wireframes, NO flat sticker style, NO mascot, NO smiling face, NO cartoon. The page surface MUST be empty so a real logo PNG can be composited on it.',
289
283
  ].join('\n'),
290
- category: 'Operations',
291
- tags: ['notion', 'docs', 'reporting', 'knowledge-base', 'archive'],
284
+ tags: ['Notifications', 'Docs', 'Reports'],
292
285
  capabilities: [
293
286
  'Create a new page in a Notion database (POST /v1/pages)',
294
287
  'Append blocks to an existing page (PATCH /v1/blocks/{pageId}/children)',
@@ -306,49 +299,45 @@ export const TEMPLATES = {
306
299
  },
307
300
  },
308
301
 
309
- // ── sentry-triage: parent workflow that uses notify-slack/-lark ──
302
+ // ── sentry-triage: hourly LLM triage Slack OR Lark ─────────────
310
303
  'sentry-triage': {
311
304
  name: 'sentry-triage',
312
305
  displayName: 'Sentry Triage Bot',
313
- description: 'Hourly Sentry triage pulls new issues, drops obvious noise with a regex pre-filter, classifies survivors with LLM (CRITICAL/HIGH/MEDIUM/LOW/NOISE), and fans out alerts to a notify-slack OR notify-lark child workflow.',
306
+ description: 'Hourly Sentry triage that classifies new issues with an LLM rubric (CRITICAL/HIGH/MEDIUM/LOW/NOISE) and posts above-threshold alerts to your Slack or Lark whichever you have connected.',
314
307
  path: join(__dirname, 'sentry-triage'),
315
308
  defaultSlug: 'sentry-triage',
316
- deps: { zod: '^3.23.0' },
309
+ deps: { zod: '^3.23.0', '@zibby/skills': '^0.1.25' },
317
310
  features: [
318
- '4-node graph: fetchfilter_noise → classify → dispatch_alerts',
319
- 'Regex noise filter before LLM cuts ~80% of classification cost',
320
- 'LLM severity classifier with explicit rubric (rules 1-5)',
321
- 'Sub-graph fan-out to notify-slack OR notify-lark (choose at deploy)',
322
- 'Per-issue failure isolation one Slack hiccup doesn\'t stall the run',
323
- 'Configurable severityThreshold (don\'t notify on LOW noise)',
311
+ '3-node LLM graph: fetch_issues → classify → dispatch_alerts',
312
+ 'Severity rubric with auditable reasoning per issue',
313
+ 'Posts to Slack OR Lark (whichever the project has connected — chat_notify OR-group)',
314
+ 'LLM dispatcher batches related issues into one message, dedupes near-duplicates',
315
+ 'CRITICAL alerts get optional @-mentions; lower severities don\'t',
324
316
  'Cron-friendly: hourly schedule, default sinceMinutes=60',
325
317
  ],
326
318
  marketplace: {
327
319
  slug: 'sentry-triage',
328
- tagline: 'Filter noise, classify severity, ping the right channel every hour.',
320
+ tagline: 'Triage Sentry, ping your teamhourly.',
329
321
  iconPrompt: [
330
- 'Hand-painted storybook illustration in a warm gouache style with soft brushwork and gentle painterly texture, featuring the friendly round lighthouse mascot character with two big smiling eyes and a rosy blush on its white-and-coral-striped tower body, perched on a tiny mint-green island and clutching a small glowing purple SHIELD BADGE in front of its body — the badge is a rounded geometric emblem in Sentry\'s signature deep violet (#362D59 / #7553FF) with a stylized white "S"-mark inside it formed from overlapping rounded parallelogram shapes, painted with the same soft gouache brushstrokes as the rest of the scene so it feels integrated rather than corporate.',
322
+ 'Hand-painted storybook illustration in a warm gouache style with soft brushwork and gentle painterly texture, featuring the friendly round lighthouse mascot character with two big smiling eyes and a rosy blush on its white-and-coral-striped tower body, perched on a tiny mint-green island and clutching a small glowing purple SHIELD BADGE in front of its body — the badge is a rounded geometric emblem in Sentry\'s signature deep violet (#362D59 / #7553FF) with a stylized white "S"-mark inside it formed from overlapping rounded parallelogram shapes.',
331
323
  'The lighthouse lantern emits a soft golden beam that catches one glowing amber alert orb while three faded grey noise specks drift harmlessly past, reinforcing the "filter the signal, calm the noise" idea.',
332
324
  'Background is a soft sunrise gradient of pale peach at the top blending through buttercream into a gentle wash of dusty lavender at the base, tying the warm scene to the violet of the badge; a few small fluffy pastel clouds float in for friendliness.',
333
- 'Centered composition with the purple shield badge as the immediate focal point in the lower-center, the lighthouse rising behind and slightly above it, beam angled diagonally; plenty of breathing room so the silhouette reads at 64×64 with the violet badge clearly visible at a glance.',
334
- 'Mood is warm, reassuring, optimistic — the friendly Sentry-flavored night-watch character, NOT tactical or corporate or alarming.',
325
+ 'Centered composition with the purple shield badge as the immediate focal point in the lower-center, the lighthouse rising behind and slightly above it, beam angled diagonally; plenty of breathing room so the silhouette reads at 64×64.',
326
+ 'Mood is warm, reassuring, optimistic — friendly night-watch character, NOT tactical or corporate or alarming.',
335
327
  'Soft rounded square 1024×1024 canvas with a subtle paper-grain texture.',
336
- 'NO text, NO letters, NO photo-realism, NO sleek 3D render, NO magnifying glass, NO speech bubbles, NO dark navy or near-black backgrounds, NO bug or insect imagery, NO literal Sentry wordmark.',
328
+ 'NO text, NO letters, NO photo-realism, NO sleek 3D render, NO magnifying glass, NO speech bubbles, NO dark navy or near-black backgrounds, NO bug or insect imagery, NO literal Sentry / Slack / Lark wordmark.',
337
329
  ].join('\n'),
338
- category: 'Operations',
339
- tags: ['sentry', 'observability', 'on-call', 'triage', 'alerting'],
330
+ tags: ['On-call', 'Bug Triage', 'Notifications'],
340
331
  capabilities: [
341
332
  'Hourly scheduled triage of new Sentry issues',
342
- 'Deterministic regex filter drops Script error / ResizeObserver / extension noise',
343
333
  'LLM severity classifier with auditable rubric',
344
- 'Dispatches to notify-slack or notify-lark (sub-graph, ~5ms in-process)',
345
- 'CRITICAL alerts get caller-supplied @-mentions; lower severities don\'t',
334
+ 'Posts to Slack or Lark whichever your project has connected',
335
+ 'Batches related issues; CRITICAL-only @-mentions for on-call',
346
336
  'Configurable severity threshold per deploy',
347
337
  ],
348
338
  conversationStarters: [
349
339
  'Triage all new Sentry issues from the last hour',
350
- 'Notify #sentry-alerts when severity is HIGH or above',
351
- 'Run hourly and post a summary to our team Slack',
340
+ 'Notify the on-call channel when severity is HIGH or above',
352
341
  'Page on-call when a CRITICAL error appears in checkout',
353
342
  ],
354
343
  },
@@ -375,16 +364,14 @@ export const TEMPLATES = {
375
364
  slug: 'ai-spend-weekly-digest',
376
365
  tagline: 'Track and explain your OpenAI / Anthropic / Cursor spending — every Monday morning, in Lark or Slack.',
377
366
  iconPrompt: [
378
- 'Hand-painted gouache illustration with soft brushwork and gentle painterly texture, featuring a friendly chubby pastel-pink piggy-bank character with two big smiling eyes and rosy blush, its body marked with a soft glowing dollar-sign sigil; the piggy is gently cradling a small stack of three coloured coins floating just above its back — a sky-blue coin for OpenAI-ish, a warm violet coin for Anthropic-ish, and a soft mint-green coin for Cursor-ish — each coin painted in the same loose gouache style with no logos or text on them, just abstract round chips.',
379
- 'A thin painterly trend-line ribbon arcs gently upward behind the piggy from lower-left to upper-right, suggesting "money over time", rendered as a soft watercolor ribbon in dusty rose with a tiny gentle peak near the top right.',
380
- 'Background is a calm sunrise gradient of pale buttercream at the top blending through soft peach into a gentle wash of pale mint at the base, with a few small pastel clouds for friendliness.',
381
- 'Centered composition with the piggy as the immediate focal point in the lower-center, the floating coins arcing across the upper third, the trend ribbon as background scaffolding; plenty of breathing room so the silhouette reads at 64×64.',
382
- 'Mood is warm, optimistic, gently informativefeels like a thoughtful finance friend, NOT corporate spreadsheet, NOT alarmist red.',
383
- 'Soft rounded square 1024×1024 canvas with a subtle paper-grain texture.',
384
- 'NO text, NO letters, NO numbers, NO photo-realism, NO sleek 3D render, NO chart axes or grid lines, NO dark navy or near-black backgrounds, NO literal OpenAI / Anthropic / Cursor logos or wordmarks, NO bar charts.',
367
+ 'A premium, hi-fi app icon for "AI Spend Weekly Digest" — a workflow that reports cross-vendor LLM spend to engineering leaders.',
368
+ 'Visual style: 3D-rendered hero object floating in space, in the style of Apple Vision Pro icons, Linear\'s changelog hero illustrations, or a Stripe product render. Glossy, dimensional, with subtle reflections and a soft rim-light.',
369
+ 'Subject: a 3D-rendered stack of three glossy disc-shaped layered chips — like a tiny ascending bar-chart-of-coins slightly rotated in three-quarter perspective. Each disc is a different premium accent color: top disc warm gold, middle disc cool platinum-silver, bottom disc deep iridescent purple. A single thin trending-up glowing line traces from lower-left to upper-right behind the stack, rendered as a soft neon ribbon (#7553FF violet glow), suggesting cost-over-time at a glance without literal axes.',
370
+ 'Background: a deep midnight-navy gradient (#0F172A at the top, #1E1B4B at the bottom), with a single soft violet glow behind the stack and a few faint star-like specks scattered across the canvas. Square format, 1024×1024.',
371
+ 'Composition: stack centered, subtle drop shadow on the canvas. Mood: high-end, executive, confident — like the cover image of a finance-ops product launch.',
372
+ 'NO text, NO numbers, NO axes or grid lines, NO outline wireframes, NO flat sticker style, NO mascot, NO piggy bank, NO cartoon faces, NO trademarked OpenAI / Anthropic / Cursor logos — this one is DEEP and 3D-rendered.',
385
373
  ].join('\n'),
386
- category: 'Operations',
387
- tags: ['cost', 'finance', 'reporting', 'openai', 'anthropic', 'cursor', 'digest', 'weekly'],
374
+ tags: ['AI Spend', 'Cost Tracking', 'Reports'],
388
375
  capabilities: [
389
376
  'Pulls org-wide cost+usage from OpenAI, Anthropic, and Cursor admin APIs in parallel',
390
377
  'Joins customer attribution from provider-native project / workspace / member metadata',
@@ -0,0 +1,4 @@
1
+ <svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M6.017 4.313l55.333 -4.087c6.797 -0.583 8.543 -0.19 12.817 2.917l17.663 12.443c2.913 2.14 3.883 2.723 3.883 5.053v68.243c0 4.277 -1.553 6.807 -6.99 7.193L24.467 99.967c-4.08 0.193 -6.023 -0.39 -8.16 -3.113L3.3 79.94c-2.333 -3.113 -3.3 -5.443 -3.3 -8.167V11.113c0 -3.497 1.553 -6.413 6.017 -6.8z" fill="#fff"/>
3
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M61.35 0.227l-55.333 4.087C1.553 4.7 0 7.617 0 11.113v60.66c0 2.723 0.967 5.053 3.3 8.167l13.007 16.913c2.137 2.723 4.08 3.307 8.16 3.113l64.257 -3.89c5.433 -0.387 6.99 -2.917 6.99 -7.193V20.64c0 -2.21 -0.873 -2.847 -3.443 -4.733L74.167 3.143c-4.273 -3.107 -6.02 -3.5 -12.817 -2.917zM25.92 19.523c-5.247 0.353 -6.437 0.433 -9.417 -1.99L8.927 11.507c-0.77 -0.78 -0.383 -1.753 1.557 -1.947l53.193 -3.887c4.467 -0.39 6.793 1.167 8.54 2.527l9.123 6.61c0.39 0.197 1.36 1.36 0.193 1.36l-54.933 3.307 -0.68 0.047zM19.803 88.3V30.367c0 -2.53 0.777 -3.697 3.103 -3.893L86 22.78c2.14 -0.193 3.107 1.167 3.107 3.693v57.547c0 2.53 -0.39 4.67 -3.883 4.863l-60.377 3.5c-3.493 0.193 -5.043 -0.97 -5.043 -4.083zm59.6 -54.827c0.387 1.75 0 3.5 -1.75 3.7l-2.91 0.577v42.773c-2.527 1.36 -4.853 2.137 -6.797 2.137 -3.107 0 -3.883 -0.973 -6.21 -3.887l-19.03 -29.94v28.967l6.02 1.363s0 3.5 -4.857 3.5l-13.39 0.777c-0.39 -0.78 0 -2.723 1.357 -3.11l3.497 -0.97v-38.3L30.48 40.667c-0.39 -1.75 0.58 -4.277 3.3 -4.473l14.367 -0.967 19.8 30.327v-26.83l-5.047 -0.58c-0.39 -2.143 1.163 -3.7 3.103 -3.89l13.4 -0.78z" fill="#000"/>
4
+ </svg>
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zibby/workflow-templates",
3
- "version": "0.4.2",
3
+ "version": "0.7.0",
4
4
  "description": "Built-in workflow templates for Zibby — browser-test-automation, code-analysis, generate-test-cases, notify-slack, notify-lark, notify-notion, sentry-triage.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -72,6 +72,7 @@
72
72
  "dependencies": {
73
73
  "@anthropic-ai/sdk": "^0.88.0",
74
74
  "@zibby/agent-workflow": "^0.4.2",
75
+ "@zibby/skills": "^0.1.25",
75
76
  "axios": "^1.15.0",
76
77
  "handlebars": "^4.7.9",
77
78
  "zod": "^3.23.0 || ^4.0.0"
@@ -1,51 +1,41 @@
1
1
  /**
2
- * sentry-triage — parent workflow.
2
+ * sentry-triage — parent workflow. Hourly Sentry issue triage.
3
3
  *
4
- * Pipeline:
4
+ * Pipeline (3 LLM nodes, end-to-end agent-driven):
5
5
  *
6
- * fetch_issues (LLM + SKILLS.SENTRY)
6
+ * fetch_issues (LLM + SKILLS.SENTRY) → list recent unresolved issues
7
7
  * ↓
8
- * filter_noise (pure JS regex pre-filter — kills ~80% of LLM cost)
8
+ * classify (LLM, no tools) → label NOISE/LOW/MEDIUM/HIGH/CRITICAL
9
9
  * ↓
10
- * classify (LLM assigns CRITICAL/HIGH/MEDIUM/LOW/NOISE per issue)
11
- *
12
- * dispatch_alerts (custom execute — sub-graphs to notify-slack OR notify-lark
13
- * per issue at or above severityThreshold)
10
+ * dispatch_alerts (LLM + SKILLS.CHAT_NOTIFY) → batch + post to Slack OR Lark for
11
+ * issues ≥ SEVERITY_THRESHOLD
14
12
  *
15
- * Sub-graph dispatch: each "real" alert fans out to ONE notify-* child
16
- * workflow (configurable per deploy via state.notifyWorker). Failures
17
- * on individual alerts don't kill the triage run — failed entries are
18
- * reported in dispatch_alerts.summary.failed and surfaced in
19
- * onComplete logging.
13
+ * Why all three nodes are LLM (not deterministic for-loops):
14
+ * - At hourly cadence with ≤20 issues/run, LLM cost is $1.50–$32/mo
15
+ * depending on model. Trivial relative to Sentry / Slack subscriptions.
16
+ * - LLM dispatch can BATCH related issues (5 errors in /checkout/ →
17
+ * 1 consolidated message) and DE-DUP near-duplicates. A
18
+ * deterministic for-loop can't.
19
+ * - outputSchema enforcement guarantees every above-threshold issue
20
+ * either gets a "sent" record or an explicit "failed/skipped" —
21
+ * no silent drops.
20
22
  *
21
- * In-process sub-graph execution (when both parent + child are bundled
22
- * in the same Fargate task) means each fan-out adds ~5ms overhead vs
23
- * an HTTP /trigger round-trip's 80s cold-start. For 20 issues that's
24
- * 100ms vs 1600s — the architecture is what makes this template
25
- * cheap enough to run hourly.
23
+ * Customize prompts: each node's prompt lives in its own module under
24
+ * nodes/. Override per-deploy by editing the file or by passing a
25
+ * custom prompt string via inputSchema (planned).
26
26
  */
27
27
 
28
- import { readFileSync, existsSync } from 'fs';
29
- import { join, dirname } from 'path';
30
- import { fileURLToPath } from 'url';
31
28
  import { WorkflowAgent, WorkflowGraph } from '@zibby/core';
32
29
 
33
30
  import { fetchIssuesNode } from './nodes/fetch-issues-node.js';
34
- import { filterNoiseNode } from './nodes/filter-noise-node.js';
35
31
  import { classifyNode } from './nodes/classify-node.js';
36
- import { dispatchAlertsNode } from './nodes/dispatch-alerts-node.js';
32
+ import { dispatchNode } from './nodes/dispatch-node.js';
37
33
 
38
34
  import {
39
35
  sentryTriageInputSchema,
40
36
  sentryTriageContextSchema,
41
37
  } from './state.js';
42
38
 
43
- const __dirname = dirname(fileURLToPath(import.meta.url));
44
- function loadPrompt(filename) {
45
- const path = join(__dirname, 'prompts', filename);
46
- return existsSync(path) ? readFileSync(path, 'utf-8') : '';
47
- }
48
-
49
39
  export class SentryTriageAgent extends WorkflowAgent {
50
40
  buildGraph() {
51
41
  const graph = new WorkflowGraph();
@@ -53,14 +43,12 @@ export class SentryTriageAgent extends WorkflowAgent {
53
43
  .setInputSchema(sentryTriageInputSchema)
54
44
  .setContextSchema(sentryTriageContextSchema);
55
45
 
56
- graph.addNode('fetch_issues', fetchIssuesNode, { prompt: loadPrompt('fetch-issues.md') });
57
- graph.addNode('filter_noise', filterNoiseNode);
58
- graph.addNode('classify', classifyNode, { prompt: loadPrompt('classify.md') });
59
- graph.addNode('dispatch_alerts', dispatchAlertsNode);
46
+ graph.addNode('fetch_issues', fetchIssuesNode);
47
+ graph.addNode('classify', classifyNode);
48
+ graph.addNode('dispatch_alerts', dispatchNode);
60
49
 
61
50
  graph.setEntryPoint('fetch_issues');
62
- graph.addEdge('fetch_issues', 'filter_noise');
63
- graph.addEdge('filter_noise', 'classify');
51
+ graph.addEdge('fetch_issues', 'classify');
64
52
  graph.addEdge('classify', 'dispatch_alerts');
65
53
  graph.addEdge('dispatch_alerts', 'END');
66
54
 
@@ -69,10 +57,11 @@ export class SentryTriageAgent extends WorkflowAgent {
69
57
 
70
58
  async onComplete(result) {
71
59
  const s = result?.state?.dispatch_alerts?.summary || {};
72
- const dropped = result?.state?.filter_noise?.dropped?.length || 0;
60
+ const classifications = result?.state?.classify?.classifications || [];
61
+ const noise = classifications.filter((c) => c.severity === 'NOISE').length;
73
62
  const fetched = result?.state?.fetch_issues?.issues?.length || 0;
74
63
  console.log(
75
- `[sentry-triage] complete — fetched=${fetched}, noise=${dropped}, ` +
64
+ `[sentry-triage] complete — fetched=${fetched}, noise=${noise}, ` +
76
65
  `sent=${s.sent || 0}, skipped=${s.skipped || 0}, failed=${s.failed || 0}`,
77
66
  );
78
67
  }
Binary file
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * classify node — LLM-driven severity classification.
3
3
  *
4
- * No tools — pure prompt + structured output. The prompt
5
- * (prompts/classify.md) carries the rubric (CRITICAL/HIGH/MEDIUM/LOW/
6
- * NOISE) and the LLM emits one classification record per kept issue.
4
+ * No tools — the LLM sees the rubric AND the concrete issues array
5
+ * (inlined as JSON at render time) and emits one classification record
6
+ * per issue. NOISE detection is part of the rubric itself; no separate
7
+ * pre-filter step.
7
8
  *
8
- * Temperature should be 0 (set by the runner via `model: 'auto'`'s
9
- * defaults for classification-style nodes). Schema enforcement
10
- * guarantees the emitted shape; bad models get a retry with the
11
- * outputSchema in the prompt.
9
+ * Severity threshold (skip-floor) lives on dispatch, NOT here this
10
+ * node always classifies every issue. dispatch decides whether to send.
12
11
  */
13
12
 
14
13
  import { z } from '@zibby/core';
@@ -27,12 +26,98 @@ const ClassifyOutputSchema = z.object({
27
26
  classifications: z.array(ClassificationShape),
28
27
  });
29
28
 
29
+ const RUBRIC = `You are the classify node of a Sentry triage workflow. Classify each Sentry issue into a severity bucket and explain WHY.
30
+
31
+ The list of issues is appended below as a JSON array. Treat it as authoritative — do NOT call any tool, you have everything you need.
32
+
33
+ # Severity rubric (apply IN ORDER, stop at first match)
34
+
35
+ 1. **NOISE** — these never warrant a human ping. Match if ANY:
36
+ - Title is "Script error." (cross-origin opaque error, no stack, useless)
37
+ - Title contains "Non-Error promise rejection captured"
38
+ - Title contains "ResizeObserver loop limit exceeded" or "ResizeObserver loop completed"
39
+ - culprit or metadata.filename URL starts with chrome-extension://, safari-extension://, moz-extension://, webkit-masked-url:// (user's extension crashed, not your code)
40
+ - Title or culprit mentions analytics SDKs: gtag, fbq, _paq, dataLayer, googletagmanager, piwik
41
+ - Title is "AbortError", contains "cancelled", or "Load failed" AND userCount < 3 (user navigated away)
42
+ - Title says "Test ", "Demo ", "[STAGING]" (wrong environment leakage)
43
+ - Stack trace has zero inApp:true frames (3rd-party only — not your code)
44
+ - User-agent in tags indicates a bot (Googlebot, AhrefsBot, etc.)
45
+
46
+ 2. **CRITICAL** if ANY of:
47
+ - userCount >= 20 (≥ 20 users affected — real prod impact)
48
+ - culprit or metadata.filename matches /payment|billing|checkout|auth|login|signup|session/i (security/revenue path)
49
+ - level === "fatal" and count >= 10
50
+ - count >= 100 AND firstSeen-to-lastSeen window is < 30 min (active spike)
51
+
52
+ 3. **HIGH** if ANY of:
53
+ - userCount >= 5 AND count >= 50
54
+ - level === "fatal" (any count)
55
+ - level === "error" AND userCount >= 3 AND count >= 20
56
+ - Errors in non-critical-but-important paths: settings, profile, search, dashboard, admin
57
+
58
+ 4. **MEDIUM** if ANY of:
59
+ - count >= 20 AND userCount >= 2
60
+ - count >= 50 regardless of userCount
61
+ - level === "error" AND count >= 10
62
+
63
+ 5. **LOW** — anything else (count < 20 AND userCount < 5, or level === "warning" | "info")
64
+
65
+ # Recommended action per severity
66
+
67
+ - CRITICAL → page_oncall (always notify, always mention rotation)
68
+ - HIGH → notify_channel (notify, no @ unless deploy author known)
69
+ - MEDIUM → notify_channel
70
+ - LOW → digest_only (rolled into a daily summary, not real-time)
71
+ - NOISE → ignore
72
+
73
+ # Output shape
74
+
75
+ For EACH issue in the JSON array below, emit ONE record:
76
+
77
+ \`\`\`json
78
+ {
79
+ "classifications": [
80
+ {
81
+ "issueId": "1234567890",
82
+ "severity": "CRITICAL",
83
+ "confidence": 0.95,
84
+ "reasoning": "12 users affected, culprit handleCheckout (payment path). Likely regression after recent deploy.",
85
+ "suggestedAction": "page_oncall",
86
+ "ruleMatched": "rule 2 (culprit matches /checkout/)"
87
+ }
88
+ ]
89
+ }
90
+ \`\`\`
91
+
92
+ # Rules
93
+
94
+ - confidence reflects how cleanly the issue matched. CRITICAL in /payment/ with userCount=50 → 0.95. Borderline → 0.6.
95
+ - reasoning is ONE sentence written for an on-call engineer. Lead with the impact metric.
96
+ - ruleMatched is which numbered rule fired. Helps operators tune the rubric over time.
97
+ - Be consistent: same issue twice should always get the same severity.
98
+ - Temperature 0. Classification, not creative writing.
99
+
100
+ # Do NOT
101
+
102
+ - Classify more issues than appear in the array below.
103
+ - Skip issues — every issue in the array must appear in the output (NOISE included).
104
+ - Use any severity outside NOISE|LOW|MEDIUM|HIGH|CRITICAL.
105
+ - Call any tools.`;
106
+
107
+ const CLASSIFY_PROMPT = (state = {}) => {
108
+ const issues = state?.fetch_issues?.issues || [];
109
+ return `${RUBRIC}
110
+
111
+ ## Issues to classify
112
+
113
+ \`\`\`json
114
+ ${JSON.stringify(issues, null, 2)}
115
+ \`\`\`
116
+ `;
117
+ };
118
+
30
119
  export const classifyNode = {
31
120
  name: 'classify',
32
- // NO skills — this is a pure reasoning step; the LLM has all data
33
- // it needs in state.filter_noise.kept. Adding skills would let the
34
- // LLM call Sentry tools for "more context", which we don't want
35
- // (rubric is supposed to be deterministic).
36
121
  outputSchema: ClassifyOutputSchema,
37
- timeout: 90 * 1000,
122
+ prompt: CLASSIFY_PROMPT,
38
123
  };
@@ -0,0 +1,143 @@
1
+ /**
2
+ * dispatch_alerts node — LLM-driven dispatcher.
3
+ *
4
+ * The agent sees ALL classified issues + their full data and makes
5
+ * judgment calls before calling the chat tool:
6
+ * - Bulk related issues into ONE message (5 errors in /checkout/ →
7
+ * "⚠️ Checkout spike: 5 errors, top: ...").
8
+ * - De-dupe near-duplicates ("seen 3 times, same culprit").
9
+ * - Honor SEVERITY_THRESHOLD (skip anything below).
10
+ * - Attach mentions only on CRITICAL.
11
+ *
12
+ * Provider routing: chatNotifySkill.resolve() picks the slack OR lark
13
+ * MCP server based on which ENV var is set, so the LLM only ever sees
14
+ * ONE provider's tools (slack_* or lark_*) — it can't accidentally
15
+ * call the wrong one.
16
+ *
17
+ * Reliability: outputSchema enforces a `dispatched` record per
18
+ * group + summary counts. A malformed LLM response triggers a retry
19
+ * with the schema embedded.
20
+ *
21
+ * ENV tab config:
22
+ * SLACK_CHANNEL OR LARK_RECEIVE_ID — required, pick one
23
+ * SEVERITY_THRESHOLD — NOISE|LOW|MEDIUM|HIGH|CRITICAL (default MEDIUM)
24
+ * SLACK_MENTIONS OR LARK_MENTIONS — JSON array, optional, CRITICAL only
25
+ */
26
+
27
+ import { z, SKILLS } from '@zibby/core';
28
+ import { SEVERITY_LEVELS } from '../state.js';
29
+
30
+ const DispatchAlertsOutputSchema = z.object({
31
+ dispatched: z.array(z.object({
32
+ issueIds: z.array(z.string()).describe('IDs grouped into this message; usually 1, more when batched.'),
33
+ severity: z.enum(SEVERITY_LEVELS),
34
+ status: z.enum(['sent', 'skipped', 'failed']),
35
+ messageTs: z.string().optional(), // Slack
36
+ messageId: z.string().optional(), // Lark
37
+ detail: z.string().optional(),
38
+ })),
39
+ summary: z.object({
40
+ total: z.number().describe('Number of messages POSTED (not issues — batched groups count as 1).'),
41
+ sent: z.number(),
42
+ skipped: z.number(),
43
+ failed: z.number(),
44
+ }),
45
+ });
46
+
47
+ const DISPATCH_PROMPT = (state = {}) => {
48
+ const issues = state?.fetch_issues?.issues || [];
49
+ const classifications = state?.classify?.classifications || [];
50
+
51
+ const threshold = process.env.SEVERITY_THRESHOLD || 'MEDIUM';
52
+ const slackChannel = process.env.SLACK_CHANNEL || '';
53
+ const larkReceiveId = process.env.LARK_RECEIVE_ID || '';
54
+
55
+ let provider, toolName, recipientLine, mentionsRaw;
56
+ if (slackChannel) {
57
+ provider = 'slack';
58
+ toolName = 'slack_post_message';
59
+ recipientLine = `Post every message to Slack channel: ${JSON.stringify(slackChannel)}\nCall: slack_post_message({ channel: "${slackChannel}", text: "…" })`;
60
+ mentionsRaw = process.env.SLACK_MENTIONS || '[]';
61
+ } else if (larkReceiveId) {
62
+ provider = 'lark';
63
+ toolName = 'lark_send_message';
64
+ recipientLine = `Post every message to Lark receive_id: ${JSON.stringify(larkReceiveId)}\nCall: lark_send_message({ receive_id: "${larkReceiveId}", text: "…" })`;
65
+ mentionsRaw = process.env.LARK_MENTIONS || '[]';
66
+ } else {
67
+ throw new Error('sentry-triage: configure SLACK_CHANNEL (for Slack) or LARK_RECEIVE_ID (for Lark) in the ENV tab.');
68
+ }
69
+
70
+ let mentions;
71
+ try { mentions = JSON.parse(mentionsRaw); } catch { mentions = []; }
72
+ if (!Array.isArray(mentions)) mentions = [];
73
+
74
+ return `You are the dispatch_alerts node of a Sentry triage workflow. Post chat alerts using the **${toolName}** tool.
75
+
76
+ # Recipient
77
+ ${recipientLine}
78
+
79
+ # Severity threshold
80
+ Skip any issue below: ${threshold}
81
+ (Severity order, low → high: ${SEVERITY_LEVELS.join(' < ')})
82
+
83
+ # Mentions
84
+ CRITICAL messages only — prepend: ${JSON.stringify(mentions.join(' '))}
85
+ HIGH/MEDIUM/LOW — no mentions.
86
+
87
+ # Your judgment
88
+ - Batch issues with the same culprit / metadata.filename into ONE message.
89
+ - De-dupe near-duplicates (e.g. same error text in different paths). Mention "seen N times".
90
+ - Keep each message short. Lead with severity in *[BRACKETS]*. Include the Sentry permalink so the on-call can click through.
91
+
92
+ # Message format (template, adapt as needed)
93
+ \`\`\`
94
+ *[CRITICAL]* TypeError: Cannot read 'id' of undefined
95
+ 12 users hit /checkout — likely regression on r1234.
96
+ 📍 handleCheckout(checkout.ts) · 47 events
97
+ https://sentry.io/.../1234/
98
+ \`\`\`
99
+
100
+ # Output (outputSchema-enforced)
101
+
102
+ Return ONE record per ${toolName} call you actually made (or skipped/failed).
103
+ \`issueIds\` is an array — for batched messages it carries every issue in the group.
104
+ \`severity\` is the highest severity in the group.
105
+
106
+ \`\`\`json
107
+ {
108
+ "dispatched": [
109
+ { "issueIds": ["1", "5", "7"], "severity": "CRITICAL", "status": "sent"${provider === 'slack' ? ', "messageTs": "1716109330.555"' : ', "messageId": "om_xxxxx"'} }
110
+ ],
111
+ "summary": { "total": 1, "sent": 1, "skipped": 0, "failed": 0 }
112
+ }
113
+ \`\`\`
114
+
115
+ # Issues + classifications
116
+
117
+ Each entry below has the Sentry issue plus the classifier's verdict + reasoning. Use both.
118
+
119
+ \`\`\`json
120
+ ${JSON.stringify(
121
+ issues.map((issue) => {
122
+ const c = classifications.find((x) => String(x.issueId) === String(issue.id));
123
+ return { ...issue, classification: c || { severity: 'LOW' } };
124
+ }),
125
+ null,
126
+ 2,
127
+ )}
128
+ \`\`\`
129
+
130
+ # Rules
131
+ - Skip below-threshold issues silently (just include them in dispatched with status="skipped"; no chat call).
132
+ - DON'T invent severities or issue IDs. Use what's given.
133
+ - DON'T post more messages than necessary. If 5 issues are clearly one bug, post 1 message.
134
+ - DO post if in doubt — under-paging is worse than over-paging for triage.
135
+ `;
136
+ };
137
+
138
+ export const dispatchNode = {
139
+ name: 'dispatch_alerts',
140
+ skills: [SKILLS.CHAT_NOTIFY],
141
+ outputSchema: DispatchAlertsOutputSchema,
142
+ prompt: DISPATCH_PROMPT,
143
+ };
@@ -1,21 +1,32 @@
1
1
  /**
2
- * fetch_issues node LLM-driven, uses SKILLS.SENTRY.
2
+ * fetch_issues — DETERMINISTIC. Calls Sentry's REST API directly via
3
+ * the @zibby/skills client. No LLM, no MCP tool round-trip.
3
4
  *
4
- * The LLM is given the sentry_list_issues tool and asked to fetch the
5
- * recent unresolved/unassigned issue list. The prompt (prompts/fetch-issues.md)
6
- * tells it the exact query string to use; the agent shouldn't deviate.
5
+ * Why deterministic: the previous LLM-driven version hard-coded the
6
+ * query string and explicitly forbade filtering or follow-up calls.
7
+ * The LLM added zero judgment just one round-trip of latency and
8
+ * ~$0.01-0.05 of token cost per run. At hourly cadence across many
9
+ * customers, that compounds; deterministic also removes the "LLM
10
+ * hallucinated query string" failure mode.
7
11
  *
8
- * Why an LLM node here (not a custom-execute Sentry API call): the
9
- * Sentry skill's tool surface is the canonical way to interact with
10
- * Sentry from a workflow, and consistent across all sentry-* templates
11
- * we'll add (autofix, incident). Bypassing it for fetch-only would
12
- * duplicate auth/rate-limit handling code in every template.
12
+ * Why still declare `skills: [SKILLS.SENTRY]`: the backend bundler
13
+ * reads this to build `workflow.requiredIntegrations`, which the
14
+ * marketplace deploy modal uses to gate install until Sentry is
15
+ * connected. Without it, users could install with no Sentry token
16
+ * wired up and the first run would 401. The skill's runtime tool
17
+ * injection is a no-op here (no prompt for an LLM to call them), but
18
+ * the integration-requirement signal still matters — same pattern as
19
+ * ai-spend-weekly-digest's fetch-spending-node.
13
20
  *
14
- * Output validation is enforced by outputSchema — if the LLM emits a
15
- * malformed payload, the runtime retries with the schema embedded.
21
+ * Auth: sentryListIssues uses resolveIntegrationToken('sentry') which
22
+ * hits the backend's project-scoped resolver via PROJECT_API_TOKEN +
23
+ * PROGRESS_API_URL env vars (set on every Fargate task by
24
+ * workflow-executor.js).
16
25
  */
17
26
 
18
- import { z, SKILLS } from '@zibby/core';
27
+ import { z } from 'zod';
28
+ import { SKILLS } from '@zibby/core';
29
+ import { sentryListIssues } from '@zibby/skills/sentry';
19
30
 
20
31
  const IssueShape = z.object({
21
32
  id: z.string(),
@@ -38,15 +49,37 @@ const IssueShape = z.object({
38
49
 
39
50
  const FetchIssuesOutputSchema = z.object({
40
51
  issues: z.array(IssueShape),
41
- fetchedAt: z.string().optional(),
52
+ fetchedAt: z.string(),
42
53
  });
43
54
 
44
55
  export const fetchIssuesNode = {
45
56
  name: 'fetch_issues',
46
57
  skills: [SKILLS.SENTRY],
47
58
  outputSchema: FetchIssuesOutputSchema,
48
- // 60s Sentry API is usually fast but the LLM round-trip + tool
49
- // call sequence (often 2-3 turns for the agent to settle) needs
50
- // breathing room.
51
- timeout: 60 * 1000,
59
+ execute: async (context) => {
60
+ // State access pattern mirrors fetch-spending-node the framework
61
+ // passes a context whose `.state.getAll()` returns the flat state,
62
+ // but tests sometimes pass the state object directly as context.
63
+ const state = (context?.state && typeof context.state.getAll === 'function')
64
+ ? context.state.getAll()
65
+ : context;
66
+
67
+ const sinceMinutes = Number(state?.sinceMinutes) || 60;
68
+
69
+ const issues = await sentryListIssues({
70
+ query: `is:unresolved is:unassigned firstSeen:-${sinceMinutes}m`,
71
+ sort: 'created',
72
+ // 100 issues is the practical ceiling for a triage notification.
73
+ // Beyond that, classify+dispatch lose signal — a "deluge" digest
74
+ // tells the user nothing actionable. If a customer regularly
75
+ // exceeds 100/hour they need to tighten the Sentry filters
76
+ // upstream, not raise this cap.
77
+ limit: 100,
78
+ });
79
+
80
+ return {
81
+ issues,
82
+ fetchedAt: new Date().toISOString(),
83
+ };
84
+ },
52
85
  };
@@ -3,13 +3,14 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "type": "module",
6
- "description": "Hourly Sentry issue triage bot — filters noise, classifies severity with LLM, dispatches notifications to Slack/Lark via sub-graph.",
6
+ "description": "Hourly Sentry issue triage bot — LLM-classifies new issues by severity and pings Slack OR Lark for anything ≥ threshold.",
7
7
  "main": "graph.mjs",
8
8
  "scripts": {
9
9
  "test": "vitest run"
10
10
  },
11
11
  "dependencies": {
12
12
  "@zibby/core": "^0.5.1",
13
+ "@zibby/skills": "^0.1.25",
13
14
  "zod": "^3.23.0"
14
15
  },
15
16
  "devDependencies": {
@@ -1,76 +1,40 @@
1
1
  /**
2
- * sentry-triage — three-schema state model.
2
+ * sentry-triage — input + context schemas.
3
3
  *
4
- * Triage flow:
5
- * 1. fetch_issues (LLM + sentry skill) — pull recent unresolved
6
- * issues from Sentry
7
- * 2. filter_noise (custom execute) — drop known-noise patterns
8
- * (browser-extension URLs, ResizeObserver loops, etc.) WITHOUT
9
- * paying an LLM call per issue
10
- * 3. classify (LLM) — classify the survivors as
11
- * NOISE / LOW / MEDIUM / HIGH / CRITICAL with reasoning
12
- * 4. dispatch_alerts (custom execute) — sub-graph dispatch to
13
- * notify-slack OR notify-lark for issues above severityThreshold
4
+ * Trigger payload (inputSchema) is intentionally tiny: just sinceMinutes,
5
+ * the one per-run dial. Everything else is deploy-time ENV-tab config:
14
6
  *
15
- * Why sub-graph dispatch (not built-in notify here):
16
- * - notify-slack / notify-lark are reusable across MANY parent
17
- * workflows (sentry-autofix, sentry-incident, cron-summary, etc.).
18
- * - Adding a new channel (Discord, Teams) means adding a new child
19
- * workflow — sentry-triage code never changes.
20
- * - In-process sub-graph dispatch is ~5ms overhead in cloud, so the
21
- * architectural cleanliness costs nothing.
7
+ * Required (set ONE at least one chat target):
8
+ * SLACK_CHANNEL channel id "C012345" or "#name"
9
+ * LARK_RECEIVE_ID oc_… chat id, ou_… open id, or email
10
+ *
11
+ * Optional:
12
+ * SEVERITY_THRESHOLD NOISE|LOW|MEDIUM|HIGH|CRITICAL (default MEDIUM)
13
+ * SLACK_MENTIONS JSON array appended to CRITICAL Slack alerts only
14
+ * LARK_MENTIONS JSON array — appended to CRITICAL Lark alerts only
22
15
  */
23
16
 
24
17
  import { z } from 'zod';
25
18
 
19
+ // Ordered low → high. Index doubles as severity rank.
26
20
  export const SEVERITY_LEVELS = /** @type {const} */ (['NOISE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
27
21
 
22
+ /** True iff severity is at or above threshold per SEVERITY_LEVELS order. */
23
+ export function meetsSeverityThreshold(severity, threshold) {
24
+ const s = SEVERITY_LEVELS.indexOf(severity);
25
+ const t = SEVERITY_LEVELS.indexOf(threshold);
26
+ return (s === -1 ? 0 : s) >= (t === -1 ? SEVERITY_LEVELS.indexOf('MEDIUM') : t);
27
+ }
28
+
28
29
  export const sentryTriageInputSchema = z.object({
29
- // ── Sentry source ────────────────────────────────────────────────
30
- organizationSlug: z.string().min(1)
31
- .describe('Sentry organization slug (the URL segment after sentry.io/organizations/).'),
32
- projectSlug: z.string().min(1)
33
- .describe('Sentry project slug — limits triage to a single project.'),
34
- environment: z.string().default('production')
35
- .describe('Sentry environment tag to filter by (defaults to production).'),
36
30
  sinceMinutes: z.number().int().min(5).max(1440).default(60)
37
- .describe('Look back this many minutes for newly-firstSeen issues. Hourly cron → 60.'),
38
-
39
- // ── Triage thresholds ────────────────────────────────────────────
40
- severityThreshold: z.enum(SEVERITY_LEVELS).default('MEDIUM')
41
- .describe('Only dispatch alerts for issues at or above this severity. Drop the rest.'),
42
- maxIssues: z.number().int().min(1).max(100).default(20)
43
- .describe('Cap issues processed per run. Protects against an unexpected error storm.'),
44
-
45
- // ── Where to send alerts ────────────────────────────────────────
46
- notifyWorker: z.enum(['notify-slack', 'notify-lark']).default('notify-slack')
47
- .describe(
48
- 'Which child workflow to dispatch alerts to. Both must be deployed in the same project ' +
49
- 'as this triage workflow. Pick whichever messaging platform your team uses.',
50
- ),
51
-
52
- // For notify-slack
53
- slackChannel: z.string().min(1).max(120).optional()
54
- .describe('Slack channel id (C012345) or #name. Required when notifyWorker=notify-slack.'),
55
- slackMentions: z.array(z.string().max(60)).max(10).optional()
56
- .describe('Mentions to append on CRITICAL alerts only, e.g. ["<!subteam^S0ONCALL>"].'),
57
-
58
- // For notify-lark
59
- larkReceiveId: z.string().min(1).max(120).optional()
60
- .describe('Lark chat id (oc_…), open id (ou_…), or email. Required when notifyWorker=notify-lark.'),
61
- larkMentions: z.array(z.string().max(200)).max(10).optional()
62
- .describe('Lark @-mention strings for CRITICAL alerts.'),
63
-
64
- model: z.string().default('auto')
65
- .describe('LLM model override for classify_issues. Default auto-selects.'),
31
+ .describe('Lookback minutes (5–1440)'),
66
32
  });
67
33
 
68
34
  export const sentryTriageContextSchema = z.object({
69
- // Runner-injected
70
35
  workspace: z.string().optional()
71
- .describe('Workspace path — set by runner. Triage doesn\'t need it but graph.run requires it.'),
36
+ .describe('Workspace path — runner-injected; triage doesn\'t need it but graph.run requires it.'),
72
37
 
73
- // Node outputs (mid-graph, keyed by node name)
74
38
  fetch_issues: z.object({
75
39
  issues: z.array(z.object({
76
40
  id: z.string(),
@@ -93,14 +57,6 @@ export const sentryTriageContextSchema = z.object({
93
57
  fetchedAt: z.string().optional(),
94
58
  }).optional(),
95
59
 
96
- filter_noise: z.object({
97
- kept: z.array(z.any()),
98
- dropped: z.array(z.object({
99
- id: z.string(),
100
- reason: z.string(),
101
- })),
102
- }).optional(),
103
-
104
60
  classify: z.object({
105
61
  classifications: z.array(z.object({
106
62
  issueId: z.string(),
@@ -114,7 +70,11 @@ export const sentryTriageContextSchema = z.object({
114
70
 
115
71
  dispatch_alerts: z.object({
116
72
  dispatched: z.array(z.object({
117
- issueId: z.string(),
73
+ // Deterministic dispatcher emits issueId; LLM batcher emits issueIds[].
74
+ // messageTs (Slack) and messageId (Lark) are both optional — only the
75
+ // variant that ran will populate one of them.
76
+ issueId: z.string().optional(),
77
+ issueIds: z.array(z.string()).optional(),
118
78
  severity: z.enum(SEVERITY_LEVELS),
119
79
  status: z.enum(['sent', 'skipped', 'failed']),
120
80
  detail: z.string().optional(),
@@ -129,6 +89,3 @@ export const sentryTriageContextSchema = z.object({
129
89
  }),
130
90
  }).optional(),
131
91
  });
132
-
133
- export const sentryTriageStateSchema =
134
- sentryTriageInputSchema.merge(sentryTriageContextSchema);
@@ -1,191 +0,0 @@
1
- /**
2
- * dispatch-alerts node — sub-graph fan-out to notify-slack/notify-lark.
3
- *
4
- * For each classified issue at or above severityThreshold:
5
- * - Build a provider-neutral notification payload (severity, title,
6
- * body, sentryLink, etc.) from the merged issue + classification
7
- * records.
8
- * - Add caller-supplied per-provider config (channel for Slack,
9
- * receiveId for Lark, severity-conditional mentions).
10
- * - dispatchSubgraph(state.notifyWorker, { input }) — SYNC mode so we
11
- * get back the messageTs/messageId for the summary.
12
- * - Continue on per-issue failure (notify failure shouldn't kill the
13
- * whole triage run; we report `status: 'failed'` and move on).
14
- *
15
- * Sub-graph dispatch goes via in-process executor when the child is
16
- * bundled in the same Fargate task. The runtime threshold for severity
17
- * is enforced HERE, not in the LLM classifier, so an operator can
18
- * raise/lower the bar at deploy time without redeploying.
19
- */
20
-
21
- import { z } from 'zod';
22
- import { dispatchSubgraph } from '@zibby/agent-workflow';
23
- import { SEVERITY_LEVELS } from '../state.js';
24
-
25
- const DispatchAlertsOutputSchema = z.object({
26
- dispatched: z.array(z.object({
27
- issueId: z.string(),
28
- severity: z.enum(SEVERITY_LEVELS),
29
- status: z.enum(['sent', 'skipped', 'failed']),
30
- detail: z.string().optional(),
31
- messageTs: z.string().optional(),
32
- messageId: z.string().optional(),
33
- })),
34
- summary: z.object({
35
- total: z.number(),
36
- sent: z.number(),
37
- skipped: z.number(),
38
- failed: z.number(),
39
- }),
40
- });
41
-
42
- const SEVERITY_RANK = Object.freeze({
43
- NOISE: 0, LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4,
44
- });
45
-
46
- /**
47
- * Build the provider-neutral notification payload from issue + classification.
48
- * Pure function — exposed for tests so we can pin the wire shape.
49
- */
50
- export function buildNotifyPayload({ issue, classification, state }) {
51
- const severityRaw = classification?.severity || 'LOW';
52
- // notify-* workflows want lowercase severity (their inputSchema enum).
53
- const severity = severityRaw.toLowerCase();
54
- const reason = classification?.reasoning || '';
55
- const userCount = typeof issue.userCount === 'number' ? issue.userCount : undefined;
56
- const events = typeof issue.count === 'number' ? issue.count
57
- : (typeof issue.count === 'string' && /^\d+$/.test(issue.count)) ? Number(issue.count)
58
- : undefined;
59
- const release = issue?.metadata?.release
60
- || (issue.tags || []).find?.((t) => t.key === 'release')?.value
61
- || undefined;
62
- const firstSeen = issue.firstSeen || undefined;
63
-
64
- const culpritLine = issue.culprit ? `\n📍 ${issue.culprit}` : '';
65
- const reasonLine = reason ? `\n${reason}` : '';
66
- const body = `${classification?.suggestedAction ? `*Action:* ${classification.suggestedAction}\n` : ''}` +
67
- `${reasonLine}${culpritLine}`.trim();
68
-
69
- // Code snippet: when fetch_issues populated metadata.filename, use it
70
- // as a one-line hint. v2 will pull the actual context lines.
71
- const codeSnippet = issue?.metadata?.filename
72
- ? `${issue.metadata.filename}\n// ${issue.metadata.value || issue.title}`
73
- : undefined;
74
-
75
- // Mentions: only attach on CRITICAL. Lower severities get no @-blast.
76
- const isCritical = severityRaw === 'CRITICAL';
77
- const slackMentions = isCritical && Array.isArray(state.slackMentions) ? state.slackMentions : undefined;
78
- const larkMentions = isCritical && Array.isArray(state.larkMentions) ? state.larkMentions : undefined;
79
-
80
- // Common fields both providers accept.
81
- const common = {
82
- severity,
83
- title: issue.title || issue.shortId || `Sentry ${issue.id}`,
84
- body: body || undefined,
85
- sentryLink: issue.permalink || undefined,
86
- affectedUsers: userCount,
87
- events,
88
- release,
89
- firstSeen,
90
- codeSnippet,
91
- };
92
-
93
- if (state.notifyWorker === 'notify-lark') {
94
- return {
95
- ...common,
96
- receiveId: state.larkReceiveId,
97
- ...(larkMentions ? { mentions: larkMentions } : {}),
98
- };
99
- }
100
- // default → notify-slack
101
- return {
102
- ...common,
103
- channel: state.slackChannel,
104
- ...(slackMentions ? { mentions: slackMentions } : {}),
105
- };
106
- }
107
-
108
- /**
109
- * Convenience for unit tests + the node body. Decides whether a given
110
- * classification meets the threshold.
111
- */
112
- export function meetsSeverityThreshold(severity, threshold) {
113
- const s = SEVERITY_RANK[severity] ?? 0;
114
- const t = SEVERITY_RANK[threshold] ?? SEVERITY_RANK.MEDIUM;
115
- return s >= t;
116
- }
117
-
118
- export const dispatchAlertsNode = {
119
- name: 'dispatch_alerts',
120
- outputSchema: DispatchAlertsOutputSchema,
121
- timeout: 5 * 60 * 1000, // 5min — generous for fan-out across 20 children
122
- execute: async (context) => {
123
- const state = (context?.state && typeof context.state.getAll === 'function')
124
- ? context.state.getAll()
125
- : context;
126
-
127
- const issues = state?.filter_noise?.kept || state?.fetch_issues?.issues || [];
128
- const classifications = state?.classify?.classifications || [];
129
- const classMap = new Map(classifications.map((c) => [c.issueId, c]));
130
- const threshold = state.severityThreshold || 'MEDIUM';
131
- const worker = state.notifyWorker || 'notify-slack';
132
-
133
- // Validate the right per-provider config field is set.
134
- if (worker === 'notify-slack' && !state.slackChannel) {
135
- throw new Error('sentry-triage: slackChannel is required when notifyWorker=notify-slack');
136
- }
137
- if (worker === 'notify-lark' && !state.larkReceiveId) {
138
- throw new Error('sentry-triage: larkReceiveId is required when notifyWorker=notify-lark');
139
- }
140
-
141
- const dispatched = [];
142
- for (const issue of issues) {
143
- const classification = classMap.get(String(issue.id));
144
- const severity = classification?.severity || 'LOW';
145
-
146
- if (!meetsSeverityThreshold(severity, threshold)) {
147
- dispatched.push({
148
- issueId: String(issue.id),
149
- severity,
150
- status: 'skipped',
151
- detail: `severity ${severity} below threshold ${threshold}`,
152
- });
153
- continue;
154
- }
155
-
156
- const payload = buildNotifyPayload({ issue, classification, state });
157
- try {
158
- // Sync sub-graph dispatch. The in-process executor returns the
159
- // child's finalState (or the extracted `output`). We pull
160
- // messageTs / messageId by checking both shapes since the parent
161
- // is provider-agnostic at this layer.
162
- const result = await dispatchSubgraph(worker, {
163
- input: payload,
164
- async: false,
165
- });
166
- dispatched.push({
167
- issueId: String(issue.id),
168
- severity,
169
- status: 'sent',
170
- messageTs: result?.notify_slack?.messageTs || result?.messageTs,
171
- messageId: result?.notify_lark?.messageId || result?.messageId,
172
- });
173
- } catch (err) {
174
- dispatched.push({
175
- issueId: String(issue.id),
176
- severity,
177
- status: 'failed',
178
- detail: err?.message || String(err),
179
- });
180
- }
181
- }
182
-
183
- const summary = {
184
- total: dispatched.length,
185
- sent: dispatched.filter((d) => d.status === 'sent').length,
186
- skipped: dispatched.filter((d) => d.status === 'skipped').length,
187
- failed: dispatched.filter((d) => d.status === 'failed').length,
188
- };
189
- return { dispatched, summary };
190
- },
191
- };
@@ -1,112 +0,0 @@
1
- /**
2
- * filter-noise node — deterministic regex-based pre-LLM filter.
3
- *
4
- * Cuts LLM cost ~80% on a typical Sentry stream by dropping issues
5
- * that are obviously noise BEFORE we pay for classification. The
6
- * patterns are deliberately conservative — anything ambiguous goes
7
- * through to the LLM classifier rather than being killed here.
8
- *
9
- * Noise categories (matched in order; first hit wins):
10
- *
11
- * 1. Cross-origin / opaque errors:
12
- * - "Script error." (literal, with period) — useless without CORS
13
- * - "Non-Error promise rejection captured"
14
- *
15
- * 2. Browser-internal benign loops:
16
- * - "ResizeObserver loop limit exceeded"
17
- * - "ResizeObserver loop completed with undelivered notifications"
18
- *
19
- * 3. Browser-extension noise — any frame URL with extension scheme:
20
- * - chrome-extension://, safari-extension://, moz-extension://
21
- *
22
- * 4. Cancelled/aborted requests (user navigated away):
23
- * - "AbortError", title containing "cancelled" / "Failed to fetch"
24
- * AND empty stack frames
25
- *
26
- * 5. Bot/crawler traffic — usually surfaces via specific tag patterns;
27
- * v1 doesn't have the tags in our payload shape so we skip. Add
28
- * when the event-detail path is wired in.
29
- *
30
- * Anything not matching falls through to the LLM. The output carries
31
- * BOTH the kept list and the dropped list (with reason) so the
32
- * downstream dispatcher can report on filter activity ("auto-ignored
33
- * 14 noise issues this hour").
34
- */
35
-
36
- import { z } from 'zod';
37
-
38
- const FilterNoiseOutputSchema = z.object({
39
- kept: z.array(z.any()).describe('Issues that survive the noise filter — passed to LLM classifier.'),
40
- dropped: z.array(z.object({
41
- id: z.string(),
42
- reason: z.string(),
43
- })).describe('Issues filtered out and why — surfaced in the final summary.'),
44
- });
45
-
46
- /** Test hook — exposes the rule table so unit tests can assert
47
- * per-pattern matching without re-typing them. */
48
- export const NOISE_RULES = Object.freeze([
49
- { reason: 'cross-origin opaque error', test: (issue) => /^Script error\.?$/i.test((issue.title || '').trim()) },
50
- { reason: 'non-Error promise rejection', test: (issue) => /Non-Error promise rejection captured/i.test(issue.title || '') },
51
- { reason: 'ResizeObserver loop', test: (issue) => /ResizeObserver loop (limit exceeded|completed)/i.test(issue.title || '') },
52
- { reason: 'browser-extension frame', test: (issue) => isExtensionUrl(issue?.metadata?.filename) || isExtensionUrl(issue?.culprit) },
53
- { reason: 'analytics SDK', test: (issue) => /\b(gtag|fbq|_paq|dataLayer|googletagmanager|piwik)\b/i.test(`${issue.title || ''} ${issue.culprit || ''}`) },
54
- { reason: 'aborted/cancelled request', test: (issue) => /AbortError|cancelled|Load failed/i.test(issue.title || '') && (!issue.userCount || issue.userCount < 3) },
55
- ]);
56
-
57
- function isExtensionUrl(url) {
58
- if (!url || typeof url !== 'string') return false;
59
- return /^(?:chrome-extension|safari-extension|moz-extension|webkit-masked-url):\/\//.test(url)
60
- || /chrome-extension:\/\//.test(url)
61
- || /safari-extension:\/\//.test(url);
62
- }
63
-
64
- /**
65
- * Run the noise filter on an array of issues. Returns
66
- * `{ kept, dropped }`. Pure function — exposed so tests can call it
67
- * without going through the node's execute wrapper.
68
- */
69
- export function filterNoiseIssues(issues) {
70
- const kept = [];
71
- const dropped = [];
72
- for (const issue of issues || []) {
73
- if (!issue || typeof issue !== 'object' || !issue.id) {
74
- // Defensive: malformed entries go to dropped rather than crashing
75
- // the whole filter run.
76
- dropped.push({ id: String(issue?.id || '<unknown>'), reason: 'malformed-issue' });
77
- continue;
78
- }
79
- let matched = null;
80
- for (const rule of NOISE_RULES) {
81
- try {
82
- if (rule.test(issue)) {
83
- matched = rule.reason;
84
- break;
85
- }
86
- } catch {
87
- // A buggy rule shouldn't break the whole filter; just skip it.
88
- }
89
- }
90
- if (matched) {
91
- dropped.push({ id: String(issue.id), reason: matched });
92
- } else {
93
- kept.push(issue);
94
- }
95
- }
96
- return { kept, dropped };
97
- }
98
-
99
- export const filterNoiseNode = {
100
- name: 'filter_noise',
101
- outputSchema: FilterNoiseOutputSchema,
102
- // 5s is generous for a pure-CPU filter even on 100 issues — the
103
- // input is already bounded by maxIssues (default 20).
104
- timeout: 5 * 1000,
105
- execute: async (context) => {
106
- const state = (context?.state && typeof context.state.getAll === 'function')
107
- ? context.state.getAll()
108
- : context;
109
- const issues = state?.fetch_issues?.issues || [];
110
- return filterNoiseIssues(issues);
111
- },
112
- };
@@ -1,76 +0,0 @@
1
- You are the **classify** node of a Sentry triage workflow. Your job is to classify each kept issue (after the `filter_noise` node removed obvious garbage) into a severity bucket and explain WHY.
2
-
3
- ## Inputs (read from `state`)
4
-
5
- - `state.filter_noise.kept` — array of Sentry issue objects that passed the regex noise filter
6
- - `state.fetch_issues.issues` — (alias) the original list, for cross-referencing if needed
7
-
8
- ## Severity rubric (apply IN ORDER, stop at first match)
9
-
10
- 1. **CRITICAL** if ANY of:
11
- - `userCount >= 20` (≥ 20 users affected — real prod impact)
12
- - `culprit` or `metadata.filename` matches `/payment|billing|checkout|auth|login|signup|session/i` (security/revenue path)
13
- - `level === "fatal"` and `count >= 10`
14
- - `count >= 100` AND the first-seen-to-last-seen window is < 30 min (active spike)
15
-
16
- 2. **HIGH** if ANY of:
17
- - `userCount >= 5` AND `count >= 50`
18
- - `level === "fatal"` (any count)
19
- - `level === "error"` AND `userCount >= 3` AND `count >= 20`
20
- - Errors in non-critical-but-important paths: settings, profile, search, dashboard, admin
21
-
22
- 3. **MEDIUM** if ANY of:
23
- - `count >= 20` AND `userCount >= 2`
24
- - `count >= 50` regardless of userCount
25
- - `level === "error"` AND `count >= 10`
26
-
27
- 4. **LOW** if ANY of:
28
- - `count < 20` AND `userCount < 5`
29
- - `level === "warning"` or `level === "info"`
30
-
31
- 5. **NOISE** — only if the issue clearly slipped past the regex filter. Reasons:
32
- - Title says "Test ", "Demo ", "[STAGING]" — wrong environment
33
- - Stack trace has zero `inApp:true` frames (3rd-party only)
34
- - User-agent string indicates a bot (Googlebot, AhrefsBot, etc.) — though typically the issue has no user_count if it's bot traffic
35
-
36
- ## Recommended action per severity
37
-
38
- - **CRITICAL** → `page_oncall` (always notify, always mention rotation)
39
- - **HIGH** → `notify_channel` (notify, no @ unless deploy author known)
40
- - **MEDIUM** → `notify_channel`
41
- - **LOW** → `digest_only` (rolled into a daily summary — not real-time noise)
42
- - **NOISE** → `ignore`
43
-
44
- ## Output shape
45
-
46
- For EACH issue in `state.filter_noise.kept`, emit ONE classification record. Order doesn't matter.
47
-
48
- ```json
49
- {
50
- "classifications": [
51
- {
52
- "issueId": "1234567890",
53
- "severity": "CRITICAL",
54
- "confidence": 0.95,
55
- "reasoning": "12 users affected, culprit handleCheckout (payment path). Likely regression after recent deploy.",
56
- "suggestedAction": "page_oncall",
57
- "ruleMatched": "rule 1 (culprit matches /checkout/)"
58
- }
59
- ]
60
- }
61
- ```
62
-
63
- ## Rules of engagement
64
-
65
- - `confidence` reflects how cleanly the issue matched the rubric. CRITICAL with userCount=50 in /payment/ → 0.95. LOW vs MEDIUM borderline → 0.6.
66
- - `reasoning` is ONE sentence, written for an on-call engineer who sees it in Slack. Avoid academic prose; lead with the impact metric.
67
- - `ruleMatched` is which numbered rule fired. Helps operators tune the rubric over time.
68
- - Be consistent: same issue twice should always get the same severity.
69
- - Temperature should be 0 — this is a classification task, not creative writing.
70
-
71
- ## Do NOT
72
-
73
- - Do NOT classify more issues than were in `state.filter_noise.kept`.
74
- - Do NOT skip issues — every kept issue must appear in the output.
75
- - Do NOT use any severity outside `NOISE|LOW|MEDIUM|HIGH|CRITICAL`.
76
- - Do NOT call any tools. This is a pure-classification node — the inputs are already in state.
@@ -1,66 +0,0 @@
1
- You are the **fetch_issues** node of a Sentry triage workflow.
2
-
3
- ## Your job
4
-
5
- Pull the list of recently-firstSeen, unresolved, unassigned issues from the configured Sentry project, then return them in a structured JSON object matching the node's outputSchema.
6
-
7
- ## Inputs (read from `state`)
8
-
9
- - `state.organizationSlug` — Sentry org slug
10
- - `state.projectSlug` — project slug to scope the query to
11
- - `state.environment` — environment tag to filter (e.g. `"production"`)
12
- - `state.sinceMinutes` — look back this many minutes (default 60)
13
- - `state.maxIssues` — cap returned issues (default 20)
14
-
15
- ## How to do it
16
-
17
- 1. Use the **`sentry_list_issues`** tool with:
18
- ```
19
- project: <projectSlug>
20
- query: "is:unresolved is:unassigned firstSeen:-<sinceMinutes>m environment:<environment>"
21
- sort: "created"
22
- limit: <maxIssues>
23
- ```
24
-
25
- 2. Each issue in the result has fields:
26
- `id, shortId, title, culprit, level, status, count, userCount, firstSeen, lastSeen, permalink, metadata`.
27
-
28
- 3. Pass them through verbatim. Do NOT classify or filter here — that's the next two nodes' job. Your only job is to fetch.
29
-
30
- 4. If the Sentry API returns 0 issues for the window, return `{ issues: [], fetchedAt: <ISO timestamp> }`. The downstream nodes handle empty lists gracefully.
31
-
32
- 5. If the API errors (token missing, project not found, rate-limited), throw a clear error including the Sentry error code. The runner surfaces this on the workflow execution row.
33
-
34
- ## Output shape (strict — outputSchema-enforced)
35
-
36
- ```json
37
- {
38
- "issues": [
39
- {
40
- "id": "1234567890",
41
- "shortId": "ZIBBY-API-42K",
42
- "title": "TypeError: Cannot read properties of undefined (reading 'id')",
43
- "culprit": "handleCheckout(checkout.ts)",
44
- "level": "error",
45
- "status": "unresolved",
46
- "count": 47,
47
- "userCount": 12,
48
- "firstSeen": "2026-05-19T08:14:22Z",
49
- "lastSeen": "2026-05-19T09:02:11Z",
50
- "permalink": "https://sentry.io/organizations/zibby/issues/1234567890/",
51
- "metadata": {
52
- "type": "TypeError",
53
- "value": "Cannot read properties of undefined (reading 'id')",
54
- "filename": "src/handlers/checkout.ts"
55
- }
56
- }
57
- ],
58
- "fetchedAt": "2026-05-19T09:00:00Z"
59
- }
60
- ```
61
-
62
- ## Do NOT
63
-
64
- - Do NOT filter "obvious noise" issues here. The next node (`filter_noise`) does that with cheap regex rules — your job is the unfiltered firehose.
65
- - Do NOT call `sentry_get_issue` per issue. The list endpoint returns everything we need for triage; per-issue detail fetch is wasteful.
66
- - Do NOT invent fields. If `userCount` is missing on an issue, omit it; don't fill in 0.