From 2cad2f7b25c5d7fef3d9cfb4739caf3c3f7b377b Mon Sep 17 00:00:00 2001 From: kasin-it Date: Tue, 5 May 2026 14:22:40 +0200 Subject: [PATCH] fix: slack command latency --- package.json | 1 + pnpm-lock.yaml | 29 ++++++--- src/lib/adapters.ts | 7 ++- src/lib/cancel-run.ts | 19 ++++++ src/lib/dispatch.test.ts | 6 ++ src/lib/slack/commands.ts | 10 ++- src/lib/slack/format.ts | 58 +++++++++++++++++- src/lib/slack/handlers.test.ts | 8 ++- src/lib/slack/handlers.ts | 85 +++++++++++++++++++++++++- src/routes/webhooks/slack.post.test.ts | 20 ++++-- src/routes/webhooks/slack.post.ts | 46 ++++++++------ 11 files changed, 247 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index 5b8b48c..8e73233 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@octokit/rest": "^22.0.1", "@t3-oss/env-core": "^0.13.10", "@upstash/redis": "^1.37.0", + "@vercel/functions": "^3.5.0", "@vercel/sandbox": "^1.8.1", "chat": "^4.20.2", "h3": "^1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a1cd3c..2fa6e2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@upstash/redis': specifier: ^1.37.0 version: 1.37.0 + '@vercel/functions': + specifier: ^3.5.0 + version: 3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13) '@vercel/sandbox': specifier: ^1.8.1 version: 1.8.1 @@ -34,7 +37,7 @@ importers: version: 1.15.9 nitropack: specifier: ^2 - version: 2.13.1(@upstash/redis@1.37.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(drizzle-orm@0.45.1(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)) + version: 2.13.1(@upstash/redis@1.37.0)(@vercel/functions@3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13))(drizzle-orm@0.45.1(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)) pino: specifier: ^10.3.1 version: 10.3.1 @@ -1304,8 +1307,8 @@ packages: '@vercel/cli-auth@0.0.1': resolution: {integrity: sha512-CnqiuMlZ4pjs2LCPYiR6aLKPPd3Xb8SBI1Y7eotXKgpx6qgrGNY+E7EIyUt5ErGHJGIrCZyGG5WEo4bHtVmz2Q==} - '@vercel/functions@3.4.3': - resolution: {integrity: sha512-kA14KIUVgAY6VXbhZ5jjY+s0883cV3cZqIU3WhrSRxuJ9KvxatMjtmzl0K23HK59oOUjYl7HaE/eYMmhmqpZzw==} + '@vercel/functions@3.5.0': + resolution: {integrity: sha512-+RokZ+4gkYyOsKBuJ29cQ8iSZG123LLJbZfPry20kkTgrN9U0277La4feP4DnWVo3sGoYa4plCEKY9XKUYoX9g==} engines: {node: '>= 20'} peerDependencies: '@aws-sdk/credential-provider-web-identity': '*' @@ -1322,6 +1325,10 @@ packages: resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} + '@vercel/oidc@3.4.0': + resolution: {integrity: sha512-p0sKfHkfRmMaqqDwNL4tjnX9TgRrLMlEtUjIxfrEns8pOxz1R9ztqOVI+ehqiq93/2/HnfPe/UBZkfAZwnx0UA==} + engines: {node: '>= 20'} + '@vercel/queue@0.1.4': resolution: {integrity: sha512-wo+jCycmCX078vQSbkX+RcLvySONDCK0f9aQp5UMKQD1+B+xKt3YVbIYbZukvoHQpbm5nnk6If+ADSeK/PmCgQ==} engines: {node: '>=20.0.0'} @@ -5576,9 +5583,9 @@ snapshots: xdg-app-paths: 5.1.0 zod: 4.1.11 - '@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13)': + '@vercel/functions@3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13)': dependencies: - '@vercel/oidc': 3.2.0 + '@vercel/oidc': 3.4.0 optionalDependencies: '@aws-sdk/credential-provider-web-identity': 3.972.13 @@ -5603,6 +5610,8 @@ snapshots: '@vercel/oidc@3.2.0': {} + '@vercel/oidc@3.4.0': {} + '@vercel/queue@0.1.4': dependencies: '@vercel/oidc': 3.2.0 @@ -5746,7 +5755,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 '@standard-schema/spec': 1.0.0 '@types/ms': 2.1.0 - '@vercel/functions': 3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13) + '@vercel/functions': 3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13) '@workflow/errors': 4.1.0-beta.18 '@workflow/serde': 4.1.0-beta.2 '@workflow/utils': 4.1.0-beta.13 @@ -7717,7 +7726,7 @@ snapshots: negotiator@0.6.3: {} - nitropack@2.13.1(@upstash/redis@1.37.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(drizzle-orm@0.45.1(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)): + nitropack@2.13.1(@upstash/redis@1.37.0)(@vercel/functions@3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13))(drizzle-orm@0.45.1(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.59.0) @@ -7784,7 +7793,7 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 5.7.0 unplugin-utils: 0.3.1 - unstorage: 1.17.4(@upstash/redis@1.37.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(db0@0.3.4(drizzle-orm@0.45.1(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)))(ioredis@5.10.1) + unstorage: 1.17.4(@upstash/redis@1.37.0)(@vercel/functions@3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13))(db0@0.3.4(drizzle-orm@0.45.1(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)))(ioredis@5.10.1) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.0 @@ -8767,7 +8776,7 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unstorage@1.17.4(@upstash/redis@1.37.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(db0@0.3.4(drizzle-orm@0.45.1(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)))(ioredis@5.10.1): + unstorage@1.17.4(@upstash/redis@1.37.0)(@vercel/functions@3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13))(db0@0.3.4(drizzle-orm@0.45.1(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)))(ioredis@5.10.1): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -8779,7 +8788,7 @@ snapshots: ufo: 1.6.3 optionalDependencies: '@upstash/redis': 1.37.0 - '@vercel/functions': 3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13) + '@vercel/functions': 3.5.0(@aws-sdk/credential-provider-web-identity@3.972.13) db0: 0.3.4(drizzle-orm@0.45.1(@types/pg@8.18.0)(@upstash/redis@1.37.0)(pg@8.20.0)(postgres@3.4.8)) ioredis: 5.10.1 diff --git a/src/lib/adapters.ts b/src/lib/adapters.ts index 9c0a646..e061646 100644 --- a/src/lib/adapters.ts +++ b/src/lib/adapters.ts @@ -6,13 +6,16 @@ import { createVCS } from "./create-vcs.js"; import type { IssueTrackerAdapter } from "../adapters/issue-tracker/types.js"; import type { VCSAdapter } from "../adapters/vcs/types.js"; import type { MessagingAdapter } from "../adapters/messaging/types.js"; -import type { RunRegistryAdapter } from "../adapters/run-registry/types.js"; +import type { + RunRegistryAdapter, + ThreadStore, +} from "../adapters/run-registry/types.js"; export interface Adapters { issueTracker: IssueTrackerAdapter; vcs: VCSAdapter; messaging: MessagingAdapter; - runRegistry: RunRegistryAdapter; + runRegistry: RunRegistryAdapter & ThreadStore; } export function createAdapters(): Adapters { diff --git a/src/lib/cancel-run.ts b/src/lib/cancel-run.ts index 491b8d6..059c7dc 100644 --- a/src/lib/cancel-run.ts +++ b/src/lib/cancel-run.ts @@ -1,17 +1,24 @@ import { getRun } from "workflow/api"; import { logger } from "./logger.js"; import type { RunRegistryAdapter } from "../adapters/run-registry/types.js"; +import type { IssueTrackerAdapter } from "../adapters/issue-tracker/types.js"; import { stopTicketSandboxes } from "../sandbox/stop-ticket-sandboxes.js"; /** * Cancel a workflow run and unregister it from the registry. * Idempotent: safe to call multiple times for the same ticket. * Returns true if cancel succeeded, false if it errored (still unregisters). + * + * If `issueTracker` and `targetColumn` are provided, also transitions the + * ticket out of its current column. Without this, the cron sees the ticket + * still in COLUMN_AI on the next tick and re-dispatches a fresh run. */ export async function cancelRun( ticketKey: string, runId: string, runRegistry: RunRegistryAdapter, + issueTracker?: IssueTrackerAdapter, + targetColumn?: string, ): Promise { let cancelled = false; try { @@ -32,5 +39,17 @@ export async function cancelRun( const sandboxId = await runRegistry.getSandboxId(ticketKey).catch(() => null); await stopTicketSandboxes(ticketKey, sandboxId).catch(() => {}); await runRegistry.unregister(ticketKey).catch(() => {}); + + if (issueTracker && targetColumn) { + try { + await issueTracker.moveTicket(ticketKey, targetColumn); + } catch (err) { + logger.warn( + { ticketKey, targetColumn, error: (err as Error).message }, + "cancel_run_move_ticket_failed", + ); + } + } + return cancelled; } diff --git a/src/lib/dispatch.test.ts b/src/lib/dispatch.test.ts index f62d745..4629635 100644 --- a/src/lib/dispatch.test.ts +++ b/src/lib/dispatch.test.ts @@ -95,6 +95,9 @@ function makeAdapters( isTicketFailed: overrides.isTicketFailed ?? vi.fn().mockResolvedValue(false), listAllFailed: vi.fn().mockResolvedValue([]), clearFailedMark: vi.fn().mockResolvedValue(undefined), + getParent: vi.fn().mockResolvedValue(null), + setParent: vi.fn().mockResolvedValue(undefined), + clearParent: vi.fn().mockResolvedValue(undefined), }, }; } @@ -451,6 +454,9 @@ describe("failed-ticket safeguard full loop", () => { clearFailedMark: vi.fn().mockImplementation(async (key: string) => { failedMarkers.delete(key); }), + getParent: vi.fn().mockResolvedValue(null), + setParent: vi.fn().mockResolvedValue(undefined), + clearParent: vi.fn().mockResolvedValue(undefined), }; const adapters = makeAdapters(); diff --git a/src/lib/slack/commands.ts b/src/lib/slack/commands.ts index aa0dfcb..ac959ee 100644 --- a/src/lib/slack/commands.ts +++ b/src/lib/slack/commands.ts @@ -4,6 +4,8 @@ export type ParsedCommand = | { kind: "list" } | { kind: "status"; ticketKey: string } | { kind: "cancel"; ticketKey: string } + | { kind: "inspect"; ticketKey: string | null } + | { kind: "reset"; ticketKey: string } | { kind: "help" } | { kind: "unknown"; raw: string }; @@ -18,12 +20,18 @@ export function parseCommand(text: string): ParsedCommand { if (verb === "help") return { kind: "help" }; if (verb === "list") return { kind: "list" }; - if (verb === "status" || verb === "cancel") { + if (verb === "status" || verb === "cancel" || verb === "reset") { if (arg && TICKET_KEY_RE.test(arg)) { return { kind: verb, ticketKey: arg }; } return { kind: "unknown", raw: trimmed }; } + if (verb === "inspect") { + if (!arg) return { kind: "inspect", ticketKey: null }; + if (TICKET_KEY_RE.test(arg)) return { kind: "inspect", ticketKey: arg }; + return { kind: "unknown", raw: trimmed }; + } + return { kind: "unknown", raw: trimmed }; } diff --git a/src/lib/slack/format.ts b/src/lib/slack/format.ts index 817ee84..95f99e5 100644 --- a/src/lib/slack/format.ts +++ b/src/lib/slack/format.ts @@ -1,3 +1,5 @@ +import type { FailedTicketMeta } from "../../adapters/run-registry/types.js"; + export interface RunRow { ticketKey: string; runId: string; @@ -8,6 +10,14 @@ export interface RunStatusSnapshot { sandboxId: string | null; } +export interface InspectTicketSnapshot { + runId: string | null; + sandboxId: string | null; + entryCreatedAt: number | null; + threadParent: string | null; + isFailed: boolean; +} + export function formatRunList(rows: RunRow[], jiraBaseUrl: string): string { if (rows.length === 0) return "No active workflows."; return rows @@ -26,11 +36,57 @@ export function formatRunStatus( return `${link}: runId \`${snapshot.runId}\`, sandbox: ${sandbox}`; } +export function formatInspectTicket( + ticketKey: string, + jiraBaseUrl: string, + snap: InspectTicketSnapshot, +): string { + const link = jiraLink(ticketKey, jiraBaseUrl); + const lines: string[] = [`*Inspect ${link}*`]; + lines.push(`• runId: ${snap.runId ? `\`${snap.runId}\`` : "_none_"}`); + lines.push(`• sandboxId: ${snap.sandboxId ? `\`${snap.sandboxId}\`` : "_none_"}`); + lines.push( + `• entryCreatedAt: ${snap.entryCreatedAt ? new Date(snap.entryCreatedAt).toISOString() : "_none_"}`, + ); + lines.push(`• threadParent: ${snap.threadParent ? `\`${snap.threadParent}\`` : "_none_"}`); + lines.push(`• failed: ${snap.isFailed ? "yes" : "no"}`); + return lines.join("\n"); +} + +export function formatInspectAll( + active: RunRow[], + failed: Array<{ ticketKey: string; meta: FailedTicketMeta }>, + jiraBaseUrl: string, +): string { + const lines: string[] = ["*Redis snapshot*"]; + lines.push(`*Active runs (${active.length}):*`); + if (active.length === 0) { + lines.push("• _none_"); + } else { + for (const { ticketKey, runId } of active) { + lines.push(`• ${jiraLink(ticketKey, jiraBaseUrl)} — \`${runId}\``); + } + } + lines.push(`*Failed markers (${failed.length}):*`); + if (failed.length === 0) { + lines.push("• _none_"); + } else { + for (const { ticketKey, meta } of failed) { + lines.push( + `• ${jiraLink(ticketKey, jiraBaseUrl)} — \`${meta.runId}\` (${meta.failedAt})`, + ); + } + } + return lines.join("\n"); +} + export const HELP_TEXT = [ "*Blazebot commands*", "• `/ai-workflow list` — show every tracked workflow", "• `/ai-workflow status ` — show the run + sandbox tied to a ticket", - "• `/ai-workflow cancel ` — cancel the workflow run for a ticket", + "• `/ai-workflow cancel ` — cancel the workflow run + move ticket to backlog", + "• `/ai-workflow inspect [KEY]` — dump Redis state for a ticket, or summary across all hashes", + "• `/ai-workflow reset ` — clear Redis entries for a ticket (does NOT cancel the run)", ].join("\n"); function jiraLink(ticketKey: string, jiraBaseUrl: string): string { diff --git a/src/lib/slack/handlers.test.ts b/src/lib/slack/handlers.test.ts index 6ccd4db..252623e 100644 --- a/src/lib/slack/handlers.test.ts +++ b/src/lib/slack/handlers.test.ts @@ -133,7 +133,13 @@ describe("handleCancel", () => { cancelRunFn, stopSandboxes, ); - expect(cancelRunFn).toHaveBeenCalledWith("AWT-1", "run_a", registry); + expect(cancelRunFn).toHaveBeenCalledWith( + "AWT-1", + "run_a", + registry, + undefined, + undefined, + ); expect(out).toContain("Cancelled"); expect(out).toContain("AWT-1"); }); diff --git a/src/lib/slack/handlers.ts b/src/lib/slack/handlers.ts index c9e9514..1a53c5c 100644 --- a/src/lib/slack/handlers.ts +++ b/src/lib/slack/handlers.ts @@ -1,12 +1,23 @@ -import type { RunRegistryAdapter } from "../../adapters/run-registry/types.js"; +import type { + RunRegistryAdapter, + ThreadStore, +} from "../../adapters/run-registry/types.js"; +import type { IssueTrackerAdapter } from "../../adapters/issue-tracker/types.js"; import { isClaimingSentinel } from "../dispatch.js"; import { logger } from "../logger.js"; -import { formatRunList, formatRunStatus } from "./format.js"; +import { + formatInspectAll, + formatInspectTicket, + formatRunList, + formatRunStatus, +} from "./format.js"; export type CancelRunFn = ( ticketKey: string, runId: string, registry: RunRegistryAdapter, + issueTracker?: IssueTrackerAdapter, + targetColumn?: string, ) => Promise; export type StopTicketSandboxesFn = ( @@ -48,6 +59,8 @@ export async function handleCancel( ticketKey: string, cancelRunFn: CancelRunFn, stopSandboxes: StopTicketSandboxesFn, + issueTracker?: IssueTrackerAdapter, + targetColumn?: string, ): Promise { const runId = await registry.getRunId(ticketKey); if (!runId) return `No active run for ${ticketKey}.`; @@ -93,7 +106,73 @@ export async function handleCancel( return `${ticketKey} is mid-dispatch; cleared the claim. Try the cancel again in a moment if a real run shows up.`; } - const ok = await cancelRunFn(ticketKey, runId, registry); + const ok = await cancelRunFn(ticketKey, runId, registry, issueTracker, targetColumn); if (ok) return `Cancelled ${ticketKey} (runId \`${runId}\`).`; return `${ticketKey}: could not cancel run \`${runId}\` cleanly — sandbox + registry have been cleaned up.`; } + +export async function handleInspect( + registry: RunRegistryAdapter & ThreadStore, + ticketKey: string | null, + jiraBaseUrl: string, +): Promise { + if (ticketKey) { + const [runId, sandboxId, entryCreatedAt, threadParent, isFailed] = + await Promise.all([ + registry.getRunId(ticketKey).catch(() => null), + registry.getSandboxId(ticketKey).catch(() => null), + registry.getEntryCreatedAt(ticketKey).catch(() => null), + registry.getParent(ticketKey).catch(() => null), + registry.isTicketFailed(ticketKey).catch(() => false), + ]); + return formatInspectTicket(ticketKey, jiraBaseUrl, { + runId, + sandboxId, + entryCreatedAt, + threadParent, + isFailed, + }); + } + + const [active, failed] = await Promise.all([ + registry.listAll().catch(() => []), + registry.listAllFailed().catch(() => []), + ]); + return formatInspectAll(active, failed, jiraBaseUrl); +} + +export async function handleReset( + registry: RunRegistryAdapter & ThreadStore, + ticketKey: string, +): Promise { + const cleared: string[] = []; + const failures: string[] = []; + + try { + await registry.unregister(ticketKey); + cleared.push("active+sandbox+entry-ts"); + } catch (err) { + failures.push(`unregister: ${(err as Error).message}`); + } + try { + await registry.clearFailedMark(ticketKey); + cleared.push("failed-mark"); + } catch (err) { + failures.push(`clearFailedMark: ${(err as Error).message}`); + } + try { + await registry.clearParent(ticketKey); + cleared.push("thread-parent"); + } catch (err) { + failures.push(`clearParent: ${(err as Error).message}`); + } + + if (failures.length > 0) { + logger.warn( + { ticketKey, failures }, + "slack_reset_partial", + ); + return `${ticketKey}: partial reset. Cleared ${cleared.join(", ")}. Failed: ${failures.join("; ")}.`; + } + return `${ticketKey}: reset Redis entries (${cleared.join(", ")}). Workflow run was NOT cancelled — use \`cancel\` for that.`; +} diff --git a/src/routes/webhooks/slack.post.test.ts b/src/routes/webhooks/slack.post.test.ts index aeb9ff7..5d928b6 100644 --- a/src/routes/webhooks/slack.post.test.ts +++ b/src/routes/webhooks/slack.post.test.ts @@ -11,6 +11,7 @@ vi.mock("../../../env.js", () => ({ SLACK_SIGNING_SECRET: SIGNING_SECRET, SLACK_ALLOWED_USER_IDS: undefined as string | undefined, JIRA_BASE_URL, + COLUMN_BACKLOG: "Backlog", }, })); @@ -23,11 +24,14 @@ const runRegistry = { listAll: vi.fn(), registerSandbox: vi.fn(), getSandboxId: vi.fn().mockResolvedValue(null), - getEntryCreatedAt: vi.fn(), + getEntryCreatedAt: vi.fn().mockResolvedValue(null), markFailed: vi.fn(), - isTicketFailed: vi.fn(), - listAllFailed: vi.fn(), - clearFailedMark: vi.fn(), + isTicketFailed: vi.fn().mockResolvedValue(false), + listAllFailed: vi.fn().mockResolvedValue([]), + clearFailedMark: vi.fn().mockResolvedValue(undefined), + getParent: vi.fn().mockResolvedValue(null), + setParent: vi.fn().mockResolvedValue(undefined), + clearParent: vi.fn().mockResolvedValue(undefined), }; vi.mock("../../lib/adapters.js", () => ({ createAdapters: () => ({ @@ -218,7 +222,13 @@ describe("POST /webhooks/slack", () => { await flushDeferred(); expect(cancelRunFn).toHaveBeenCalledTimes(1); - expect(cancelRunFn).toHaveBeenCalledWith("AWT-1", "run_a", runRegistry); + expect(cancelRunFn).toHaveBeenCalledWith( + "AWT-1", + "run_a", + runRegistry, + expect.anything(), + "Backlog", + ); expect(postedToResponseUrl).toHaveLength(1); expect(postedToResponseUrl[0]!.payload.text).toContain("Cancelled AWT-1"); }); diff --git a/src/routes/webhooks/slack.post.ts b/src/routes/webhooks/slack.post.ts index dd4af4b..0e014c9 100644 --- a/src/routes/webhooks/slack.post.ts +++ b/src/routes/webhooks/slack.post.ts @@ -1,4 +1,5 @@ import { defineEventHandler, readRawBody, getHeader, createError, type H3Event } from "h3"; +import { waitUntil } from "@vercel/functions"; import { env } from "../../../env.js"; import { createAdapters } from "../../lib/adapters.js"; import { cancelRun } from "../../lib/cancel-run.js"; @@ -8,7 +9,9 @@ import { parseCommand, type ParsedCommand } from "../../lib/slack/commands.js"; import { HELP_TEXT } from "../../lib/slack/format.js"; import { handleCancel, + handleInspect, handleList, + handleReset, handleStatus, } from "../../lib/slack/handlers.js"; import { postToResponseUrl } from "../../lib/slack/respond.js"; @@ -62,7 +65,7 @@ export default defineEventHandler(async (event) => { "slack_command_dispatching", ); - scheduleHandler(event, parsed, responseUrl); + scheduleHandler(parsed, responseUrl); return ephemeral(`Working on \`${command} ${text}\`…`); }); @@ -118,28 +121,21 @@ function ephemeral(text: string) { // Deferred work // --------------------------------------------------------------------------- -function scheduleHandler( - event: H3Event, - parsed: ParsedCommand, - responseUrl: string, -): void { - // Always attach error logging *before* handing the promise off — otherwise an - // unhandled rejection inside the waitUntil-extended invocation disappears - // silently. The .catch returns void, so waitUntil still sees a settled - // promise and keeps the function alive for the original work. +function scheduleHandler(parsed: ParsedCommand, responseUrl: string): void { + // Attach error logging before handing off — an unhandled rejection inside + // the waitUntil-extended invocation would disappear silently otherwise. const promise = runHandler(parsed, responseUrl).catch((err) => logger.error( { error: (err as Error).message, parsedKind: parsed.kind }, "slack_handler_unhandled_error", ), ); - // Nitro mounts waitUntil onto the event in its app entry. On platforms that - // support it (Vercel, Cloudflare) this lets the function keep working after - // the response is sent. On platforms without it, fall back to fire-and- - // forget — the slash command will still ack within 3s. - if (typeof event.waitUntil === "function") { - event.waitUntil(promise); - } + // @vercel/functions waitUntil is the documented Vercel-native API. It keeps + // the serverless invocation alive until the promise resolves, even after + // the response is sent. Outside a Vercel runtime (tests, dev), getContext() + // returns no waitUntil and this no-ops — the promise still runs in the + // microtask queue. + waitUntil(promise); } async function runHandler(parsed: ParsedCommand, responseUrl: string): Promise { @@ -151,14 +147,26 @@ async function runHandler(parsed: ParsedCommand, responseUrl: string): Promise { - const { runRegistry } = createAdapters(); + const adapters = createAdapters(); + const { runRegistry, issueTracker } = adapters; switch (parsed.kind) { case "list": return handleList(runRegistry, env.JIRA_BASE_URL); case "status": return handleStatus(runRegistry, parsed.ticketKey, env.JIRA_BASE_URL); case "cancel": - return handleCancel(runRegistry, parsed.ticketKey, cancelRun, stopTicketSandboxes); + return handleCancel( + runRegistry, + parsed.ticketKey, + cancelRun, + stopTicketSandboxes, + issueTracker, + env.COLUMN_BACKLOG, + ); + case "inspect": + return handleInspect(runRegistry, parsed.ticketKey, env.JIRA_BASE_URL); + case "reset": + return handleReset(runRegistry, parsed.ticketKey); case "help": case "unknown": // Already handled synchronously, but exhaustive for type-narrowing.