@zibby/workflow-templates 0.3.0 → 0.4.2

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.
Files changed (34) hide show
  1. package/browser-test-automation/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  2. package/browser-test-automation/package.json +1 -0
  3. package/code-analysis/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  4. package/code-analysis/nodes/generate-code-node.js +12 -2
  5. package/index.js +235 -0
  6. package/notify-lark/README.md +88 -0
  7. package/notify-lark/graph.mjs +43 -0
  8. package/notify-lark/icon.png +0 -0
  9. package/notify-lark/nodes/notify-lark-node.js +303 -0
  10. package/notify-lark/package.json +18 -0
  11. package/notify-lark/state.js +85 -0
  12. package/notify-notion/README.md +71 -0
  13. package/notify-notion/graph.mjs +64 -0
  14. package/notify-notion/icon.png +0 -0
  15. package/notify-notion/nodes/notify-notion-node.js +342 -0
  16. package/notify-notion/package.json +19 -0
  17. package/notify-notion/state.js +110 -0
  18. package/notify-slack/README.md +94 -0
  19. package/notify-slack/graph.mjs +51 -0
  20. package/notify-slack/icon.png +0 -0
  21. package/notify-slack/nodes/notify-slack-node.js +268 -0
  22. package/notify-slack/package.json +18 -0
  23. package/notify-slack/state.js +112 -0
  24. package/package.json +17 -3
  25. package/sentry-triage/graph.mjs +81 -0
  26. package/sentry-triage/icon.png +0 -0
  27. package/sentry-triage/nodes/classify-node.js +38 -0
  28. package/sentry-triage/nodes/dispatch-alerts-node.js +191 -0
  29. package/sentry-triage/nodes/fetch-issues-node.js +52 -0
  30. package/sentry-triage/nodes/filter-noise-node.js +112 -0
  31. package/sentry-triage/package.json +18 -0
  32. package/sentry-triage/prompts/classify.md +76 -0
  33. package/sentry-triage/prompts/fetch-issues.md +66 -0
  34. package/sentry-triage/state.js +134 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * notify-slack node — deterministic, no LLM.
3
+ *
4
+ * Posts a single Block Kit message to Slack via chat.postMessage. We
5
+ * don't use an LLM for this because:
6
+ * - The output is fully specified by the input (no decisions to make).
7
+ * - Notifications need to be fast (<500ms) and predictable.
8
+ * - LLM cost on every alert is silly when the parent has already
9
+ * decided the severity + body.
10
+ *
11
+ * Auth: pulls the Slack OAuth token via @zibby/core's
12
+ * `resolveIntegrationToken('slack')`. Same path slackSkill uses for the
13
+ * LLM-tool variant. Tokens are cached per-process for ~50min so a
14
+ * parent dispatching 20 alerts in a row only pays one round-trip to the
15
+ * backend integrations endpoint.
16
+ *
17
+ * Severity → color + decoration:
18
+ * low → grey (#7f8c8d), no mentions
19
+ * medium → yellow (#f1c40f), no mentions
20
+ * high → orange (#e67e22), mentions appended if caller provided
21
+ * critical → red (#c0392b), mentions appended (caller usually
22
+ * supplies <!subteam^...> or @here)
23
+ *
24
+ * Severity emoji ⏺ in the header keeps the message scannable even when
25
+ * Slack collapses attachment colors on mobile.
26
+ *
27
+ * Failure modes (no retry — sub-graph caller handles retries):
28
+ * - Slack token not connected → throws "slack is not connected" via
29
+ * resolveIntegrationToken. Caller sees SUBGRAPH_NOT_FOUND-shaped err.
30
+ * - Channel not found → Slack API returns ok=false with
31
+ * error="channel_not_found". We surface a clear typed error.
32
+ * - Bot not in channel → error="not_in_channel". Same.
33
+ * - Network blip → fetch throws. Parent's onComplete sees a failed
34
+ * execution row and can re-dispatch.
35
+ */
36
+
37
+ import { z } from 'zod';
38
+ import { SKILLS } from '@zibby/core';
39
+ import { resolveIntegrationToken } from '@zibby/core/backend-client.js';
40
+ // reportToBlockKit lives in @zibby/skills (universal renderer for any
41
+ // destination). When the parent passes a `report` object, we delegate
42
+ // rendering entirely; the legacy severity/title/body path is unchanged
43
+ // for sentry-triage and other callers still on the simple shape.
44
+ import { reportToBlockKit } from '@zibby/skills/report';
45
+
46
+ const SEVERITY_COLORS = Object.freeze({
47
+ low: '#7f8c8d',
48
+ medium: '#f1c40f',
49
+ high: '#e67e22',
50
+ critical: '#c0392b',
51
+ });
52
+
53
+ const SEVERITY_EMOJI = Object.freeze({
54
+ low: ':white_circle:',
55
+ medium: ':large_yellow_circle:',
56
+ high: ':large_orange_circle:',
57
+ critical: ':rotating_light:',
58
+ });
59
+
60
+ const NotifySlackOutputSchema = z.object({
61
+ delivered: z.boolean().describe('true if chat.postMessage returned ok'),
62
+ channel: z.string().describe('Channel id Slack actually posted to (canonical form).'),
63
+ messageTs: z.string().optional().describe('Slack message timestamp — use as thread_ts for follow-ups.'),
64
+ blocksCount: z.number().int().optional().describe('Block count in the rendered message — diagnostic only.'),
65
+ });
66
+
67
+ /**
68
+ * Build the Slack Block Kit `attachments[0].blocks` payload from the
69
+ * input. Exposed for unit tests so we can pin the rendered shape
70
+ * without going through fetch.
71
+ */
72
+ export function buildSlackBlocks(input) {
73
+ const {
74
+ severity = 'medium',
75
+ title,
76
+ body,
77
+ sentryLink,
78
+ affectedUsers,
79
+ events,
80
+ release,
81
+ firstSeen,
82
+ codeSnippet,
83
+ actionUrl,
84
+ actionLabel,
85
+ mentions,
86
+ } = input;
87
+
88
+ const emoji = SEVERITY_EMOJI[severity] || SEVERITY_EMOJI.medium;
89
+ const blocks = [];
90
+
91
+ // Header — severity emoji + uppercased severity + title.
92
+ blocks.push({
93
+ type: 'header',
94
+ text: {
95
+ type: 'plain_text',
96
+ // Slack header text caps at 150 chars; truncate defensively.
97
+ text: `${emoji} ${severity.toUpperCase()} ${title}`.slice(0, 150),
98
+ emoji: true,
99
+ },
100
+ });
101
+
102
+ // Metadata fields (2-column grid). Only rendered when at least one
103
+ // optional field is present — keeps the alert tight for "headline-only" messages.
104
+ const fields = [];
105
+ if (typeof affectedUsers === 'number') {
106
+ fields.push({ type: 'mrkdwn', text: `*Users affected:*\n${affectedUsers}` });
107
+ }
108
+ if (typeof events === 'number') {
109
+ fields.push({ type: 'mrkdwn', text: `*Events:*\n${events}` });
110
+ }
111
+ if (release) {
112
+ fields.push({ type: 'mrkdwn', text: `*Release:*\n\`${release}\`` });
113
+ }
114
+ if (firstSeen) {
115
+ fields.push({ type: 'mrkdwn', text: `*First seen:*\n${firstSeen}` });
116
+ }
117
+ if (fields.length > 0) {
118
+ blocks.push({ type: 'section', fields });
119
+ }
120
+
121
+ // Body — mrkdwn-rendered. Empty body still posts (header alone is OK).
122
+ if (body && body.trim().length > 0) {
123
+ blocks.push({
124
+ type: 'section',
125
+ text: { type: 'mrkdwn', text: body },
126
+ });
127
+ }
128
+
129
+ // Code snippet — fenced triple-backtick block. Slack treats these as
130
+ // monospaced when posted inside a mrkdwn section.
131
+ if (codeSnippet) {
132
+ blocks.push({
133
+ type: 'section',
134
+ text: { type: 'mrkdwn', text: '```' + codeSnippet + '```' },
135
+ });
136
+ }
137
+
138
+ // Action buttons.
139
+ const actions = [];
140
+ if (sentryLink) {
141
+ actions.push({
142
+ type: 'button',
143
+ text: { type: 'plain_text', text: 'Open in Sentry' },
144
+ url: sentryLink,
145
+ style: 'primary',
146
+ });
147
+ }
148
+ if (actionUrl && actionLabel) {
149
+ actions.push({
150
+ type: 'button',
151
+ text: { type: 'plain_text', text: actionLabel.slice(0, 40) },
152
+ url: actionUrl,
153
+ });
154
+ }
155
+ if (actions.length > 0) {
156
+ blocks.push({ type: 'actions', elements: actions });
157
+ }
158
+
159
+ // Mentions go into a small-text context block at the bottom so they
160
+ // don't dominate the visual. Caller-supplied strings are joined with
161
+ // spaces. We don't validate the mention syntax — Slack will silently
162
+ // ignore malformed ones.
163
+ if (Array.isArray(mentions) && mentions.length > 0) {
164
+ blocks.push({
165
+ type: 'context',
166
+ elements: [{ type: 'mrkdwn', text: mentions.join(' ') }],
167
+ });
168
+ }
169
+
170
+ return blocks;
171
+ }
172
+
173
+ /**
174
+ * Post the message via Slack's chat.postMessage. Exposed for tests so
175
+ * we can stub fetch + assert payload shape without invoking
176
+ * resolveIntegrationToken.
177
+ */
178
+ export async function postToSlack({ token, channel, blocks, text }) {
179
+ // `text` is the plain-text fallback shown in notifications / when
180
+ // blocks can't render. Slack recommends ALWAYS setting it.
181
+ const payload = { channel, blocks, text };
182
+ const res = await fetch('https://slack.com/api/chat.postMessage', {
183
+ method: 'POST',
184
+ headers: {
185
+ Authorization: `Bearer ${token}`,
186
+ 'Content-Type': 'application/json; charset=utf-8',
187
+ },
188
+ body: JSON.stringify(payload),
189
+ });
190
+ const data = await res.json().catch(() => ({}));
191
+ if (!data.ok) {
192
+ const err = new Error(`Slack chat.postMessage failed: ${data.error || `http ${res.status}`}`);
193
+ err.code = data.error || 'unknown';
194
+ err.status = res.status;
195
+ throw err;
196
+ }
197
+ return { channel: data.channel, ts: data.ts };
198
+ }
199
+
200
+ export const notifySlackNode = {
201
+ name: 'notify_slack',
202
+ // Declares the Slack integration as a hard requirement so the
203
+ // marketplace card surfaces "Slack required" via the backend's
204
+ // automatic deriveRequiredIntegrations(). The MCP slack server tools
205
+ // aren't actually invoked here (custom-execute path), but the
206
+ // declaration is the canonical way to wire the marketplace gate.
207
+ skills: [SKILLS.SLACK],
208
+ outputSchema: NotifySlackOutputSchema,
209
+ // 15s — chat.postMessage is fast, and we want to fail-out quick if
210
+ // Slack is degraded so the parent's sync-dispatch doesn't hang.
211
+ timeout: 15 * 1000,
212
+ execute: async (context) => {
213
+ // Custom-execute gets either the merged state object or the new
214
+ // {state, agent, ...} context wrapper. Normalize to a plain state.
215
+ const state = (context?.state && typeof context.state.getAll === 'function')
216
+ ? context.state.getAll()
217
+ : context;
218
+
219
+ const channel = state.channel;
220
+ if (!channel) {
221
+ throw new Error('notify-slack: input.channel is required');
222
+ }
223
+ if (!state.report && !state.title) {
224
+ throw new Error('notify-slack: input.title is required when input.report is absent');
225
+ }
226
+
227
+ // Resolve the bot token. The cache inside resolveIntegrationToken
228
+ // means this is a single HTTP RTT the first time per process; later
229
+ // dispatches in the same Fargate task are zero-cost.
230
+ const { token } = await resolveIntegrationToken('slack');
231
+
232
+ // Two rendering paths:
233
+ // 1. Rich `report` object (digest workflows like
234
+ // ai-spend-weekly-digest) → reportToBlockKit produces the full
235
+ // structured card. The plain-text fallback is sourced from
236
+ // report.title (best summary for mobile push).
237
+ // 2. Legacy severity/title/body shape (sentry-triage et al.) →
238
+ // buildSlackBlocks renders the simple alert card.
239
+ //
240
+ // The two paths are mutually exclusive at runtime; `report` wins
241
+ // if both are present (a misconfiguration the parent shouldn't do,
242
+ // but we resolve it predictably rather than mixing the outputs).
243
+ let blocks;
244
+ let text;
245
+ if (state.report && typeof state.report === 'object') {
246
+ blocks = reportToBlockKit(state.report);
247
+ const title = state.report.title || 'Report';
248
+ const headline = state.report.headline?.primary
249
+ ? `: ${state.report.headline.primary}`
250
+ : '';
251
+ text = `${title}${headline}`.slice(0, 200);
252
+ } else {
253
+ blocks = buildSlackBlocks(state);
254
+ // Plain-text fallback — Slack renders this in mobile push
255
+ // notifications when blocks are absent. Severity + title is the
256
+ // most useful summary in <100 chars.
257
+ text = `${(state.severity || 'medium').toUpperCase()}: ${state.title}`.slice(0, 200);
258
+ }
259
+
260
+ const result = await postToSlack({ token, channel, blocks, text });
261
+ return {
262
+ delivered: true,
263
+ channel: result.channel,
264
+ messageTs: result.ts,
265
+ blocksCount: blocks.length,
266
+ };
267
+ },
268
+ };
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "notify-slack",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Post a structured alert to a Slack channel — child workflow, dispatched by parent workflows (Sentry triage / autofix / incident / etc.) via sub-graph.",
7
+ "main": "graph.mjs",
8
+ "scripts": {
9
+ "test": "vitest run"
10
+ },
11
+ "dependencies": {
12
+ "@zibby/core": "^0.5.1",
13
+ "zod": "^3.23.0"
14
+ },
15
+ "devDependencies": {
16
+ "vitest": "^2.1.5"
17
+ }
18
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * notify-slack — three-schema state model.
3
+ *
4
+ * Designed to be invoked as a sub-graph child from any parent workflow
5
+ * that wants to fan-out alerts to Slack. The shape is intentionally
6
+ * provider-neutral on the wire (severity / title / body / link / users)
7
+ * so that notify-lark can accept the SAME inputs without the parent
8
+ * having to branch on which notifier it's dispatching to.
9
+ *
10
+ * Three schemas:
11
+ * - notifySlackInputSchema — payload from the parent's
12
+ * `dispatchSubgraph('notify-slack', { input: … })`
13
+ * - notifySlackContextSchema — runner-injected fields + node outputs
14
+ * - notifySlackStateSchema — merge of the two (tests + tooling)
15
+ */
16
+
17
+ import { z } from 'zod';
18
+
19
+ export const SEVERITIES = /** @type {const} */ (['low', 'medium', 'high', 'critical']);
20
+
21
+ // ── Parent-supplied payload ────────────────────────────────────────────
22
+ // Keep this minimal + provider-neutral. Anything Slack-specific (channel
23
+ // id format, Block Kit, mention strings) is RESOLVED inside the node,
24
+ // not declared on the inputSchema.
25
+ export const notifySlackInputSchema = z.object({
26
+ severity: z.enum(SEVERITIES)
27
+ .default('medium')
28
+ .describe('Alert severity. Drives color + mention strategy.'),
29
+
30
+ // Required in legacy-mode (severity/title/body alerts from sentry-triage
31
+ // and friends). In rich-mode (when `report` is set), title is sourced
32
+ // from `report.title` so the top-level field is optional. The execute
33
+ // path enforces "at least one source of title" at runtime.
34
+ title: z.string().min(1).max(300).optional()
35
+ .describe('Short one-line headline (renders in the Slack header). Required when `report` is absent.'),
36
+
37
+ body: z.string().max(3000).optional()
38
+ .describe('Body text. Supports Slack mrkdwn (e.g. *bold*, `code`, <url|text>).'),
39
+
40
+ channel: z.string().min(1).max(100)
41
+ .describe('Target channel ID (C012345) or #channel-name. Provided by the parent at dispatch.'),
42
+
43
+ // ── Sentry-flavored optional context — used to render rich blocks
44
+ // when the alert originates from a Sentry workflow. Other workflows
45
+ // can ignore these.
46
+ sentryLink: z.string().url().optional()
47
+ .describe('Sentry issue/event URL. Adds an "Open in Sentry" button.'),
48
+
49
+ affectedUsers: z.number().int().min(0).optional()
50
+ .describe('Count of users hit by this issue (rendered in the fields block).'),
51
+
52
+ events: z.number().int().min(0).optional()
53
+ .describe('Total event count for this issue.'),
54
+
55
+ release: z.string().max(120).optional()
56
+ .describe('Release/version tag this error appeared in.'),
57
+
58
+ firstSeen: z.string().optional()
59
+ .describe('ISO timestamp or human-friendly "N min ago".'),
60
+
61
+ codeSnippet: z.string().max(2000).optional()
62
+ .describe('Optional code block to render verbatim (e.g. the throwing line + context).'),
63
+
64
+ // Action button — optional, opens a URL when clicked.
65
+ actionUrl: z.string().url().optional()
66
+ .describe('Secondary URL (e.g. PR link). Renders as a non-primary button next to "Open in Sentry".'),
67
+ actionLabel: z.string().max(40).optional()
68
+ .describe('Label for the secondary action button (e.g. "View PR").'),
69
+
70
+ // ── Mentions — Slack-specific syntax provided by the caller. Parent
71
+ // decides who to mention based on severity (e.g. <!subteam^S0...>
72
+ // for critical, <@U_USERID> for the deploy author).
73
+ mentions: z.array(z.string().max(60))
74
+ .max(20)
75
+ .optional()
76
+ .describe('Slack mention strings appended to the context block, e.g. ["<!here>", "<@U123>"].'),
77
+
78
+ // Idempotency: if the parent retries dispatch (or two parents dispatch
79
+ // the same alert), we want at most ONE Slack post. ts is returned in
80
+ // the output for thread replies later.
81
+ idempotencyKey: z.string().max(128).optional()
82
+ .describe('Caller-supplied dedup key. The node still posts; idempotency is the parent\'s job today.'),
83
+
84
+ // ── Rich-report mode ──────────────────────────────────────────────
85
+ // When `report` is present, the node renders the report-object via
86
+ // reportToBlockKit() and IGNORES severity/title/body/sentryLink/etc.
87
+ // (those legacy fields are exclusively for the simple-alert path
88
+ // used by sentry-triage et al.).
89
+ //
90
+ // Schema validation is intentionally permissive here — the contract
91
+ // lives in @zibby/skills's reportObjectSchema. We declare it as a
92
+ // free-form object on the wire so old runtimes that don't know about
93
+ // the field don't fail validation, and the actual structure is
94
+ // validated by reportToBlockKit at render time. New consumers should
95
+ // import reportObjectSchema from @zibby/skills for tighter typing.
96
+ report: z.record(z.any()).optional()
97
+ .describe('Rich report-object (see @zibby/skills/report). When set, supersedes severity/title/body — the node renders a full Block-Kit card.'),
98
+ });
99
+
100
+ export const notifySlackContextSchema = z.object({
101
+ // Node outputs land here keyed by node name.
102
+ notify_slack: z.object({
103
+ delivered: z.boolean(),
104
+ channel: z.string(),
105
+ messageTs: z.string().optional(),
106
+ blocksCount: z.number().int().optional(),
107
+ }).optional()
108
+ .describe('Output of the notify_slack node — set after dispatch completes.'),
109
+ });
110
+
111
+ export const notifySlackStateSchema =
112
+ notifySlackInputSchema.merge(notifySlackContextSchema);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zibby/workflow-templates",
3
- "version": "0.3.0",
4
- "description": "Built-in workflow templates for Zibby — browser-test-automation, code-analysis, generate-test-cases. Carved out of @zibby/core@0.4.6 so the engine ships without scaffolding payload.",
3
+ "version": "0.4.2",
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",
7
7
  "exports": {
@@ -15,6 +15,16 @@
15
15
  "./code-analysis/*": "./code-analysis/*",
16
16
  "./generate-test-cases": "./generate-test-cases/graph.mjs",
17
17
  "./generate-test-cases/*": "./generate-test-cases/*",
18
+ "./notify-slack": "./notify-slack/graph.mjs",
19
+ "./notify-slack/*": "./notify-slack/*",
20
+ "./notify-lark": "./notify-lark/graph.mjs",
21
+ "./notify-lark/*": "./notify-lark/*",
22
+ "./notify-notion": "./notify-notion/graph.mjs",
23
+ "./notify-notion/*": "./notify-notion/*",
24
+ "./sentry-triage": "./sentry-triage/graph.mjs",
25
+ "./sentry-triage/*": "./sentry-triage/*",
26
+ "./ai-spend-weekly-digest": "./ai-spend-weekly-digest/graph.mjs",
27
+ "./ai-spend-weekly-digest/*": "./ai-spend-weekly-digest/*",
18
28
  "./package.json": "./package.json"
19
29
  },
20
30
  "scripts": {
@@ -42,6 +52,10 @@
42
52
  "browser-test-automation/",
43
53
  "code-analysis/",
44
54
  "generate-test-cases/",
55
+ "notify-slack/",
56
+ "notify-lark/",
57
+ "notify-notion/",
58
+ "sentry-triage/",
45
59
  "index.js",
46
60
  "register-nodes.js",
47
61
  "global-setup.js",
@@ -57,7 +71,7 @@
57
71
  },
58
72
  "dependencies": {
59
73
  "@anthropic-ai/sdk": "^0.88.0",
60
- "@zibby/agent-workflow": "^0.4.1",
74
+ "@zibby/agent-workflow": "^0.4.2",
61
75
  "axios": "^1.15.0",
62
76
  "handlebars": "^4.7.9",
63
77
  "zod": "^3.23.0 || ^4.0.0"
@@ -0,0 +1,81 @@
1
+ /**
2
+ * sentry-triage — parent workflow.
3
+ *
4
+ * Pipeline:
5
+ *
6
+ * fetch_issues (LLM + SKILLS.SENTRY)
7
+ * ↓
8
+ * filter_noise (pure JS regex pre-filter — kills ~80% of LLM cost)
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)
14
+ *
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.
20
+ *
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.
26
+ */
27
+
28
+ import { readFileSync, existsSync } from 'fs';
29
+ import { join, dirname } from 'path';
30
+ import { fileURLToPath } from 'url';
31
+ import { WorkflowAgent, WorkflowGraph } from '@zibby/core';
32
+
33
+ import { fetchIssuesNode } from './nodes/fetch-issues-node.js';
34
+ import { filterNoiseNode } from './nodes/filter-noise-node.js';
35
+ import { classifyNode } from './nodes/classify-node.js';
36
+ import { dispatchAlertsNode } from './nodes/dispatch-alerts-node.js';
37
+
38
+ import {
39
+ sentryTriageInputSchema,
40
+ sentryTriageContextSchema,
41
+ } from './state.js';
42
+
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
+ export class SentryTriageAgent extends WorkflowAgent {
50
+ buildGraph() {
51
+ const graph = new WorkflowGraph();
52
+ graph
53
+ .setInputSchema(sentryTriageInputSchema)
54
+ .setContextSchema(sentryTriageContextSchema);
55
+
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);
60
+
61
+ graph.setEntryPoint('fetch_issues');
62
+ graph.addEdge('fetch_issues', 'filter_noise');
63
+ graph.addEdge('filter_noise', 'classify');
64
+ graph.addEdge('classify', 'dispatch_alerts');
65
+ graph.addEdge('dispatch_alerts', 'END');
66
+
67
+ return graph;
68
+ }
69
+
70
+ async onComplete(result) {
71
+ const s = result?.state?.dispatch_alerts?.summary || {};
72
+ const dropped = result?.state?.filter_noise?.dropped?.length || 0;
73
+ const fetched = result?.state?.fetch_issues?.issues?.length || 0;
74
+ console.log(
75
+ `[sentry-triage] complete — fetched=${fetched}, noise=${dropped}, ` +
76
+ `sent=${s.sent || 0}, skipped=${s.skipped || 0}, failed=${s.failed || 0}`,
77
+ );
78
+ }
79
+ }
80
+
81
+ export default SentryTriageAgent;
Binary file
@@ -0,0 +1,38 @@
1
+ /**
2
+ * classify node — LLM-driven severity classification.
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.
7
+ *
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.
12
+ */
13
+
14
+ import { z } from '@zibby/core';
15
+ import { SEVERITY_LEVELS } from '../state.js';
16
+
17
+ const ClassificationShape = z.object({
18
+ issueId: z.string(),
19
+ severity: z.enum(SEVERITY_LEVELS),
20
+ confidence: z.number().min(0).max(1).optional(),
21
+ reasoning: z.string().optional(),
22
+ suggestedAction: z.string().optional(),
23
+ ruleMatched: z.string().optional(),
24
+ });
25
+
26
+ const ClassifyOutputSchema = z.object({
27
+ classifications: z.array(ClassificationShape),
28
+ });
29
+
30
+ export const classifyNode = {
31
+ 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
+ outputSchema: ClassifyOutputSchema,
37
+ timeout: 90 * 1000,
38
+ };