← Public packages
@kentcdodds/sentry-triage
Sentry triage wakes Cole (Grok Bot) per issue: one active wake per repo (lease + queue), loop-safe Discord. Cole may spawn Cursor for isolated repo work.
src/handle-sentry-webhook.ts
260 lines · 7.2 KB · TypeScriptimport { packageStorage, workflows } from 'kody:runtime'
import processSentryWebhook from './process-sentry-webhook.ts'
import {
issueStorageKey,
seerGraceMs,
seerPrGraceMs,
webhookPayloadStorageKey,
} from './shared.ts'
import type {
HandleSentryWebhookInput,
HandleSentryWebhookResult,
SentryWebhookPayload,
} from './types.ts'
function isSeerResource(
resource: string | null,
payload: SentryWebhookPayload | null | undefined,
) {
const action = String(payload?.action ?? '')
return (
resource === 'seer' ||
action.startsWith('root_cause') ||
action.startsWith('solution') ||
action.startsWith('coding') ||
payload?.action === 'pr_created'
)
}
function isInterestingSeerAction(action: string | null) {
return action === 'root_cause_completed' || action === 'pr_created'
}
function extractIssueId(payload: SentryWebhookPayload | null | undefined) {
const issueId = payload?.data?.issue?.id
if (issueId != null && String(issueId)) return String(issueId)
const eventIssueId = payload?.data?.event?.issue_id
if (eventIssueId != null && String(eventIssueId)) return String(eventIssueId)
return null
}
/**
* Build a stable workflow idempotency key for issue.created deliveries.
* Retries of the same logical event reuse the key; stale re-triage / rescue
* uses a distinct key anchored on the prior record timestamp so regressions
* are not blocked by a completed prior workflow.
*/
async function issueIdempotencyKey(
issueId: string,
payload: SentryWebhookPayload,
) {
if (payload.kodySweeperRescue === true) {
const existing = await packageStorage()
.get(issueStorageKey(issueId))
.catch(() => null)
const anchor =
existing?.seerPrAwaitedAt ??
existing?.seerRequestedAt ??
existing?.seenAt ??
'rescue'
return `sentry-triage:issue:${issueId}:rescue:${anchor}`
}
const existing = await packageStorage()
.get(issueStorageKey(issueId))
.catch(() => null)
if (!existing) return `sentry-triage:issue:${issueId}:created`
const finishedAt =
existing.completedAt ?? existing.spawnedAt ?? existing.seenAt ?? null
const ageMs =
finishedAt != null ? Date.now() - Date.parse(String(finishedAt)) : 0
const awaitingAgeMs =
Date.now() -
(Date.parse(
String(existing.seerRequestedAt ?? finishedAt ?? ''),
) || Date.now())
const awaitingPrAgeMs =
Date.now() -
(Date.parse(
String(existing.seerPrAwaitedAt ?? finishedAt ?? ''),
) || Date.now())
const retriageAfterMs = 6 * 60 * 60 * 1000
const inFlightStaleMs = 3 * 60 * 60 * 1000
const spawnFailedRetryMs = 15 * 60 * 1000
const inFlight = existing.status === 'agent-spawned'
const stale =
existing.status === 'awaiting-seer'
? awaitingAgeMs > seerGraceMs
: existing.status === 'awaiting-seer-pr'
? awaitingPrAgeMs > seerPrGraceMs
: existing.status === 'spawn-failed'
? ageMs > spawnFailedRetryMs
: inFlight
? ageMs > inFlightStaleMs
: ageMs > retriageAfterMs
if (!stale) {
// Still in the dedupe window — dispatch with the base key so a
// concurrent Sentry retry joins the in-flight / completed workflow
// instead of waking Cole a second time.
return `sentry-triage:issue:${issueId}:created`
}
const anchor = String(finishedAt ?? existing.seenAt ?? 'retriage')
return `sentry-triage:issue:${issueId}:retriage:${anchor}`
}
function seerIdempotencyKey(groupId: string, action: string, runId: unknown) {
const runPart = runId != null && String(runId) ? `:run:${runId}` : ''
return `sentry-triage:seer:${groupId}:${action}${runPart}`
}
/**
* Stage the full Sentry request in packageStorage and dispatch a durable
* processor with a bounded params object (well under the 16KB workflows
* serialize cap). Never embed the full Sentry JSON in workflow params.
*/
async function dispatchDurableProcessor({
idempotencyKey,
issueId,
resource,
action,
headers,
payload,
}: {
idempotencyKey: string
issueId: string
resource: string | null
action: string | null
headers: Record<string, string | undefined>
payload: SentryWebhookPayload
}) {
const payloadKey = webhookPayloadStorageKey(idempotencyKey)
await packageStorage().set(payloadKey, {
headers,
json: payload,
stagedAt: new Date().toISOString(),
})
const created = await workflows.create({
exportName: './process-sentry-webhook',
idempotencyKey,
params: {
payloadKey,
issueId,
resource,
action,
},
})
return { payloadKey, created }
}
/**
* Inbound Sentry webhook entrypoint (HMAC-verified by the platform).
* Fast skips and `kodyDryRun` stay synchronous; durable issue/Seer work is
* dispatched with `workflows.create` to `./process-sentry-webhook` so the
* ingress export returns before prefetch / Discord / Cole wake.
*/
export default async function handleSentryWebhook(
input: HandleSentryWebhookInput = {},
): Promise<HandleSentryWebhookResult> {
const payload = (input?.request?.json ?? null) as SentryWebhookPayload | null
const resource = input?.request?.headers?.['sentry-hook-resource'] ?? null
const action = payload?.action ?? null
if (isSeerResource(resource, payload)) {
const groupId =
payload?.data?.group_id != null ? String(payload.data.group_id) : null
if (!groupId) {
return { ok: true, skipped: 'no-group-id', resource, action }
}
if (!isInterestingSeerAction(action)) {
return {
ok: true,
skipped: `seer-action-${action}`,
resource,
action,
issueId: groupId,
}
}
if (payload?.kodyDryRun === true) {
return processSentryWebhook(input)
}
const idempotencyKey = seerIdempotencyKey(
groupId,
String(action),
payload?.data?.run_id,
)
const { created } = await dispatchDurableProcessor({
idempotencyKey,
issueId: groupId,
resource: resource ?? 'seer',
action,
headers: input?.request?.headers ?? {
'sentry-hook-resource': 'seer',
},
payload: payload ?? {},
})
return {
ok: true,
accepted: true,
dispatched: true,
issueId: groupId,
resource: resource ?? 'seer',
action,
workflowId: created.id,
workflowStatus: created.status ?? null,
workflowName: created.workflow_name,
idempotencyKey,
exportName: './process-sentry-webhook',
}
}
const issueId = extractIssueId(payload)
if (!issueId) {
return { ok: true, skipped: 'not-an-issue-event', resource, action }
}
// event_alert deliveries are normalized to action "created" in the
// processor; only skip clearly non-create issue actions here.
const normalizedAction =
!payload?.data?.issue?.id && payload?.data?.event?.issue_id
? 'created'
: action
if (normalizedAction !== 'created') {
return {
ok: true,
skipped: `action-${normalizedAction}`,
issueId,
resource,
action: normalizedAction,
}
}
if (payload?.kodyDryRun === true) {
return processSentryWebhook(input)
}
const idempotencyKey = await issueIdempotencyKey(issueId, payload ?? {})
const { created } = await dispatchDurableProcessor({
idempotencyKey,
issueId,
resource,
action: normalizedAction,
headers: input?.request?.headers ?? {
'sentry-hook-resource': 'issue',
},
payload: payload ?? {},
})
return {
ok: true,
accepted: true,
dispatched: true,
issueId,
resource,
action: normalizedAction,
workflowId: created.id,
workflowStatus: created.status ?? null,
workflowName: created.workflow_name,
idempotencyKey,
exportName: './process-sentry-webhook',
}
}