@zibby/workflow-templates 0.4.1 → 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.
@@ -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);