From a90bed0e714d642636317484b6ff3b723ff922a3 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Tue, 23 Jun 2026 19:10:19 -0700 Subject: [PATCH 01/19] feat(slack): trigger automations from watched channel messages Add a `slack` automation event source so messages posted in watched Slack channels can auto-trigger coding sessions, extending the existing automation engine rather than forking a parallel path. Flow: the slack-bot filters ambient channel messages (kill switch, bot-mention suppression, watched-channel pre-filter), normalizes them, and forwards to the control plane's /internal/slack-event endpoint. The scheduler selects candidates by channel (new automation_slack_channels join table), evaluates conditions (text contains/exact/regex, slack_channel, slack_actor), enforces a per-automation hourly rate limit and per-thread concurrency, dispatches a session, and posts the result back into the originating thread. - shared: `slack` event source, SlackAutomationEvent + normalizer, and the three condition handlers, co-located under triggers/slack/. - control-plane: D1 migration 0025 (join table + run thread coordinates + blast-radius columns), store queries, scheduler slack handling, and the /internal/slack-event forwarding route. - slack-bot: channel-trigger ingress, bot-identity resolution (fail-closed auth.test cache), watched-channel cache, and thread-reply / concurrency-skip callbacks. - web: slack trigger type, condition editors, reply-in-thread + max-runs/hour controls, and an auto-triage template. Ships dark behind SLACK_TRIGGERS_ENABLED (Terraform var, default false). Structural cleanups landed alongside (behavior-preserving): a shared createAutomationEventRoute factory backing the github + slack webhook routes; a generic two-tier cache fetcher behind getRoutingRules/getWatchedChannels; a single rejectInvalidCallback guard for the signed callback routes; channel ingress extracted to its own module; and a shared isDuplicateKeyError / SlackRunColumns contract in the DB layer. --- docs/AUTOMATIONS.md | 71 +++- docs/integrations/SLACK.md | 66 +++- .../src/db/automation-store.test.ts | 12 + .../control-plane/src/db/automation-store.ts | 148 +++++++- .../control-plane/src/routes/automations.ts | 89 +++++ .../src/scheduler/durable-object.ts | 184 ++++++++- .../src/scheduler/slack-completion.test.ts | 99 +++++ .../src/scheduler/slack-completion.ts | 75 ++++ .../src/webhooks/automation-event.ts | 92 +++++ packages/control-plane/src/webhooks/github.ts | 88 +---- packages/control-plane/src/webhooks/index.ts | 2 + packages/control-plane/src/webhooks/slack.ts | 20 + .../test/integration/automation-store.test.ts | 171 +++++++++ .../automations-slack-route.test.ts | 173 +++++++++ .../control-plane/test/integration/cleanup.ts | 2 +- .../scheduler-slack-events.test.ts | 285 ++++++++++++++ .../test/integration/webhooks-slack.test.ts | 157 ++++++++ packages/shared/src/slack/client.ts | 38 ++ packages/shared/src/slack/index.ts | 10 +- packages/shared/src/slack/mrkdwn.test.ts | 12 + packages/shared/src/slack/mrkdwn.ts | 2 +- packages/shared/src/triggers/conditions.ts | 11 + packages/shared/src/triggers/index.ts | 13 + packages/shared/src/triggers/registry.ts | 3 + .../src/triggers/slack/conditions.test.ts | 216 +++++++++++ .../shared/src/triggers/slack/conditions.ts | 101 +++++ packages/shared/src/triggers/slack/index.ts | 29 ++ .../src/triggers/slack/normalizer.test.ts | 82 ++++ .../shared/src/triggers/slack/normalizer.ts | 87 +++++ packages/shared/src/triggers/testing.ts | 14 + packages/shared/src/triggers/types.ts | 19 +- packages/shared/src/types/index.ts | 11 +- packages/slack-bot/src/bot-identity.test.ts | 72 ++++ packages/slack-bot/src/bot-identity.ts | 80 ++++ packages/slack-bot/src/callbacks.test.ts | 135 +++++++ packages/slack-bot/src/callbacks.ts | 355 ++++++++++++++---- .../slack-bot/src/channel-trigger.test.ts | 231 ++++++++++++ packages/slack-bot/src/channel-trigger.ts | 174 +++++++++ .../slack-bot/src/classifier/repos.test.ts | 61 ++- packages/slack-bot/src/classifier/repos.ts | 164 +++++--- packages/slack-bot/src/dm-utils.test.ts | 69 +++- packages/slack-bot/src/dm-utils.ts | 48 +++ packages/slack-bot/src/index.ts | 21 +- packages/slack-bot/src/internal-auth.ts | 14 + packages/slack-bot/src/types/index.ts | 6 + .../app/(app)/automations/[id]/edit/page.tsx | 2 + .../src/app/(app)/automations/[id]/page.tsx | 1 + .../automations/automation-form.test.tsx | 113 ++++++ .../automations/automation-form.tsx | 60 +++ .../automations/condition-builder.test.tsx | 55 +++ .../automations/condition-builder.tsx | 97 +++++ .../automations/trigger-type-selector.tsx | 5 + .../web/src/lib/automation-templates.test.ts | 8 +- packages/web/src/lib/automation-templates.ts | 37 ++ .../d1/migrations/0025_slack_triggers.sql | 33 ++ .../environments/production/variables.tf | 6 + .../environments/production/workers-slack.tf | 3 + 57 files changed, 3994 insertions(+), 238 deletions(-) create mode 100644 packages/control-plane/src/scheduler/slack-completion.test.ts create mode 100644 packages/control-plane/src/scheduler/slack-completion.ts create mode 100644 packages/control-plane/src/webhooks/automation-event.ts create mode 100644 packages/control-plane/src/webhooks/slack.ts create mode 100644 packages/control-plane/test/integration/automations-slack-route.test.ts create mode 100644 packages/control-plane/test/integration/scheduler-slack-events.test.ts create mode 100644 packages/control-plane/test/integration/webhooks-slack.test.ts create mode 100644 packages/shared/src/triggers/slack/conditions.test.ts create mode 100644 packages/shared/src/triggers/slack/conditions.ts create mode 100644 packages/shared/src/triggers/slack/index.ts create mode 100644 packages/shared/src/triggers/slack/normalizer.test.ts create mode 100644 packages/shared/src/triggers/slack/normalizer.ts create mode 100644 packages/slack-bot/src/bot-identity.test.ts create mode 100644 packages/slack-bot/src/bot-identity.ts create mode 100644 packages/slack-bot/src/channel-trigger.test.ts create mode 100644 packages/slack-bot/src/channel-trigger.ts create mode 100644 packages/slack-bot/src/internal-auth.ts create mode 100644 packages/web/src/components/automations/condition-builder.test.tsx create mode 100644 terraform/d1/migrations/0025_slack_triggers.sql diff --git a/docs/AUTOMATIONS.md b/docs/AUTOMATIONS.md index 65f349110..7313a3cbb 100644 --- a/docs/AUTOMATIONS.md +++ b/docs/AUTOMATIONS.md @@ -6,13 +6,14 @@ session whenever the trigger fires. Trigger types: -| Trigger Type | Description | Availability | -| ------------------- | ----------------------------------------- | ------------ | -| **Schedule** | Run on a cron schedule | Available | -| **Inbound Webhook** | Trigger from any system with an HTTP POST | Available | -| **Sentry Alert** | Trigger from a Sentry Custom Integration | Available | -| **GitHub Event** | Trigger on GitHub activity | Planned | -| **Linear Event** | Trigger on Linear activity | Planned | +| Trigger Type | Description | Availability | +| ------------------- | ----------------------------------------- | ------------------ | +| **Schedule** | Run on a cron schedule | Available | +| **Inbound Webhook** | Trigger from any system with an HTTP POST | Available | +| **Sentry Alert** | Trigger from a Sentry Custom Integration | Available | +| **Slack Message** | Trigger on messages in watched channels | Available (opt-in) | +| **GitHub Event** | Trigger on GitHub activity | Planned | +| **Linear Event** | Trigger on Linear activity | Planned | Common use cases include nightly dependency updates, reacting to deploy or incident events, triaging new Sentry issues, and recurring report generation. @@ -45,11 +46,12 @@ Start by choosing a **Trigger Type**. The rest of the form adjusts based on that ### Trigger-Specific Fields -| Trigger Type | Additional Fields | -| ------------------- | ------------------------------------------- | -| **Schedule** | **Schedule** and **Timezone** | -| **Inbound Webhook** | No extra required fields | -| **Sentry Alert** | **Event Type** and **Sentry Client Secret** | +| Trigger Type | Additional Fields | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| **Schedule** | **Schedule** and **Timezone** | +| **Inbound Webhook** | No extra required fields | +| **Sentry Alert** | **Event Type** and **Sentry Client Secret** | +| **Slack Message** | **Conditions** (a Slack Channel and a Message Text condition are required), **Reply in thread**, **Max runs per hour** | For non-schedule automations, schedule fields are not used. @@ -192,6 +194,51 @@ concurrency protection. --- +## Slack Message Triggers + +A **Slack Message** automation starts a session when someone posts a matching message in a watched +Slack channel. Unlike `@mention` sessions (which are explicit, interactive requests), these triggers +fire on ambient channel messages that match the conditions you define. + +This source is opt-in per deployment and ships **disabled by default**. Enabling it requires the +operator to set the `SLACK_TRIGGERS_ENABLED` flag and configure the Slack app — see +[the Slack integration guide](integrations/SLACK.md#channel-message-triggers) for setup and the +threat model. The web form and these conditions are always available to author; messages are only +ingested once the flag is on. + +### Required conditions + +A Slack automation must define both: + +- **Slack Channel** — one or more channel IDs (for example `C0123ABCD`) to watch. Only messages in + these channels are considered; the bot must be a member of each. +- **Message Text** — how the message text is matched. Pick a mode: + - **contains** — the message contains the substring (optionally case-insensitive). + - **exact** — the message equals the text. + - **regex** — the message matches a regular expression. Patterns are capped in length and limited + to the `i` and `m` flags; an invalid pattern is rejected when you save. + +Optionally add a **Slack User** condition to include or exclude specific Slack user IDs (an +allowlist is the recommended way to limit who can trigger a run). + +A message runs the automation only when **every** condition passes. The bot-mention token is +stripped before matching, and messages that `@mention` the bot are handled by the interactive +`@mention` flow instead — they never double-fire as triggers. + +### Delivery and rate behavior + +| Field | Behavior | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Reply in thread** | When on (default), the run result is posted back into the originating message's thread. | +| **Max runs per hour** | Caps how many runs this automation starts per hour. Extra matching messages are recorded as a skipped run with reason `rate_limited` and start no session. Leave blank to use the default. | + +While a run is active for a thread, another matching message in that same thread is skipped (reason +`concurrent_run_active`) and the author receives an ephemeral "a run is already active" notice. A +triggering message is marked with the 👀 reaction while its run is in flight; the reaction is +cleared when the result is posted. + +--- + ## Schedule Options The schedule picker offers four presets and a custom mode: diff --git a/docs/integrations/SLACK.md b/docs/integrations/SLACK.md index 1cd904f1e..27f9d42a5 100644 --- a/docs/integrations/SLACK.md +++ b/docs/integrations/SLACK.md @@ -37,9 +37,13 @@ notification controls and safety notes are covered near the end. | Set personal defaults | Use the Slack app's **Home** tab for model, reasoning effort, and branch | | Follow the result | Read the completion reply or open the full session with **View Session** | | Ask the agent to post Slack | Enable agent notifications, then explicitly ask the agent to post to Slack | +| Auto-trigger from a channel | Opt-in: watch a channel so matching messages start an automation | -Open-Inspect does not use slash commands today. In channels, it responds to `@mentions`, not every -message posted in the channel. +Open-Inspect does not use slash commands today. In channels, it normally responds only to +`@mentions`, not to every message. The optional +[channel-message triggers](#channel-message-triggers) feature can additionally start an +**automation** from non-mention messages that match conditions you configure; it is disabled by +default and must be enabled by an operator. --- @@ -220,6 +224,64 @@ always stripped from agent notification messages. --- +## Channel Message Triggers + +Channel message triggers let an **automation** start a session when someone posts a matching message +in a watched channel — without `@mentioning` the bot. This is distinct from the interactive +`@mention` flow: it is driven by [automations](../AUTOMATIONS.md#slack-message-triggers) with +keyword, substring, or regex conditions. + +The feature is **disabled by default** and gated by the `SLACK_TRIGGERS_ENABLED` deployment flag. +When the flag is off, the bot ignores channel messages and forwards nothing; authoring a Slack +automation in the web app is still allowed, but it will not run until the flag is enabled. + +### Slack app setup + +In addition to the standard event subscription the bot already uses, enable the bot to receive +ordinary channel messages: + +- **Event subscriptions**: subscribe to `message.channels` (public channels). Add `message.groups` + if you also want to watch private channels. +- **Bot token scopes**: `channels:history` (public) and, for private channels, `groups:history`. +- Invite the bot to every channel you intend to watch. The bot only sees messages in channels it is + a member of. + +Then, in the web app, create a **Slack Message** automation, add a **Slack Channel** condition with +the channel ID(s), and a **Message Text** condition. See +[Slack Message Triggers](../AUTOMATIONS.md#slack-message-triggers) for the full field reference. + +### Reply and rate behavior + +- The run result is posted back into the originating thread when **Reply in thread** is on + (default). +- A triggering message gets a 👀 reaction while its run is in flight; it is cleared when the result + posts. +- **Max runs per hour** caps how often an automation fires; messages over the cap are skipped + (recorded as `rate_limited`) and start no session. +- A new matching message in a thread that already has an active run is skipped and the author gets + an ephemeral "a run is already active" notice. + +### Threat model + +Channel triggers widen who can start a coding session, so weigh the following before enabling them: + +- **Any member of a watched channel can trigger a run** simply by posting a matching message. Treat + every watched channel as a list of people authorized to start sessions against the automation's + repository. +- **Prefer an allowlist.** Add a **Slack User** condition (`include`) so only specific people can + trigger the automation, and keep watched channels small and trusted. +- **Message text reaches the agent.** The triggering message becomes part of the prompt. Scope the + automation's instructions defensively and rely on the deployment's repository access boundary — + the same GitHub App installation limits used elsewhere apply here too. +- **Regex conditions run untimed.** Conditions are evaluated with the native regex engine and no + per-match timeout; a pathological pattern is an operator-authored risk. Patterns are length-capped + and validated at save time, and the `SLACK_TRIGGERS_ENABLED` flag is the kill switch if a bad + pattern degrades automation dispatch. +- **The kill switch is immediate.** Setting `SLACK_TRIGGERS_ENABLED` back to `false` stops the bot + from ingesting or forwarding channel messages right away. + +--- + ## Admin and Safety Notes These notes are most useful for workspace admins deciding where the Slack bot should be available. diff --git a/packages/control-plane/src/db/automation-store.test.ts b/packages/control-plane/src/db/automation-store.test.ts index 6e612a0c3..2924b3df3 100644 --- a/packages/control-plane/src/db/automation-store.test.ts +++ b/packages/control-plane/src/db/automation-store.test.ts @@ -142,6 +142,18 @@ describe("toAutomation", () => { const automation = toAutomation({ ...sampleRow, enabled: 0 }); expect(automation.enabled).toBe(false); }); + + it("defaults the slack knobs (null cap, reply-in-thread on) when unset", () => { + const automation = toAutomation(sampleRow); + expect(automation.maxRunsPerHour).toBeNull(); + expect(automation.replyInThread).toBe(true); + }); + + it("maps explicit slack knobs (cap + reply_in_thread=0 → false)", () => { + const automation = toAutomation({ ...sampleRow, max_runs_per_hour: 5, reply_in_thread: 0 }); + expect(automation.maxRunsPerHour).toBe(5); + expect(automation.replyInThread).toBe(false); + }); }); describe("toAutomationRun", () => { diff --git a/packages/control-plane/src/db/automation-store.ts b/packages/control-plane/src/db/automation-store.ts index 88a700403..0cc152880 100644 --- a/packages/control-plane/src/db/automation-store.ts +++ b/packages/control-plane/src/db/automation-store.ts @@ -33,6 +33,10 @@ export interface AutomationRow { event_type: string | null; trigger_config: string | null; // JSON-serialized TriggerConfig trigger_auth_data: string | null; + /** Slack triggers (#716): per-automation hourly run cap (null = app default). */ + max_runs_per_hour?: number | null; + /** Slack triggers (#716): post the run result back into the thread (1/0). */ + reply_in_thread?: number; } export interface AutomationRunRow { @@ -48,6 +52,11 @@ export interface AutomationRunRow { created_at: number; trigger_key: string | null; concurrency_key: string | null; + // Slack triggers (#716): thread coordinates + posting actor (slack-origin runs only). + slack_channel?: string | null; + slack_thread_ts?: string | null; + slack_message_ts?: string | null; + actor_user_id?: string | null; } export interface EnrichedRunRow extends AutomationRunRow { @@ -55,6 +64,12 @@ export interface EnrichedRunRow extends AutomationRunRow { artifact_summary: string | null; } +/** The slack thread-coordinate columns of a run row, set together for slack-origin runs. */ +export type SlackRunColumns = Pick< + AutomationRunRow, + "slack_channel" | "slack_thread_ts" | "slack_message_ts" | "actor_user_id" +>; + // ─── Mappers ───────────────────────────────────────────────────────────────── export function toAutomation(row: AutomationRow): Automation { @@ -80,6 +95,8 @@ export function toAutomation(row: AutomationRow): Automation { deletedAt: row.deleted_at, eventType: row.event_type ?? null, triggerConfig: row.trigger_config ? JSON.parse(row.trigger_config) : null, + maxRunsPerHour: row.max_runs_per_hour ?? null, + replyInThread: row.reply_in_thread == null ? true : row.reply_in_thread === 1, }; } @@ -116,8 +133,8 @@ export class AutomationStore { (id, name, repo_owner, repo_name, base_branch, repo_id, instructions, trigger_type, schedule_cron, schedule_tz, model, reasoning_effort, enabled, next_run_at, consecutive_failures, created_by, user_id, created_at, updated_at, deleted_at, - event_type, trigger_config, trigger_auth_data) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + event_type, trigger_config, trigger_auth_data, max_runs_per_hour, reply_in_thread) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( row.id, @@ -142,7 +159,9 @@ export class AutomationStore { row.deleted_at, row.event_type, row.trigger_config, - row.trigger_auth_data + row.trigger_auth_data, + row.max_runs_per_hour ?? null, + row.reply_in_thread ?? 1 ) .run(); } @@ -198,6 +217,8 @@ export class AutomationStore { "event_type", "trigger_config", "trigger_auth_data", + "max_runs_per_hour", + "reply_in_thread", ]; for (const field of allowedFields) { @@ -291,8 +312,9 @@ export class AutomationStore { .prepare( `INSERT INTO automation_runs (id, automation_id, session_id, status, skip_reason, failure_reason, - scheduled_at, started_at, completed_at, created_at, trigger_key, concurrency_key) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + scheduled_at, started_at, completed_at, created_at, trigger_key, concurrency_key, + slack_channel, slack_thread_ts, slack_message_ts, actor_user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( run.id, @@ -306,7 +328,11 @@ export class AutomationStore { run.completed_at, run.created_at, run.trigger_key ?? null, - run.concurrency_key ?? null + run.concurrency_key ?? null, + run.slack_channel ?? null, + run.slack_thread_ts ?? null, + run.slack_message_ts ?? null, + run.actor_user_id ?? null ); } @@ -429,6 +455,110 @@ export class AutomationStore { return result.results || []; } + // --- Slack trigger queries (#716) --- + + /** Enabled, non-deleted slack_event automations watching a channel (indexed by channel_id). */ + async getSlackAutomationsForChannel(channelId: string): Promise { + const result = await this.db + .prepare( + `SELECT a.* FROM automations a + JOIN automation_slack_channels c ON c.automation_id = a.id + WHERE c.channel_id = ? AND a.enabled = 1 AND a.deleted_at IS NULL + AND a.trigger_type = 'slack_event'` + ) + .bind(channelId) + .all(); + return result.results || []; + } + + /** Distinct channel IDs watched by any enabled slack_event automation. */ + async getWatchedSlackChannels(): Promise { + const result = await this.db + .prepare( + `SELECT DISTINCT c.channel_id FROM automation_slack_channels c + JOIN automations a ON a.id = c.automation_id + WHERE a.enabled = 1 AND a.deleted_at IS NULL AND a.trigger_type = 'slack_event'` + ) + .all<{ channel_id: string }>(); + return (result.results || []).map((r) => r.channel_id); + } + + /** Replace an automation's watched-channel set atomically. */ + async setSlackChannels(automationId: string, channelIds: string[]): Promise { + const statements: D1PreparedStatement[] = [ + this.db + .prepare("DELETE FROM automation_slack_channels WHERE automation_id = ?") + .bind(automationId), + ]; + for (const channelId of channelIds) { + statements.push( + this.db + .prepare( + "INSERT OR IGNORE INTO automation_slack_channels (automation_id, channel_id) VALUES (?, ?)" + ) + .bind(automationId, channelId) + ); + } + await this.db.batch(statements); + } + + /** + * Count runs for an automation since `sinceEpochMs`, for the rate-limit window. + * Excludes `skipped` rows — only runs that materialized a session count (a + * `failed` run still spawned a sandbox, so it counts). Served by + * idx_runs_automation_created (automation_id, created_at). + */ + async countRunsInWindow(automationId: string, sinceEpochMs: number): Promise { + const result = await this.db + .prepare( + `SELECT COUNT(*) as count FROM automation_runs + WHERE automation_id = ? AND created_at >= ? AND status != 'skipped'` + ) + .bind(automationId, sinceEpochMs) + .first<{ count: number }>(); + return result?.count ?? 0; + } + + /** + * Record a `skipped` run for observability (rate-limited / concurrency skips). + * Leaves trigger_key null so the skip stays excluded from countRunsInWindow. + * `slackColumns` carries the run's thread coordinates as a unit — the same + * value a materialized run is inserted with — so there is no re-mapping. + */ + async recordSkippedRun(params: { + id: string; + automationId: string; + skipReason: string; + concurrencyKey?: string | null; + slackColumns?: SlackRunColumns; + }): Promise { + const now = Date.now(); + try { + await this.insertRun({ + id: params.id, + automation_id: params.automationId, + session_id: null, + status: "skipped", + skip_reason: params.skipReason, + failure_reason: null, + scheduled_at: now, + started_at: null, + completed_at: now, + created_at: now, + trigger_key: null, + concurrency_key: params.concurrencyKey ?? null, + ...params.slackColumns, + }); + } catch (e) { + // Defensive only: the insert uses a fresh unique id and a null trigger_key + // (NULLs are distinct under the unique automation_id/trigger_key index), so + // a collision is not expected. Swallow one anyway so best-effort skip + // bookkeeping never throws into the dispatch path. + if (isDuplicateKeyError(e)) return; + throw e; + } + } + async getActiveRunForKey( automationId: string, concurrencyKey: string | null @@ -509,3 +639,9 @@ export class AutomationStore { .run(); } } + +/** True when a D1 write failed because it violated a UNIQUE constraint. */ +export function isDuplicateKeyError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("UNIQUE constraint failed"); +} diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts index aac83958a..a9ede8c84 100644 --- a/packages/control-plane/src/routes/automations.ts +++ b/packages/control-plane/src/routes/automations.ts @@ -15,6 +15,7 @@ import { type CreateAutomationRequest, type UpdateAutomationRequest, type AutomationTriggerType, + type TriggerConfig, } from "@open-inspect/shared"; import { AutomationStore, toAutomation, toAutomationRun } from "../db/automation-store"; import { UserStore } from "../db/user-store"; @@ -67,6 +68,33 @@ function isValidTimezone(tz: string): boolean { } } +/** Extract the watched channel IDs from a slack automation's `slack_channel` condition. */ +function extractSlackChannels(triggerConfig: TriggerConfig | null | undefined): string[] { + for (const condition of triggerConfig?.conditions ?? []) { + if (condition.type === "slack_channel") return condition.value; + } + return []; +} + +/** + * Slack triggers must be scoped: an explicit channel set + at least one + * `text_match`. This is net-new validation — the engine otherwise skips + * condition validation entirely when none are present. Returns an error message, + * or null when valid. + */ +function validateSlackRequiredConditions( + triggerConfig: TriggerConfig | null | undefined +): string | null { + const conditions = triggerConfig?.conditions ?? []; + if (!conditions.some((c) => c.type === "slack_channel")) { + return "slack_event triggers require a slack_channel condition"; + } + if (!conditions.some((c) => c.type === "text_match")) { + return "slack_event triggers require at least one text_match condition"; + } + return null; +} + // ─── Handlers ──────────────────────────────────────────────────────────────── async function handleListAutomations( @@ -126,6 +154,7 @@ async function handleCreateAutomation( "webhook", "github_event", "linear_event", + "slack_event", ]; if (!validTriggerTypes.includes(triggerType)) { return error(`triggerType must be one of: ${validTriggerTypes.join(", ")}`, 400); @@ -175,6 +204,12 @@ async function handleCreateAutomation( } } + // Slack triggers require explicit scoping (a channel set + at least one text_match). + if (triggerType === "slack_event") { + const slackError = validateSlackRequiredConditions(body.triggerConfig); + if (slackError) return error(slackError, 400); + } + // Validate model const model = getValidModelOrDefault(body.model); const reasoningEffort = resolveReasoningEffort(model, body.reasoningEffort); @@ -264,8 +299,17 @@ async function handleCreateAutomation( event_type: body.eventType ?? null, trigger_config: body.triggerConfig ? JSON.stringify(body.triggerConfig) : null, trigger_auth_data: triggerAuthData, + // Slack-only knobs; harmless defaults for other sources (they never read these). + max_runs_per_hour: body.maxRunsPerHour ?? null, + reply_in_thread: body.replyInThread === false ? 0 : 1, }); + // Mirror the slack_channel condition into the join table that drives + // channel-keyed candidate selection in the scheduler. + if (triggerType === "slack_event") { + await store.setSlackChannels(id, extractSlackChannels(body.triggerConfig)); + } + const automation = toAutomation((await store.getById(id))!); logger.info("automation.created", { @@ -413,6 +457,10 @@ async function handleUpdateAutomation( if (existing.trigger_type === "schedule") { return error("Cannot set triggerConfig on schedule automations", 400); } + if (existing.trigger_type === "slack_event") { + const slackError = validateSlackRequiredConditions(body.triggerConfig); + if (slackError) return error(slackError, 400); + } if (body.triggerConfig === null) { updateFields.trigger_config = null; } else { @@ -436,6 +484,14 @@ async function handleUpdateAutomation( } } + // Slack-only knobs + if (body.maxRunsPerHour !== undefined) { + updateFields.max_runs_per_hour = body.maxRunsPerHour; + } + if (body.replyInThread !== undefined) { + updateFields.reply_in_thread = body.replyInThread ? 1 : 0; + } + // Recompute next_run_at if schedule changed (only for schedule types) if ( existing.trigger_type === "schedule" && @@ -452,6 +508,11 @@ async function handleUpdateAutomation( const updated = await store.update(id, updateFields); if (!updated) return error("Automation not found", 404); + // Re-sync the watched-channel join table when slack conditions change. + if (existing.trigger_type === "slack_event" && body.triggerConfig !== undefined) { + await store.setSlackChannels(id, extractSlackChannels(body.triggerConfig)); + } + logger.info("automation.updated", { event: "automation.updated", automation_id: id, @@ -715,9 +776,37 @@ async function handleRegenerateKey( }); } +/** + * GET /integration-settings/slack/watched-channels + * + * Returns the distinct set of Slack channel IDs referenced by enabled + * `slack_event` automations. The slack-bot polls this (cached) to pre-filter + * channel messages before normalizing and forwarding them — only messages in a + * watched channel are worth forwarding to the scheduler. + * + * Grouped under the `/integration-settings/slack` prefix the bot already uses + * for its runtime config (routing rules), even though the data is sourced from + * the automations store. Internal-auth gated by the router (non-public route). + */ +async function handleGetWatchedSlackChannels( + _request: Request, + env: Env, + _match: RegExpMatchArray, + _ctx: RequestContext +): Promise { + const store = new AutomationStore(env.DB); + const channels = await store.getWatchedSlackChannels(); + return json({ channels }); +} + // ─── Route exports ─────────────────────────────────────────────────────────── export const automationRoutes: Route[] = [ + { + method: "GET", + pattern: parsePattern("/integration-settings/slack/watched-channels"), + handler: handleGetWatchedSlackChannels, + }, { method: "GET", pattern: parsePattern("/automations"), diff --git a/packages/control-plane/src/scheduler/durable-object.ts b/packages/control-plane/src/scheduler/durable-object.ts index 3fd82de16..8d3d677ba 100644 --- a/packages/control-plane/src/scheduler/durable-object.ts +++ b/packages/control-plane/src/scheduler/durable-object.ts @@ -13,11 +13,22 @@ import { nextCronOccurrence, matchesConditions, conditionRegistry, + computeHmacHex, + DEFAULT_MAX_RUNS_PER_HOUR, type AutomationCallbackContext, type AutomationEvent, + type SlackAutomationEvent, type TriggerConfig, } from "@open-inspect/shared"; -import { AutomationStore, toAutomationRun, type AutomationRow } from "../db/automation-store"; +import { + AutomationStore, + toAutomationRun, + isDuplicateKeyError, + type AutomationRow, + type AutomationRunRow, + type SlackRunColumns, +} from "../db/automation-store"; +import { buildSlackCompletionNotification, buildSlackSkipNotification } from "./slack-completion"; import { UserStore } from "../db/user-store"; import { createRequestMetrics } from "../db/instrumented-d1"; import { generateId } from "../auth/crypto"; @@ -42,6 +53,9 @@ const DEFAULT_EXECUTION_TIMEOUT_MS = 90 * 60 * 1000; /** Consecutive failure threshold for auto-pause. */ const AUTO_PAUSE_THRESHOLD = 3; +/** Rate-limit window for slack triggers (1 hour, matching max_runs_per_hour). */ +const SLACK_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; + export class SchedulerDO extends DurableObject { private readonly log: Logger; @@ -288,12 +302,20 @@ export class SchedulerDO extends DurableObject { event.eventType ); break; + case "slack": + candidates = await store.getSlackAutomationsForChannel(event.channelId); + break; } let triggered = 0; let skipped = 0; + // Surface at most one concurrency-skip ephemeral per event, even when + // several automations watch the same thread and all skip. + let concurrencySkipped = false; for (const automation of candidates) { + const now = Date.now(); + // 2. Evaluate conditions const config: TriggerConfig = automation.trigger_config ? JSON.parse(automation.trigger_config) @@ -302,16 +324,36 @@ export class SchedulerDO extends DurableObject { continue; } - // 3. Concurrency check (per-event-instance) + // 3. Rate limit (slack only) — bounds the top-level-post flood that the + // per-thread concurrency_key does not. Records a skip for observability. + if (event.source === "slack") { + const maxRuns = automation.max_runs_per_hour ?? DEFAULT_MAX_RUNS_PER_HOUR; + const recentRuns = await store.countRunsInWindow( + automation.id, + now - SLACK_RATE_LIMIT_WINDOW_MS + ); + if (recentRuns >= maxRuns) { + await this.recordSlackSkip(store, automation.id, event, "rate_limited"); + skipped++; + continue; + } + } + + // 4. Concurrency check (per-event-instance) const activeRun = await store.getActiveRunForKey(automation.id, event.concurrencyKey); if (activeRun) { + // For slack, persist the skip so the bot can surface "a run is already + // active for this thread" and so the drop is auditable. + if (event.source === "slack") { + await this.recordSlackSkip(store, automation.id, event, "concurrent_run_active"); + concurrencySkipped = true; + } skipped++; continue; } - // 4. Create run (dedup via unique index on trigger_key) + // 5. Create run (dedup via unique index on trigger_key) const runId = generateId(); - const now = Date.now(); try { await store.insertRun({ id: runId, @@ -326,6 +368,7 @@ export class SchedulerDO extends DurableObject { created_at: now, trigger_key: event.triggerKey, concurrency_key: event.concurrencyKey, + ...(event.source === "slack" ? slackRunColumns(event) : {}), }); } catch (e) { if (isDuplicateKeyError(e)) { @@ -354,6 +397,10 @@ export class SchedulerDO extends DurableObject { } } + if (event.source === "slack" && concurrencySkipped) { + await this.notifySlackConcurrencySkip(event); + } + this.log.info("Event processed", { event: "scheduler.event_processed", source: event.source, @@ -520,11 +567,129 @@ export class SchedulerDO extends DurableObject { }); } + // Slack-triggered runs deliver their result back into the originating + // thread. The scheduler owns this fan-out (not the session callback path) + // because the thread coordinates live on the run row. Best-effort. + if (run.slack_channel) { + await this.notifySlackCompletion( + store, + run, + body.success, + body.success ? undefined : body.error + ); + } + return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, }); } + /** + * Persist a `skipped` run for a slack event dropped by the rate-limit or + * concurrency guard, carrying the same thread coordinates a materialized run + * would. Best-effort observability; `recordSkippedRun` swallows the unexpected + * duplicate-key case internally. + */ + private async recordSlackSkip( + store: AutomationStore, + automationId: string, + event: SlackAutomationEvent, + reason: string + ): Promise { + await store.recordSkippedRun({ + id: generateId(), + automationId, + skipReason: reason, + concurrencyKey: event.concurrencyKey, + slackColumns: slackRunColumns(event), + }); + } + + /** + * Post a Slack-triggered run's result into its originating thread by calling + * the slack-bot's `/callbacks/automation-complete` endpoint. Signs the body + * with `INTERNAL_CALLBACK_SECRET` (in-body HMAC, matching the bot's other + * callbacks). No-ops when the run has no thread anchor, when `SLACK_BOT` is + * unbound, or when the secret is unset — all best-effort. + */ + private async notifySlackCompletion( + store: AutomationStore, + run: AutomationRunRow, + success: boolean, + error?: string + ): Promise { + const binding = this.env.SLACK_BOT; + const secret = this.env.INTERNAL_CALLBACK_SECRET; + if (!binding || !secret) return; + + const automation = await store.getById(run.automation_id); + const body = buildSlackCompletionNotification({ + run, + automationName: automation?.name ?? "Automation", + success, + error, + }); + if (!body) return; + + try { + const signature = await computeHmacHex(JSON.stringify(body), secret); + const response = await binding.fetch("https://internal/callbacks/automation-complete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...body, signature }), + }); + if (!response.ok) { + this.log.warn("Slack completion callback failed", { + event: "scheduler.slack_complete_failed", + automation_id: run.automation_id, + run_id: run.id, + http_status: response.status, + }); + } + } catch (e) { + this.log.warn("Slack completion callback errored", { + event: "scheduler.slack_complete_failed", + automation_id: run.automation_id, + run_id: run.id, + error: e instanceof Error ? e : new Error(String(e)), + }); + } + } + + /** + * Post a best-effort ephemeral "a run is already active for this thread" + * notice to the message author when a slack event is dropped by the + * per-thread concurrency guard. No-ops without a binding/secret/actor. + */ + private async notifySlackConcurrencySkip(event: SlackAutomationEvent): Promise { + const binding = this.env.SLACK_BOT; + const secret = this.env.INTERNAL_CALLBACK_SECRET; + if (!binding || !secret) return; + + const body = buildSlackSkipNotification({ + channelId: event.channelId, + actorUserId: event.actorUserId, + threadTs: event.threadTs, + ts: event.ts, + }); + if (!body) return; + + try { + const signature = await computeHmacHex(JSON.stringify(body), secret); + await binding.fetch("https://internal/callbacks/automation-skip", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...body, signature }), + }); + } catch (e) { + this.log.warn("Slack skip callback errored", { + event: "scheduler.slack_skip_failed", + channel: event.channelId, + error: e instanceof Error ? e : new Error(String(e)), + }); + } + } + // ─── Health check ──────────────────────────────────────────────────────── private async handleHealth(): Promise { @@ -638,7 +803,12 @@ export class SchedulerDO extends DurableObject { } } -function isDuplicateKeyError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return message.includes("UNIQUE constraint failed"); +/** Run-row slack columns for a slack-origin event — shared by insertRun and recordSkippedRun. */ +function slackRunColumns(event: SlackAutomationEvent): SlackRunColumns { + return { + slack_channel: event.channelId, + slack_thread_ts: event.threadTs ?? null, + slack_message_ts: event.ts, + actor_user_id: event.actorUserId, + }; } diff --git a/packages/control-plane/src/scheduler/slack-completion.test.ts b/packages/control-plane/src/scheduler/slack-completion.test.ts new file mode 100644 index 000000000..e2ea1b8e7 --- /dev/null +++ b/packages/control-plane/src/scheduler/slack-completion.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { + buildSlackCompletionNotification, + buildSlackSkipNotification, + type SlackRunCoords, +} from "./slack-completion"; + +function coords(overrides?: Partial): SlackRunCoords { + return { + slack_channel: "C1", + slack_thread_ts: null, + slack_message_ts: "1700000000.000100", + session_id: "sess-1", + ...overrides, + }; +} + +describe("buildSlackCompletionNotification", () => { + it("returns null for a non-slack run (no channel)", () => { + expect( + buildSlackCompletionNotification({ + run: coords({ slack_channel: null }), + automationName: "Triage", + success: true, + }) + ).toBeNull(); + }); + + it("returns null when a slack run has no thread anchor", () => { + expect( + buildSlackCompletionNotification({ + run: coords({ slack_thread_ts: null, slack_message_ts: null }), + automationName: "Triage", + success: true, + }) + ).toBeNull(); + }); + + it("anchors to the thread ts when present", () => { + const n = buildSlackCompletionNotification({ + run: coords({ slack_thread_ts: "1699999999.000001" }), + automationName: "Triage", + success: true, + }); + expect(n).toMatchObject({ + channel: "C1", + threadTs: "1699999999.000001", + reactionMessageTs: "1700000000.000100", + sessionId: "sess-1", + success: true, + automationName: "Triage", + }); + expect(n?.summary).toBeUndefined(); + }); + + it("falls back to the message ts as the thread anchor", () => { + const n = buildSlackCompletionNotification({ + run: coords({ slack_thread_ts: null }), + automationName: "Triage", + success: true, + }); + expect(n?.threadTs).toBe("1700000000.000100"); + }); + + it("includes a truncated error summary on failure", () => { + const longError = "x".repeat(5000); + const n = buildSlackCompletionNotification({ + run: coords(), + automationName: "Triage", + success: false, + error: longError, + }); + expect(n?.success).toBe(false); + expect(n?.summary?.length).toBe(1500); + }); +}); + +describe("buildSlackSkipNotification", () => { + it("returns null when the actor is unknown", () => { + expect(buildSlackSkipNotification({ channelId: "C1", ts: "1700000000.000100" })).toBeNull(); + }); + + it("targets the actor and anchors to the thread ts when present", () => { + expect( + buildSlackSkipNotification({ + channelId: "C1", + actorUserId: "U9", + threadTs: "1699999999.000001", + ts: "1700000000.000100", + }) + ).toEqual({ channel: "C1", user: "U9", threadTs: "1699999999.000001" }); + }); + + it("falls back to the message ts as the thread anchor", () => { + expect( + buildSlackSkipNotification({ channelId: "C1", actorUserId: "U9", ts: "1700000000.000100" }) + ).toEqual({ channel: "C1", user: "U9", threadTs: "1700000000.000100" }); + }); +}); diff --git a/packages/control-plane/src/scheduler/slack-completion.ts b/packages/control-plane/src/scheduler/slack-completion.ts new file mode 100644 index 000000000..e8c64d7b5 --- /dev/null +++ b/packages/control-plane/src/scheduler/slack-completion.ts @@ -0,0 +1,75 @@ +/** + * Pure builders for the scheduler → slack-bot notifications (run completion and + * concurrency-skip). Kept free of Durable Object state so they can be unit + * tested directly; the SchedulerDO method signs the result (HMAC over the JSON + * body) and POSTs it via the optional `SLACK_BOT` Fetcher. + * + * Returning `null` from either builder is the explicit signal to skip the bot + * call — for a non-slack run, a slack run with no thread anchor, or a skip with + * no actor to address. + */ + +import type { AutomationRunRow } from "../db/automation-store"; + +/** Run-row coordinate subset the slack completion notification needs. */ +export type SlackRunCoords = Pick< + AutomationRunRow, + "slack_channel" | "slack_thread_ts" | "slack_message_ts" | "session_id" +>; + +/** Max characters of an error surfaced inline; the full transcript is one click away. */ +const SUMMARY_MAX_LENGTH = 1500; + +export interface SlackCompletionNotification { + channel: string; + threadTs: string; + /** Message to clear the `eyes` reaction from (the triggering message). */ + reactionMessageTs?: string; + sessionId: string | null; + success: boolean; + /** Short failure summary; omitted on success (the bot renders a generic ✅). */ + summary?: string; + automationName: string; +} + +export function buildSlackCompletionNotification(params: { + run: SlackRunCoords; + automationName: string; + success: boolean; + error?: string; +}): SlackCompletionNotification | null { + const { run } = params; + if (!run.slack_channel) return null; + const threadTs = run.slack_thread_ts ?? run.slack_message_ts ?? undefined; + if (!threadTs) return null; + + return { + channel: run.slack_channel, + threadTs, + reactionMessageTs: run.slack_message_ts ?? undefined, + sessionId: run.session_id ?? null, + success: params.success, + summary: params.error ? params.error.slice(0, SUMMARY_MAX_LENGTH) : undefined, + automationName: params.automationName, + }; +} + +export interface SlackSkipNotification { + channel: string; + user: string; + threadTs: string; +} + +export function buildSlackSkipNotification(params: { + channelId: string; + actorUserId?: string; + threadTs?: string; + ts: string; +}): SlackSkipNotification | null { + if (!params.actorUserId) return null; + return { + channel: params.channelId, + user: params.actorUserId, + threadTs: params.threadTs ?? params.ts, + }; +} diff --git a/packages/control-plane/src/webhooks/automation-event.ts b/packages/control-plane/src/webhooks/automation-event.ts new file mode 100644 index 000000000..bb15ee010 --- /dev/null +++ b/packages/control-plane/src/webhooks/automation-event.ts @@ -0,0 +1,92 @@ +/** + * Shared handler for the internal "normalized automation event" endpoints + * (e.g. `/internal/github-event`, `/internal/slack-event`). Each bot + * pre-normalizes its source's events and POSTs them here; this layer + * authenticates, validates the event envelope, and forwards to the singleton + * SchedulerDO for matching and dispatch. The only per-source difference is the + * required-field check, supplied via `validate`. + */ + +import type { AutomationEventSource } from "@open-inspect/shared"; +import { verifyInternalToken } from "../auth/internal"; +import type { Route, RequestContext } from "../routes/shared"; +import { parsePattern, json, error } from "../routes/shared"; +import type { Env } from "../types"; + +export function createAutomationEventRoute(opts: { + path: string; + source: AutomationEventSource; + /** Validate source-specific required fields. Returns an error message, or null when valid. */ + validate: (event: Record) => string | null; +}): Route { + async function handler( + request: Request, + env: Env, + _match: RegExpMatchArray, + _ctx: RequestContext + ): Promise { + // 0. Authenticate — fail closed if secret is unconfigured or token is invalid. + // The router-level gate already enforces this; repeat it here for + // defense-in-depth. + if (!env.INTERNAL_CALLBACK_SECRET) { + return error("Internal authentication not configured", 500); + } + const isValid = await verifyInternalToken( + request.headers.get("Authorization"), + env.INTERNAL_CALLBACK_SECRET + ); + if (!isValid) { + return error("Unauthorized", 401); + } + + // 1. Parse body + let body: unknown; + try { + body = await request.json(); + } catch { + return error("Invalid JSON", 400); + } + + // 2. Validate envelope — source, then source-specific fields, then the + // common dispatch keys every event must carry. + const event = body as Record; + if (event.source !== opts.source) { + return error(`Invalid event: source must be '${opts.source}'`, 400); + } + const fieldError = opts.validate(event); + if (fieldError) { + return error(fieldError, 400); + } + if (!event.eventType || !event.triggerKey || !event.concurrencyKey) { + return error("Invalid event: eventType, triggerKey, and concurrencyKey are required", 400); + } + + // 3. Forward to SchedulerDO + if (!env.SCHEDULER) { + return error("Scheduler not configured", 503); + } + const stub = env.SCHEDULER.get(env.SCHEDULER.idFromName("global-scheduler")); + + let response: Response; + try { + response = await stub.fetch("http://internal/internal/event", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(event), + }); + } catch { + return json({ ok: false, error: "Failed to reach scheduler" }, 502); + } + + let result: { triggered: number; skipped: number }; + try { + result = await response.json<{ triggered: number; skipped: number }>(); + } catch { + return json({ ok: false, error: "Invalid response from scheduler" }, 502); + } + + return json({ ok: true, ...result }, response.status); + } + + return { method: "POST", pattern: parsePattern(opts.path), handler }; +} diff --git a/packages/control-plane/src/webhooks/github.ts b/packages/control-plane/src/webhooks/github.ts index c648515e6..f840ec1fc 100644 --- a/packages/control-plane/src/webhooks/github.ts +++ b/packages/control-plane/src/webhooks/github.ts @@ -4,81 +4,13 @@ * them to the SchedulerDO for automation matching and session dispatch. */ -import type { GitHubAutomationEvent } from "@open-inspect/shared"; -import { verifyInternalToken } from "../auth/internal"; -import type { Route, RequestContext } from "../routes/shared"; -import { parsePattern, json, error } from "../routes/shared"; -import type { Env } from "../types"; - -async function handleGitHubAutomationEvent( - request: Request, - env: Env, - _match: RegExpMatchArray, - _ctx: RequestContext -): Promise { - // 0. Authenticate — fail closed if secret is unconfigured or token is invalid - if (!env.INTERNAL_CALLBACK_SECRET) { - return error("Internal authentication not configured", 500); - } - const isValid = await verifyInternalToken( - request.headers.get("Authorization"), - env.INTERNAL_CALLBACK_SECRET - ); - if (!isValid) { - return error("Unauthorized", 401); - } - - // 1. Parse body - let body: unknown; - try { - body = await request.json(); - } catch { - return error("Invalid JSON", 400); - } - - // 2. Validate required fields - const event = body as Partial; - if (event.source !== "github") { - return error("Invalid event: source must be 'github'", 400); - } - if (!event.repoOwner || !event.repoName) { - return error("Invalid event: repoOwner and repoName are required", 400); - } - if (!event.eventType || !event.triggerKey || !event.concurrencyKey) { - return error("Invalid event: eventType, triggerKey, and concurrencyKey are required", 400); - } - - // 3. Forward to SchedulerDO - if (!env.SCHEDULER) { - return error("Scheduler not configured", 503); - } - - const doId = env.SCHEDULER.idFromName("global-scheduler"); - const stub = env.SCHEDULER.get(doId); - - let response: Response; - try { - response = await stub.fetch("http://internal/internal/event", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(event), - }); - } catch (_e) { - return json({ ok: false, error: "Failed to reach scheduler" }, 502); - } - - let result: { triggered: number; skipped: number }; - try { - result = await response.json<{ triggered: number; skipped: number }>(); - } catch { - return json({ ok: false, error: "Invalid response from scheduler" }, 502); - } - - return json({ ok: true, ...result }, response.status); -} - -export const githubAutomationEventRoute: Route = { - method: "POST", - pattern: parsePattern("/internal/github-event"), - handler: handleGitHubAutomationEvent, -}; +import { createAutomationEventRoute } from "./automation-event"; + +export const githubAutomationEventRoute = createAutomationEventRoute({ + path: "/internal/github-event", + source: "github", + validate: (event) => + !event.repoOwner || !event.repoName + ? "Invalid event: repoOwner and repoName are required" + : null, +}); diff --git a/packages/control-plane/src/webhooks/index.ts b/packages/control-plane/src/webhooks/index.ts index 073d7c697..102ab316b 100644 --- a/packages/control-plane/src/webhooks/index.ts +++ b/packages/control-plane/src/webhooks/index.ts @@ -6,9 +6,11 @@ import type { Route } from "../routes/shared"; import { sentryWebhookRoute } from "./sentry"; import { automationWebhookRoute } from "./automation-webhook"; import { githubAutomationEventRoute } from "./github"; +import { slackAutomationEventRoute } from "./slack"; export const webhookRoutes: Route[] = [ sentryWebhookRoute, automationWebhookRoute, githubAutomationEventRoute, + slackAutomationEventRoute, ]; diff --git a/packages/control-plane/src/webhooks/slack.ts b/packages/control-plane/src/webhooks/slack.ts new file mode 100644 index 000000000..bbac0028e --- /dev/null +++ b/packages/control-plane/src/webhooks/slack.ts @@ -0,0 +1,20 @@ +/** + * Slack automation event webhook route — internal endpoint that receives + * pre-normalized SlackAutomationEvents from the slack-bot and proxies them + * to the SchedulerDO for automation matching and session dispatch. + * + * The slack-bot is responsible for ingress filtering (watched channels, + * mention suppression) and normalization; this endpoint only authenticates, + * validates the event envelope, and forwards. Channel-keyed candidate + * selection, condition evaluation, rate limiting, and dedup all happen in the + * scheduler. + */ + +import { createAutomationEventRoute } from "./automation-event"; + +export const slackAutomationEventRoute = createAutomationEventRoute({ + path: "/internal/slack-event", + source: "slack", + validate: (event) => + !event.channelId || !event.ts ? "Invalid event: channelId and ts are required" : null, +}); diff --git a/packages/control-plane/test/integration/automation-store.test.ts b/packages/control-plane/test/integration/automation-store.test.ts index b47799c80..c556e6e9b 100644 --- a/packages/control-plane/test/integration/automation-store.test.ts +++ b/packages/control-plane/test/integration/automation-store.test.ts @@ -757,4 +757,175 @@ describe("AutomationStore (D1 integration)", () => { expect(runs.total).toBe(2); }); }); + + // ─── Slack triggers (#716) ────────────────────────────────────────────────── + + describe("slack triggers", () => { + const makeSlackAutomation = (overrides?: Partial) => + makeAutomation({ + trigger_type: "slack_event", + event_type: "message.posted", + ...overrides, + }); + + it("setSlackChannels writes and replaces the channel set", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeSlackAutomation({ id: "auto-s1" })); + + await store.setSlackChannels("auto-s1", ["C1", "C2"]); + expect((await store.getWatchedSlackChannels()).sort()).toEqual(["C1", "C2"]); + + await store.setSlackChannels("auto-s1", ["C2", "C3"]); + expect((await store.getWatchedSlackChannels()).sort()).toEqual(["C2", "C3"]); + }); + + it("getSlackAutomationsForChannel returns only enabled, non-deleted slack automations", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeSlackAutomation({ id: "auto-s2" })); + await store.create(makeSlackAutomation({ id: "auto-s3", enabled: 0 })); + await store.create( + makeAutomation({ + id: "auto-s4", + trigger_type: "github_event", + event_type: "pull_request.opened", + }) + ); + + await store.setSlackChannels("auto-s2", ["C1"]); + await store.setSlackChannels("auto-s3", ["C1"]); // disabled → excluded + await store.setSlackChannels("auto-s4", ["C1"]); // wrong trigger_type → excluded + + const matches = await store.getSlackAutomationsForChannel("C1"); + expect(matches.map((m) => m.id)).toEqual(["auto-s2"]); + }); + + it("getWatchedSlackChannels dedups and excludes disabled automations", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeSlackAutomation({ id: "auto-s5" })); + await store.create(makeSlackAutomation({ id: "auto-s6" })); + await store.create(makeSlackAutomation({ id: "auto-s7", enabled: 0 })); + + await store.setSlackChannels("auto-s5", ["C1", "C2"]); + await store.setSlackChannels("auto-s6", ["C2", "C3"]); // C2 duplicated across automations + await store.setSlackChannels("auto-s7", ["C9"]); // disabled → excluded + + expect((await store.getWatchedSlackChannels()).sort()).toEqual(["C1", "C2", "C3"]); + }); + + it("countRunsInWindow honors the window and excludes skipped runs", async () => { + const store = new AutomationStore(env.DB); + const now = Date.now(); + await store.create(makeSlackAutomation({ id: "auto-s8" })); + + // In-window: completed + failed count; skipped does not. + await store.insertRun( + makeRun("auto-s8", { + id: "r-c", + status: "completed", + scheduled_at: now - 1000, + created_at: now - 1000, + completed_at: now, + }) + ); + await store.insertRun( + makeRun("auto-s8", { + id: "r-f", + status: "failed", + scheduled_at: now - 2000, + created_at: now - 2000, + completed_at: now, + }) + ); + await store.insertRun( + makeRun("auto-s8", { + id: "r-s", + status: "skipped", + skip_reason: "rate_limited", + scheduled_at: now - 3000, + created_at: now - 3000, + completed_at: now, + }) + ); + // Out of window (2 hours ago). + await store.insertRun( + makeRun("auto-s8", { + id: "r-old", + status: "completed", + scheduled_at: now - 7_200_000, + created_at: now - 7_200_000, + completed_at: now, + }) + ); + + const count = await store.countRunsInWindow("auto-s8", now - 3_600_000); + expect(count).toBe(2); + }); + + it("recordSkippedRun persists a skip with slack coords, no trigger_key, uncounted", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeSlackAutomation({ id: "auto-s9" })); + + await store.recordSkippedRun({ + id: "r-skip", + automationId: "auto-s9", + skipReason: "rate_limited", + concurrencyKey: "slack:C1:111", + slackColumns: { + slack_channel: "C1", + slack_thread_ts: "111", + slack_message_ts: "222", + actor_user_id: "U1", + }, + }); + + const run = await store.getRunById("auto-s9", "r-skip"); + expect(run).not.toBeNull(); + expect(run!.status).toBe("skipped"); + expect(run!.skip_reason).toBe("rate_limited"); + expect(run!.slack_channel).toBe("C1"); + expect(run!.slack_message_ts).toBe("222"); + expect(run!.trigger_key).toBeNull(); + + // A recorded skip must not count toward the rate-limit window. + expect(await store.countRunsInWindow("auto-s9", Date.now() - 3_600_000)).toBe(0); + }); + + it("insertRun persists slack thread coordinates on a materialized run", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeSlackAutomation({ id: "auto-s10" })); + + await store.insertRun( + makeRun("auto-s10", { + id: "r-mat", + status: "starting", + trigger_key: "slack:msg:C1:222", + concurrency_key: "slack:C1:111", + slack_channel: "C1", + slack_thread_ts: "111", + slack_message_ts: "222", + actor_user_id: "U1", + }) + ); + + const run = await store.getRunById("auto-s10", "r-mat"); + expect(run!.slack_channel).toBe("C1"); + expect(run!.slack_thread_ts).toBe("111"); + expect(run!.slack_message_ts).toBe("222"); + expect(run!.actor_user_id).toBe("U1"); + }); + + it("persists max_runs_per_hour and reply_in_thread on the automation", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeSlackAutomation({ id: "auto-s11", max_runs_per_hour: 5 })); + + const row = await store.getById("auto-s11"); + expect(row!.max_runs_per_hour).toBe(5); + expect(row!.reply_in_thread).toBe(1); // DB default + + await store.update("auto-s11", { max_runs_per_hour: 20, reply_in_thread: 0 }); + const updated = await store.getById("auto-s11"); + expect(updated!.max_runs_per_hour).toBe(20); + expect(updated!.reply_in_thread).toBe(0); + }); + }); }); diff --git a/packages/control-plane/test/integration/automations-slack-route.test.ts b/packages/control-plane/test/integration/automations-slack-route.test.ts new file mode 100644 index 000000000..60c56943d --- /dev/null +++ b/packages/control-plane/test/integration/automations-slack-route.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { SELF, env } from "cloudflare:test"; +import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; +import { generateInternalToken } from "../../src/auth/internal"; +import { cleanD1Tables } from "./cleanup"; + +async function authHeaders(): Promise> { + const token = await generateInternalToken(env.INTERNAL_CALLBACK_SECRET!); + return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; +} + +function makeSlackAutomation(overrides?: Partial): AutomationRow { + const now = Date.now(); + return { + id: `auto-${Math.random().toString(36).slice(2, 8)}`, + name: "Slack triage", + repo_owner: "acme", + repo_name: "web-app", + base_branch: "main", + repo_id: 12345, + instructions: "Investigate and fix", + trigger_type: "slack_event", + schedule_cron: null, + schedule_tz: "UTC", + model: "anthropic/claude-sonnet-4-6", + reasoning_effort: null, + enabled: 1, + next_run_at: null, + consecutive_failures: 0, + created_by: "user-1", + user_id: null, + created_at: now, + updated_at: now, + deleted_at: null, + event_type: "message.posted", + trigger_config: null, + trigger_auth_data: null, + ...overrides, + }; +} + +function createBody(overrides: Record) { + return { + name: "Slack triage", + instructions: "Investigate the report", + repoOwner: "acme", + repoName: "web-app", + triggerType: "slack_event", + ...overrides, + }; +} + +async function postAutomation(body: Record): Promise { + return SELF.fetch("https://test.local/automations", { + method: "POST", + headers: await authHeaders(), + body: JSON.stringify(body), + }); +} + +describe("POST /automations — slack_event validation (integration)", () => { + beforeEach(cleanD1Tables); + + it("rejects a slack_event without a slack_channel condition (400)", async () => { + const res = await postAutomation(createBody({ triggerConfig: { conditions: [] } })); + expect(res.status).toBe(400); + expect(await res.text()).toContain("slack_channel"); + }); + + it("rejects a slack_event without a text_match condition (400)", async () => { + const res = await postAutomation( + createBody({ + triggerConfig: { + conditions: [{ type: "slack_channel", operator: "any_of", value: ["C1"] }], + }, + }) + ); + expect(res.status).toBe(400); + expect(await res.text()).toContain("text_match"); + }); + + it("rejects an invalid regex text_match at save time (400)", async () => { + const res = await postAutomation( + createBody({ + triggerConfig: { + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "regex", value: { pattern: "(" } }, + ], + }, + }) + ); + expect(res.status).toBe(400); + expect(await res.text()).toContain("Invalid regex"); + }); + + it("rejects a disallowed regex flag at save time (400)", async () => { + const res = await postAutomation( + createBody({ + triggerConfig: { + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "regex", value: { pattern: "deploy", flags: "g" } }, + ], + }, + }) + ); + expect(res.status).toBe(400); + expect(await res.text()).toContain("Unsupported regex flag"); + }); + + it("accepts slack_event past the trigger-type allowlist (no unknown-trigger 400)", async () => { + // Valid scoping passes validation; the request then fails later at repository + // resolution (no GitHub App in the test env). The point is that slack_event is + // NOT rejected as an unknown trigger type before reaching that stage. + const res = await postAutomation( + createBody({ + triggerConfig: { + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }, + }) + ); + expect(await res.text()).not.toContain("triggerType must be one of"); + }); +}); + +describe("GET /integration-settings/slack/watched-channels (integration)", () => { + beforeEach(cleanD1Tables); + + async function getWatchedChannels(): Promise { + return SELF.fetch("https://test.local/integration-settings/slack/watched-channels", { + method: "GET", + headers: await authHeaders(), + }); + } + + it("returns 401 without an internal token", async () => { + const res = await SELF.fetch("https://test.local/integration-settings/slack/watched-channels", { + method: "GET", + }); + expect(res.status).toBe(401); + }); + + it("returns the distinct watched channels for enabled slack automations", async () => { + const store = new AutomationStore(env.DB); + const a = makeSlackAutomation(); + const b = makeSlackAutomation(); + await store.create(a); + await store.create(b); + await store.setSlackChannels(a.id, ["C1", "C2"]); + await store.setSlackChannels(b.id, ["C2", "C3"]); + + const res = await getWatchedChannels(); + expect(res.status).toBe(200); + const body = await res.json<{ channels: string[] }>(); + expect([...body.channels].sort()).toEqual(["C1", "C2", "C3"]); + }); + + it("excludes channels of disabled automations and returns an empty list when none", async () => { + const store = new AutomationStore(env.DB); + const disabled = makeSlackAutomation({ enabled: 0 }); + await store.create(disabled); + await store.setSlackChannels(disabled.id, ["C9"]); + + const res = await getWatchedChannels(); + expect(res.status).toBe(200); + const body = await res.json<{ channels: string[] }>(); + expect(body.channels).toEqual([]); + }); +}); diff --git a/packages/control-plane/test/integration/cleanup.ts b/packages/control-plane/test/integration/cleanup.ts index 5b3eff2a5..af705d610 100644 --- a/packages/control-plane/test/integration/cleanup.ts +++ b/packages/control-plane/test/integration/cleanup.ts @@ -6,6 +6,6 @@ import { env } from "cloudflare:test"; */ export async function cleanD1Tables(): Promise { await env.DB.exec( - "DELETE FROM automation_runs; DELETE FROM automations; DELETE FROM sessions; DELETE FROM user_scm_tokens; DELETE FROM repo_metadata; DELETE FROM repo_secrets; DELETE FROM global_secrets; DELETE FROM integration_settings; DELETE FROM integration_repo_settings; DELETE FROM repo_images; DELETE FROM mcp_servers; DELETE FROM user_identities; DELETE FROM users;" + "DELETE FROM automation_slack_channels; DELETE FROM automation_runs; DELETE FROM automations; DELETE FROM sessions; DELETE FROM user_scm_tokens; DELETE FROM repo_metadata; DELETE FROM repo_secrets; DELETE FROM global_secrets; DELETE FROM integration_settings; DELETE FROM integration_repo_settings; DELETE FROM repo_images; DELETE FROM mcp_servers; DELETE FROM user_identities; DELETE FROM users;" ); } diff --git a/packages/control-plane/test/integration/scheduler-slack-events.test.ts b/packages/control-plane/test/integration/scheduler-slack-events.test.ts new file mode 100644 index 000000000..738875c31 --- /dev/null +++ b/packages/control-plane/test/integration/scheduler-slack-events.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { env } from "cloudflare:test"; +import { + AutomationStore, + type AutomationRow, + type AutomationRunRow, +} from "../../src/db/automation-store"; +import type { SlackAutomationEvent } from "@open-inspect/shared"; +import { cleanD1Tables } from "./cleanup"; + +function getSchedulerStub() { + const id = env.SCHEDULER.idFromName("global-scheduler"); + return env.SCHEDULER.get(id); +} + +function makeAutomation(overrides?: Partial): AutomationRow { + const now = Date.now(); + return { + id: `auto-${Math.random().toString(36).slice(2, 8)}`, + name: "Slack triage", + repo_owner: "acme", + repo_name: "web-app", + base_branch: "main", + repo_id: 12345, + instructions: "Investigate and fix", + trigger_type: "slack_event", + schedule_cron: null, + schedule_tz: "UTC", + model: "anthropic/claude-sonnet-4-6", + reasoning_effort: null, + enabled: 1, + next_run_at: null, + consecutive_failures: 0, + created_by: "user-1", + user_id: null, + created_at: now, + updated_at: now, + deleted_at: null, + event_type: "message.posted", + trigger_config: JSON.stringify({ + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }), + trigger_auth_data: null, + ...overrides, + }; +} + +function makeRun(automationId: string, overrides?: Partial): AutomationRunRow { + const now = Date.now(); + return { + id: `run-${Math.random().toString(36).slice(2, 8)}`, + automation_id: automationId, + session_id: null, + status: "starting", + skip_reason: null, + failure_reason: null, + scheduled_at: now, + started_at: null, + completed_at: null, + created_at: now, + trigger_key: null, + concurrency_key: null, + ...overrides, + }; +} + +function makeSlackEvent(overrides?: Partial): SlackAutomationEvent { + const ts = `${Date.now()}.${Math.floor(Math.random() * 1e6)}`; + return { + source: "slack", + eventType: "message.posted", + triggerKey: `slack:msg:C1:${ts}`, + concurrencyKey: `slack:C1:${ts}`, + contextBlock: "A message was posted in Slack channel #ops by user U1.", + meta: {}, + channelId: "C1", + ts, + actorUserId: "U1", + text: "please deploy the api", + ...overrides, + }; +} + +async function sendEvent(event: SlackAutomationEvent): Promise { + const opts = { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(event), + }; + try { + return await getSchedulerStub().fetch("http://internal/internal/event", opts); + } catch (e) { + if (e instanceof Error && e.message.includes("invalidating this Durable Object")) { + return getSchedulerStub().fetch("http://internal/internal/event", opts); + } + throw e; + } +} + +/** Create a watched slack_event automation (channel C1, text_match contains "deploy"). */ +async function seedSlackAutomation( + store: AutomationStore, + overrides?: Partial +): Promise { + const id = `auto-slack-${Math.random().toString(36).slice(2, 8)}`; + await store.create(makeAutomation({ id, ...overrides })); + await store.setSlackChannels(id, ["C1"]); + return id; +} + +describe("SchedulerDO /internal/event — slack (integration)", () => { + beforeEach(cleanD1Tables); + + it("triggers a matching slack automation and records thread coordinates", async () => { + const store = new AutomationStore(env.DB); + const id = await seedSlackAutomation(store); + + const event = makeSlackEvent({ text: "please deploy the api" }); + const res = await sendEvent(event); + expect(res.status).toBe(200); + + const runs = await store.listRunsForAutomation(id, { limit: 10, offset: 0 }); + expect(runs.total).toBeGreaterThanOrEqual(1); + const run = runs.runs.find((r) => r.trigger_key === event.triggerKey)!; + expect(run).toBeDefined(); + expect(run.slack_channel).toBe("C1"); + expect(run.slack_message_ts).toBe(event.ts); + expect(run.actor_user_id).toBe("U1"); + }); + + it("does not trigger when the text_match condition fails", async () => { + const store = new AutomationStore(env.DB); + const id = await seedSlackAutomation(store); + + const res = await sendEvent(makeSlackEvent({ text: "good morning team" })); + const body = await res.json<{ triggered: number; skipped: number }>(); + expect(body.triggered).toBe(0); + expect(body.skipped).toBe(0); + + const runs = await store.listRunsForAutomation(id, { limit: 10, offset: 0 }); + expect(runs.total).toBe(0); + }); + + it("does not trigger when the channel is not watched (no candidate)", async () => { + const store = new AutomationStore(env.DB); + const id = await seedSlackAutomation(store); + + // Event in an unwatched channel — the join table returns no candidate. + const res = await sendEvent( + makeSlackEvent({ + channelId: "C2", + text: "please deploy", + triggerKey: "slack:msg:C2:1", + concurrencyKey: "slack:C2:1", + }) + ); + const body = await res.json<{ triggered: number; skipped: number }>(); + expect(body.triggered).toBe(0); + expect(body.skipped).toBe(0); + + const runs = await store.listRunsForAutomation(id, { limit: 10, offset: 0 }); + expect(runs.total).toBe(0); + }); + + it("rate-limits once max_runs_per_hour materialized runs exist in the window", async () => { + const store = new AutomationStore(env.DB); + const id = await seedSlackAutomation(store, { max_runs_per_hour: 2 }); + + const now = Date.now(); + await store.insertRun( + makeRun(id, { + id: "seed-1", + status: "completed", + scheduled_at: now - 1000, + created_at: now - 1000, + }) + ); + await store.insertRun( + makeRun(id, { + id: "seed-2", + status: "completed", + scheduled_at: now - 2000, + created_at: now - 2000, + }) + ); + + const res = await sendEvent(makeSlackEvent({ text: "please deploy" })); + const body = await res.json<{ triggered: number; skipped: number }>(); + expect(body.skipped).toBe(1); + expect(body.triggered).toBe(0); + + const runs = await store.listRunsForAutomation(id, { limit: 20, offset: 0 }); + const rateLimited = runs.runs.find((r) => r.skip_reason === "rate_limited"); + expect(rateLimited).toBeDefined(); + expect(rateLimited!.status).toBe("skipped"); + // No new materialized run — only the two seeds count. + expect(runs.runs.filter((r) => r.status !== "skipped")).toHaveLength(2); + }); + + it("does not count prior skipped runs toward the rate limit", async () => { + const store = new AutomationStore(env.DB); + const id = await seedSlackAutomation(store, { max_runs_per_hour: 2 }); + + const now = Date.now(); + // 1 materialized + several skipped (distinct scheduled_at to avoid the idempotency index). + await store.insertRun( + makeRun(id, { + id: "mat-1", + status: "completed", + scheduled_at: now - 1000, + created_at: now - 1000, + }) + ); + for (let i = 0; i < 4; i++) { + await store.insertRun( + makeRun(id, { + id: `skip-${i}`, + status: "skipped", + skip_reason: "rate_limited", + scheduled_at: now - 100 * (i + 2), + created_at: now - 100 * (i + 2), + }) + ); + } + + // Non-skipped count = 1 < 2 → this event should still trigger. + const event = makeSlackEvent({ text: "deploy now" }); + await sendEvent(event); + + const runs = await store.listRunsForAutomation(id, { limit: 50, offset: 0 }); + const matched = runs.runs.find((r) => r.trigger_key === event.triggerKey); + expect(matched).toBeDefined(); + expect(matched!.status).not.toBe("skipped"); + }); + + it("records a concurrency skip for slack and creates no new materialized run", async () => { + const store = new AutomationStore(env.DB); + const id = await seedSlackAutomation(store); + + const concurrencyKey = "slack:C1:thread-1"; + await store.insertRun( + makeRun(id, { + id: "active-1", + status: "running", + session_id: "sess-x", + started_at: Date.now(), + concurrency_key: concurrencyKey, + trigger_key: "slack:msg:C1:first", + }) + ); + + const res = await sendEvent( + makeSlackEvent({ text: "deploy", concurrencyKey, triggerKey: "slack:msg:C1:second" }) + ); + const body = await res.json<{ triggered: number; skipped: number }>(); + expect(body.skipped).toBe(1); + expect(body.triggered).toBe(0); + + const runs = await store.listRunsForAutomation(id, { limit: 20, offset: 0 }); + const skip = runs.runs.find((r) => r.skip_reason === "concurrent_run_active"); + expect(skip).toBeDefined(); + expect(skip!.slack_channel).toBe("C1"); + }); + + it("dedups a duplicate slack message with the same trigger_key", async () => { + const store = new AutomationStore(env.DB); + const id = await seedSlackAutomation(store); + + const event = makeSlackEvent({ + text: "deploy", + triggerKey: "slack:msg:C1:dup", + concurrencyKey: "slack:C1:dup", + }); + await sendEvent(event); + const res2 = await sendEvent(event); + const body2 = await res2.json<{ triggered: number; skipped: number }>(); + expect(body2.skipped).toBe(1); + + const runs = await store.listRunsForAutomation(id, { limit: 20, offset: 0 }); + expect(runs.runs.filter((r) => r.trigger_key === "slack:msg:C1:dup")).toHaveLength(1); + }); +}); diff --git a/packages/control-plane/test/integration/webhooks-slack.test.ts b/packages/control-plane/test/integration/webhooks-slack.test.ts new file mode 100644 index 000000000..861f2f6f1 --- /dev/null +++ b/packages/control-plane/test/integration/webhooks-slack.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { SELF, env } from "cloudflare:test"; +import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; +import { generateInternalToken } from "../../src/auth/internal"; +import { cleanD1Tables } from "./cleanup"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function authHeaders(): Promise> { + const token = await generateInternalToken(env.INTERNAL_CALLBACK_SECRET!); + return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; +} + +function makeSlackEventBody(overrides?: Record): Record { + const ts = `${Date.now()}.${Math.floor(Math.random() * 1e6)}`; + return { + source: "slack", + eventType: "message.posted", + triggerKey: `slack:msg:C1:${ts}`, + concurrencyKey: `slack:C1:${ts}`, + contextBlock: "A message was posted in Slack channel #ops by user U1.", + meta: {}, + channelId: "C1", + ts, + actorUserId: "U1", + text: "please deploy the api", + ...overrides, + }; +} + +function makeSlackAutomation(overrides?: Partial): AutomationRow { + const now = Date.now(); + return { + id: `auto-slack-${Math.random().toString(36).slice(2, 8)}`, + name: "Slack triage", + repo_owner: "acme", + repo_name: "web-app", + base_branch: "main", + repo_id: 12345, + instructions: "Investigate and fix", + trigger_type: "slack_event", + schedule_cron: null, + schedule_tz: "UTC", + model: "anthropic/claude-sonnet-4-6", + reasoning_effort: null, + enabled: 1, + next_run_at: null, + consecutive_failures: 0, + created_by: "user-1", + user_id: null, + created_at: now, + updated_at: now, + deleted_at: null, + event_type: "message.posted", + trigger_config: JSON.stringify({ + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }), + trigger_auth_data: null, + ...overrides, + }; +} + +async function seedSlackAutomation(): Promise { + const store = new AutomationStore(env.DB); + const automation = makeSlackAutomation(); + await store.create(automation); + await store.setSlackChannels(automation.id, ["C1"]); + return automation.id; +} + +async function postEvent( + body: Record, + headers?: Record +): Promise { + return SELF.fetch("https://test.local/internal/slack-event", { + method: "POST", + headers: headers ?? (await authHeaders()), + body: JSON.stringify(body), + }); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("POST /internal/slack-event (integration)", () => { + beforeEach(cleanD1Tables); + + it("returns 401 without an internal token", async () => { + const res = await postEvent(makeSlackEventBody(), { "Content-Type": "application/json" }); + expect(res.status).toBe(401); + }); + + it("returns 401 with an invalid internal token", async () => { + const res = await postEvent(makeSlackEventBody(), { + Authorization: "Bearer not-a-valid-token", + "Content-Type": "application/json", + }); + expect(res.status).toBe(401); + }); + + it("returns 400 for invalid JSON", async () => { + const res = await SELF.fetch("https://test.local/internal/slack-event", { + method: "POST", + headers: await authHeaders(), + body: "not json", + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when source is not 'slack'", async () => { + const res = await postEvent(makeSlackEventBody({ source: "github" })); + expect(res.status).toBe(400); + expect(await res.text()).toContain("source"); + }); + + it("returns 400 when channelId is missing", async () => { + const res = await postEvent(makeSlackEventBody({ channelId: undefined })); + expect(res.status).toBe(400); + expect(await res.text()).toContain("channelId"); + }); + + it("returns 400 when ts is missing", async () => { + const res = await postEvent(makeSlackEventBody({ ts: undefined })); + expect(res.status).toBe(400); + expect(await res.text()).toContain("ts"); + }); + + it("returns 400 when eventType/triggerKey/concurrencyKey are missing", async () => { + const res = await postEvent(makeSlackEventBody({ triggerKey: undefined })); + expect(res.status).toBe(400); + }); + + it("forwards a valid event to the scheduler and returns trigger counts", async () => { + const id = await seedSlackAutomation(); + const body = makeSlackEventBody({ text: "please deploy the api" }); + + // The DO can transiently throw an invalidation error in the test runtime, + // which the handler surfaces as a 502. Retry once to absorb that. + let res = await postEvent(body); + if (res.status === 502) { + res = await postEvent(body); + } + + expect(res.status).toBe(200); + const result = await res.json<{ ok: boolean; triggered: number; skipped: number }>(); + expect(result.ok).toBe(true); + expect(result.triggered).toBe(1); + expect(result.skipped).toBe(0); + + // The forward actually materialized a run for the seeded automation. + const store = new AutomationStore(env.DB); + const runs = await store.listRunsForAutomation(id, { limit: 10, offset: 0 }); + expect(runs.runs.some((r) => r.trigger_key === body.triggerKey)).toBe(true); + }); +}); diff --git a/packages/shared/src/slack/client.ts b/packages/shared/src/slack/client.ts index e134b126a..42cb0a339 100644 --- a/packages/shared/src/slack/client.ts +++ b/packages/shared/src/slack/client.ts @@ -140,6 +140,27 @@ export function getPermalink( return slackGet(token, "chat.getPermalink", { channel, message_ts: messageTs }); } +/** + * Post an ephemeral message visible only to `user` in `channel` (optionally + * threaded). Used to surface best-effort notices — e.g. "a run is already + * active for this thread" — without adding noise for everyone else. + */ +export function postEphemeral( + token: string, + channel: string, + user: string, + text: string, + options?: { thread_ts?: string; blocks?: unknown[] } +): Promise> { + return slackPost(token, "chat.postEphemeral", { + channel, + user, + text, + thread_ts: options?.thread_ts, + blocks: options?.blocks, + }); +} + export function updateMessage( token: string, channel: string, @@ -173,6 +194,23 @@ export function removeReaction( return slackPost(token, "reactions.remove", { channel, timestamp: messageTs, name }); } +/** Subset of the `auth.test` response the bot uses to learn its own identity. */ +export interface SlackAuthTestResult { + user_id: string; + user?: string; + team_id?: string; + team?: string; + bot_id?: string; +} + +/** + * Call `auth.test` to resolve the identity of the token's bot user. The + * slack-bot uses the returned `user_id` to strip and suppress its own mentions. + */ +export function authTest(token: string): Promise> { + return slackPost(token, "auth.test"); +} + export interface SlackChannelInfo { id: string; name: string; diff --git a/packages/shared/src/slack/index.ts b/packages/shared/src/slack/index.ts index 090eccae1..b93aee0e5 100644 --- a/packages/shared/src/slack/index.ts +++ b/packages/shared/src/slack/index.ts @@ -1,17 +1,25 @@ export { addReaction, + authTest, getChannelInfo, getPermalink, getThreadMessages, getUserInfo, openView, + postEphemeral, postMessage, publishView, removeReaction, updateMessage, verifySlackSignature, } from "./client"; -export type { SlackChannelInfo, SlackEnvelope, SlackThreadMessage, SlackUser } from "./client"; +export type { + SlackAuthTestResult, + SlackChannelInfo, + SlackEnvelope, + SlackThreadMessage, + SlackUser, +} from "./client"; export { applyMentionPolicy, sanitizeAgentText, diff --git a/packages/shared/src/slack/mrkdwn.test.ts b/packages/shared/src/slack/mrkdwn.test.ts index d5133777e..68a8c1e29 100644 --- a/packages/shared/src/slack/mrkdwn.test.ts +++ b/packages/shared/src/slack/mrkdwn.test.ts @@ -115,6 +115,18 @@ describe("applyMentionPolicy", () => { it("preserves channel refs", () => { expect(applyMentionPolicy("<#C123|ops> <@U1>", "strip")).toBe("<#C123|ops> "); }); + + it("escape converts the piped <@ID|label> form to literal @ID", () => { + expect(applyMentionPolicy("hi <@U123|cole>", "escape")).toBe("hi @U123"); + }); + + it("strip removes the piped <@ID|label> form", () => { + expect(applyMentionPolicy("hi <@U123|cole>", "strip")).toBe("hi "); + }); + + it("handles a mix of bare and piped mentions", () => { + expect(applyMentionPolicy("<@U1> and <@U2|two>", "escape")).toBe("@U1 and @U2"); + }); }); describe("truncateForSlack", () => { diff --git a/packages/shared/src/slack/mrkdwn.ts b/packages/shared/src/slack/mrkdwn.ts index b44d30d0b..d8fb2418f 100644 --- a/packages/shared/src/slack/mrkdwn.ts +++ b/packages/shared/src/slack/mrkdwn.ts @@ -22,7 +22,7 @@ const TRUNCATION_MARKER = "… (truncated)"; const BROADCAST_MENTION_RE = /]*)?)>/g; const URL_LINK_RE = /<(https?:\/\/[^|>\s]+|mailto:[^|>\s]+)(?:\|[^>]*)?>/g; -const USER_MENTION_RE = /<@([A-Z0-9]+)>/g; +const USER_MENTION_RE = /<@([A-Z0-9]+)(?:\|[^>]*)?>/g; export function stripBroadcastMentions(text: string): string { return text.replace(BROADCAST_MENTION_RE, ""); diff --git a/packages/shared/src/triggers/conditions.ts b/packages/shared/src/triggers/conditions.ts index a04c73254..9c7067e1a 100644 --- a/packages/shared/src/triggers/conditions.ts +++ b/packages/shared/src/triggers/conditions.ts @@ -20,6 +20,9 @@ export interface ConditionConfigMap { sentry_project: { operator: "any_of"; value: string[] }; sentry_level: { operator: "any_of"; value: string[] }; jsonpath: { operator: "all_match"; value: JsonPathFilter[] }; + text_match: { operator: "contains" | "exact" | "regex"; value: TextMatchValue }; + slack_channel: { operator: "any_of"; value: string[] }; + slack_actor: { operator: "include" | "exclude"; value: string[] }; } export interface JsonPathFilter { @@ -28,6 +31,14 @@ export interface JsonPathFilter { value?: string | number | boolean; } +/** Value shape for the `text_match` condition (keyword / substring / regex). */ +export interface TextMatchValue { + /** Keyword/substring (contains/exact) or regular-expression source (regex). */ + pattern: string; + /** Case/regex flags; only an allowlisted subset is accepted (see ALLOWED_REGEX_FLAGS). */ + flags?: string; +} + // ─── 2. Derived discriminated union ────────────────────────────────────────── export type TriggerCondition = { diff --git a/packages/shared/src/triggers/index.ts b/packages/shared/src/triggers/index.ts index 22094c596..42b6dd4f1 100644 --- a/packages/shared/src/triggers/index.ts +++ b/packages/shared/src/triggers/index.ts @@ -10,6 +10,7 @@ export type { LinearAutomationEvent, SentryAutomationEvent, WebhookAutomationEvent, + SlackAutomationEvent, TriggerSourceDefinition, } from "./types"; export { TRIGGER_TYPE_TO_SOURCE } from "./types"; @@ -22,6 +23,7 @@ export type { ConditionHandler, ConditionRegistry, JsonPathFilter, + TextMatchValue, TriggerConfig, } from "./conditions"; export { matchesConditions, validateConditions } from "./conditions"; @@ -53,3 +55,14 @@ export { evaluateJsonPathFilter, buildWebhookContextBlock, } from "./webhook"; + +// Slack source module +export { + slackSource, + normalizeSlackEvent, + SLACK_TEXT_MAX_LENGTH, + REGEX_PATTERN_MAX_LENGTH, + ALLOWED_REGEX_FLAGS, + DEFAULT_MAX_RUNS_PER_HOUR, +} from "./slack"; +export type { SlackMessageInput, SlackChannelMeta } from "./slack"; diff --git a/packages/shared/src/triggers/registry.ts b/packages/shared/src/triggers/registry.ts index e0796a78c..64ceceb6e 100644 --- a/packages/shared/src/triggers/registry.ts +++ b/packages/shared/src/triggers/registry.ts @@ -7,6 +7,7 @@ import type { TriggerSourceDefinition } from "./types"; import { sentrySource, sentryConditions } from "./sentry"; import { webhookSource, webhookConditions } from "./webhook"; import { githubSource } from "./github"; +import { slackSource, slackConditions } from "./slack"; // GitHub and Linear condition handlers (stubs for Phase 2c). // These need to exist so that the ConditionRegistry is complete. @@ -114,6 +115,7 @@ export const conditionRegistry: ConditionRegistry = { ...sharedConditions, ...sentryConditions, ...webhookConditions, + ...slackConditions, }; /** @@ -124,4 +126,5 @@ export const triggerSources: TriggerSourceDefinition[] = [ sentrySource, webhookSource, githubSource, + slackSource, ]; diff --git a/packages/shared/src/triggers/slack/conditions.test.ts b/packages/shared/src/triggers/slack/conditions.test.ts new file mode 100644 index 000000000..82b9df65f --- /dev/null +++ b/packages/shared/src/triggers/slack/conditions.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect } from "vitest"; +import { matchesConditions, validateConditions } from "../conditions"; +import type { TriggerCondition } from "../conditions"; +import { conditionRegistry } from "../registry"; +import { buildMockEvent } from "../testing"; +import { SLACK_TEXT_MAX_LENGTH } from "./normalizer"; + +const slackEvent = (overrides: { text?: string; channelId?: string; actorUserId?: string }) => + buildMockEvent("slack", overrides); + +const match = (condition: TriggerCondition, event = slackEvent({ text: "please deploy now" })) => + matchesConditions([condition], event, conditionRegistry); + +describe("text_match condition", () => { + it("contains: case-sensitive match", () => { + expect(match({ type: "text_match", operator: "contains", value: { pattern: "deploy" } })).toBe( + true + ); + }); + + it("contains: case-sensitive miss", () => { + expect(match({ type: "text_match", operator: "contains", value: { pattern: "Deploy" } })).toBe( + false + ); + }); + + it("contains: case-insensitive with flags i", () => { + expect( + match({ type: "text_match", operator: "contains", value: { pattern: "DEPLOY", flags: "i" } }) + ).toBe(true); + }); + + it("exact: matches the whole text", () => { + expect( + match( + { type: "text_match", operator: "exact", value: { pattern: "deploy" } }, + slackEvent({ text: "deploy" }) + ) + ).toBe(true); + }); + + it("exact: rejects a substring", () => { + expect( + match( + { type: "text_match", operator: "exact", value: { pattern: "deploy" } }, + slackEvent({ text: "please deploy" }) + ) + ).toBe(false); + }); + + it("exact: case-insensitive with flags i", () => { + expect( + match( + { type: "text_match", operator: "exact", value: { pattern: "DEPLOY", flags: "i" } }, + slackEvent({ text: "deploy" }) + ) + ).toBe(true); + }); + + it("regex: matches", () => { + expect( + match({ type: "text_match", operator: "regex", value: { pattern: "deploy\\s+\\w+" } }) + ).toBe(true); + }); + + it("regex: no match", () => { + expect(match({ type: "text_match", operator: "regex", value: { pattern: "^urgent" } })).toBe( + false + ); + }); + + it("regex: a malformed pattern is a non-match, not a throw", () => { + expect(match({ type: "text_match", operator: "regex", value: { pattern: "(" } })).toBe(false); + }); + + it("regex: a disallowed flag is a non-match", () => { + expect( + match({ type: "text_match", operator: "regex", value: { pattern: "deploy", flags: "g" } }) + ).toBe(false); + }); + + it("does not match input over the length cap", () => { + const big = "deploy " + "x".repeat(SLACK_TEXT_MAX_LENGTH); + expect( + match( + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + slackEvent({ text: big }) + ) + ).toBe(false); + }); + + it("passes through (true) for non-slack events", () => { + expect( + matchesConditions( + [{ type: "text_match", operator: "contains", value: { pattern: "deploy" } }], + buildMockEvent("github"), + conditionRegistry + ) + ).toBe(true); + }); +}); + +describe("slack_channel condition", () => { + it("any_of matches when the channel is in the list", () => { + expect( + match( + { type: "slack_channel", operator: "any_of", value: ["C123", "C999"] }, + slackEvent({ channelId: "C123" }) + ) + ).toBe(true); + }); + + it("any_of misses when the channel is not in the list", () => { + expect( + match( + { type: "slack_channel", operator: "any_of", value: ["C999"] }, + slackEvent({ channelId: "C123" }) + ) + ).toBe(false); + }); +}); + +describe("slack_actor condition", () => { + it("include matches the poster", () => { + expect( + match( + { type: "slack_actor", operator: "include", value: ["U1"] }, + slackEvent({ actorUserId: "U1" }) + ) + ).toBe(true); + }); + + it("include rejects a non-listed poster", () => { + expect( + match( + { type: "slack_actor", operator: "include", value: ["U2"] }, + slackEvent({ actorUserId: "U1" }) + ) + ).toBe(false); + }); + + it("exclude rejects a listed poster", () => { + expect( + match( + { type: "slack_actor", operator: "exclude", value: ["U1"] }, + slackEvent({ actorUserId: "U1" }) + ) + ).toBe(false); + }); + + it("exclude allows a non-listed poster", () => { + expect( + match( + { type: "slack_actor", operator: "exclude", value: ["U2"] }, + slackEvent({ actorUserId: "U1" }) + ) + ).toBe(true); + }); +}); + +describe("validateConditions (slack)", () => { + it("rejects an empty text_match pattern", () => { + const errors = validateConditions( + [{ type: "text_match", operator: "contains", value: { pattern: "" } }], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it("rejects a disallowed regex flag", () => { + const errors = validateConditions( + [{ type: "text_match", operator: "regex", value: { pattern: "x", flags: "g" } }], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it("rejects an invalid regex pattern at save time", () => { + const errors = validateConditions( + [{ type: "text_match", operator: "regex", value: { pattern: "(" } }], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it("accepts a valid regex with an allowed flag", () => { + const errors = validateConditions( + [{ type: "text_match", operator: "regex", value: { pattern: "deploy", flags: "i" } }], + "slack", + conditionRegistry + ); + expect(errors).toHaveLength(0); + }); + + it("rejects an empty slack_channel list", () => { + const errors = validateConditions( + [{ type: "slack_channel", operator: "any_of", value: [] }], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it("reports a slack condition used on a github trigger", () => { + const errors = validateConditions( + [{ type: "slack_channel", operator: "any_of", value: ["C1"] }], + "github", + conditionRegistry + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("does not apply to github"); + }); +}); diff --git a/packages/shared/src/triggers/slack/conditions.ts b/packages/shared/src/triggers/slack/conditions.ts new file mode 100644 index 000000000..a0cb87d2b --- /dev/null +++ b/packages/shared/src/triggers/slack/conditions.ts @@ -0,0 +1,101 @@ +/** + * Slack condition handlers (text_match / slack_channel / slack_actor). + * + * Co-located with the rest of the slack trigger source (source definition, + * normalizer) and assembled into the central registry by `../registry`. Kept + * here — rather than inline in the registry — so the source owns its conditions, + * matching the sentry/webhook modules. + */ + +import type { ConditionRegistry, TextMatchValue } from "../conditions"; +import type { AutomationEvent } from "../types"; +import { SLACK_TEXT_MAX_LENGTH } from "./normalizer"; + +/** Max length of a user-supplied `text_match` regex pattern (characters). */ +export const REGEX_PATTERN_MAX_LENGTH = 200; + +/** Regex flags accepted for the `text_match` `regex` operator. */ +export const ALLOWED_REGEX_FLAGS = new Set(["i", "m"]); + +/** True when every flag character is on the allowlist (empty/undefined is allowed). */ +function flagsAllowed(flags: string | undefined): boolean { + if (!flags) return true; + for (const flag of flags) { + if (!ALLOWED_REGEX_FLAGS.has(flag)) return false; + } + return true; +} + +/** + * Slack condition handlers. `slack_actor` is a distinct slack-only handler — the + * github/linear `actor` handler passes through for slack, so it cannot be reused. + */ +export const slackConditions = { + text_match: { + appliesTo: ["slack"] as const, + validate(c: { operator: "contains" | "exact" | "regex"; value: TextMatchValue }) { + const { pattern, flags } = c.value; + if (!pattern) return "Text match pattern is required"; + if (pattern.length > REGEX_PATTERN_MAX_LENGTH) { + return `Pattern exceeds the ${REGEX_PATTERN_MAX_LENGTH}-character limit`; + } + if (!flagsAllowed(flags)) return "Unsupported regex flag"; + if (c.operator === "regex") { + try { + new RegExp(pattern, flags ?? ""); + } catch { + return "Invalid regex pattern"; + } + } + return null; + }, + evaluate( + c: { operator: "contains" | "exact" | "regex"; value: TextMatchValue }, + event: AutomationEvent + ) { + if (event.source !== "slack") return true; + const { text } = event; + // Defensive input bound — the normalizer already caps text at this length. + if (text.length > SLACK_TEXT_MAX_LENGTH) return false; + const { pattern, flags } = c.value; + const caseInsensitive = flags?.includes("i") ?? false; + if (c.operator === "contains") { + return caseInsensitive + ? text.toLowerCase().includes(pattern.toLowerCase()) + : text.includes(pattern); + } + if (c.operator === "exact") { + return caseInsensitive ? text.toLowerCase() === pattern.toLowerCase() : text === pattern; + } + // regex + if (pattern.length > REGEX_PATTERN_MAX_LENGTH) return false; + if (!flagsAllowed(flags)) return false; + try { + return new RegExp(pattern, flags ?? "").test(text); + } catch { + return false; + } + }, + }, + slack_channel: { + appliesTo: ["slack"] as const, + validate(c: { value: string[] }) { + return c.value.length === 0 ? "At least one channel required" : null; + }, + evaluate(c: { value: string[] }, event: AutomationEvent) { + if (event.source !== "slack") return true; + return c.value.includes(event.channelId); + }, + }, + slack_actor: { + appliesTo: ["slack"] as const, + validate(c: { value: string[] }) { + return c.value.length === 0 ? "At least one actor required" : null; + }, + evaluate(c: { operator: "include" | "exclude"; value: string[] }, event: AutomationEvent) { + if (event.source !== "slack") return true; + const inList = c.value.includes(event.actorUserId); + return c.operator === "include" ? inList : !inList; + }, + }, +} satisfies Partial; diff --git a/packages/shared/src/triggers/slack/index.ts b/packages/shared/src/triggers/slack/index.ts new file mode 100644 index 000000000..426c0b9bb --- /dev/null +++ b/packages/shared/src/triggers/slack/index.ts @@ -0,0 +1,29 @@ +/** + * Slack trigger source module. + */ + +import type { TriggerSourceDefinition } from "../types"; + +export type { SlackAutomationEvent } from "../types"; +export { normalizeSlackEvent, SLACK_TEXT_MAX_LENGTH } from "./normalizer"; +export type { SlackMessageInput, SlackChannelMeta } from "./normalizer"; +export { slackConditions, REGEX_PATTERN_MAX_LENGTH, ALLOWED_REGEX_FLAGS } from "./conditions"; + +/** Default per-automation hourly run cap when none is configured (rate limit). */ +export const DEFAULT_MAX_RUNS_PER_HOUR = 10; + +export const slackSource: TriggerSourceDefinition = { + source: "slack", + triggerType: "slack_event", + displayName: "Slack Message", + description: "Trigger when a message is posted in a watched Slack channel", + supportsEventTypes: false, + eventTypes: [ + { + eventType: "message.posted", + displayName: "Message posted", + description: "A message is posted in a watched channel", + }, + ], + supportedConditions: ["text_match", "slack_channel", "slack_actor"], +}; diff --git a/packages/shared/src/triggers/slack/normalizer.test.ts b/packages/shared/src/triggers/slack/normalizer.test.ts new file mode 100644 index 000000000..9903b41af --- /dev/null +++ b/packages/shared/src/triggers/slack/normalizer.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { normalizeSlackEvent, SLACK_TEXT_MAX_LENGTH } from "./normalizer"; + +const baseInput = { + channel: "C123", + ts: "1700000000.000100", + user: "U999", + text: "please deploy the api", +}; + +describe("normalizeSlackEvent", () => { + it("normalizes a valid channel message", () => { + const event = normalizeSlackEvent(baseInput, "UBOT", { + channelName: "ops", + permalink: "https://example.slack.com/archives/C123/p1700000000000100", + }); + expect(event).not.toBeNull(); + expect(event!.source).toBe("slack"); + expect(event!.eventType).toBe("message.posted"); + expect(event!.channelId).toBe("C123"); + expect(event!.channelName).toBe("ops"); + expect(event!.actorUserId).toBe("U999"); + expect(event!.ts).toBe("1700000000.000100"); + expect(event!.text).toBe("please deploy the api"); + expect(event!.triggerKey).toBe("slack:msg:C123:1700000000.000100"); + expect(event!.concurrencyKey).toBe("slack:C123:1700000000.000100"); + expect(event!.contextBlock).toContain("please deploy the api"); + expect(event!.contextBlock).toContain("ops"); + }); + + it("uses thread_ts for the concurrency key on a thread reply", () => { + const event = normalizeSlackEvent({ ...baseInput, thread_ts: "1699999999.000001" }, "UBOT"); + expect(event!.threadTs).toBe("1699999999.000001"); + expect(event!.concurrencyKey).toBe("slack:C123:1699999999.000001"); + // triggerKey stays keyed on the message ts (one run per message). + expect(event!.triggerKey).toBe("slack:msg:C123:1700000000.000100"); + }); + + it("returns null for empty/whitespace text", () => { + expect(normalizeSlackEvent({ ...baseInput, text: " " }, "UBOT")).toBeNull(); + expect(normalizeSlackEvent({ ...baseInput, text: "" }, "UBOT")).toBeNull(); + }); + + it("strips the bot's own bare mention <@BOT>", () => { + const event = normalizeSlackEvent({ ...baseInput, text: "<@UBOT> please deploy" }, "UBOT"); + expect(event!.text).toBe("please deploy"); + expect(event!.text).not.toContain("UBOT"); + }); + + it("strips the bot's piped mention <@BOT|name>", () => { + const event = normalizeSlackEvent( + { ...baseInput, text: "<@UBOT|open-inspect> please deploy" }, + "UBOT" + ); + expect(event!.text).toBe("please deploy"); + expect(event!.text).not.toContain("UBOT"); + }); + + it("returns null when the message is only the bot mention", () => { + expect(normalizeSlackEvent({ ...baseInput, text: "<@UBOT>" }, "UBOT")).toBeNull(); + expect(normalizeSlackEvent({ ...baseInput, text: "<@UBOT|open-inspect>" }, "UBOT")).toBeNull(); + }); + + it("does not strip other users' mentions", () => { + const event = normalizeSlackEvent({ ...baseInput, text: "<@UBOT> ping <@U777>" }, "UBOT"); + expect(event!.text).toContain("<@U777>"); + }); + + it("caps text at SLACK_TEXT_MAX_LENGTH", () => { + const event = normalizeSlackEvent( + { ...baseInput, text: "x".repeat(SLACK_TEXT_MAX_LENGTH + 500) }, + "UBOT" + ); + expect(event!.text.length).toBe(SLACK_TEXT_MAX_LENGTH); + }); + + it("falls back to the channel id when no channel name is supplied", () => { + const event = normalizeSlackEvent(baseInput, "UBOT"); + expect(event!.channelName).toBeUndefined(); + expect(event!.contextBlock).toContain("C123"); + }); +}); diff --git a/packages/shared/src/triggers/slack/normalizer.ts b/packages/shared/src/triggers/slack/normalizer.ts new file mode 100644 index 000000000..cc9ab5c08 --- /dev/null +++ b/packages/shared/src/triggers/slack/normalizer.ts @@ -0,0 +1,87 @@ +/** + * Normalize raw Slack channel messages into SlackAutomationEvent objects. + */ + +import type { SlackAutomationEvent } from "../types"; + +/** Max length of message text retained for matching + context (characters). */ +export const SLACK_TEXT_MAX_LENGTH = 8 * 1024; + +/** Minimal shape of the Slack `message` event fields the normalizer consumes. */ +export interface SlackMessageInput { + channel: string; + ts: string; + thread_ts?: string; + user: string; + text: string; +} + +/** Bot-fetched channel metadata the shared normalizer cannot resolve itself. */ +export interface SlackChannelMeta { + channelName?: string; + permalink?: string; +} + +/** Matches both the bare `<@U…>` and piped `<@U…|display-name>` renderings. */ +function botMentionPattern(botUserId: string): RegExp { + return new RegExp(`<@${botUserId}(?:\\|[^>]*)?>`, "g"); +} + +function buildContextBlock(params: { + channelLabel: string; + actorUserId: string; + permalink?: string; + text: string; +}): string { + const lines = [ + `A message was posted in Slack channel ${params.channelLabel} by user ${params.actorUserId}.`, + ]; + if (params.permalink) lines.push(`Permalink: ${params.permalink}`); + lines.push("", "", params.text, ""); + return lines.join("\n"); +} + +/** + * Normalize a Slack channel message into a SlackAutomationEvent. + * Returns null when the message has no usable text (e.g. it is only the bot mention). + * + * The caller (slack-bot) supplies `botUserId` so the bot's own mention token is + * stripped, and `channelMeta` for the human-readable name + permalink the shared + * package cannot fetch (it has no Slack token). + */ +export function normalizeSlackEvent( + input: SlackMessageInput, + botUserId: string, + channelMeta?: SlackChannelMeta +): SlackAutomationEvent | null { + const stripped = (input.text ?? "").replace(botMentionPattern(botUserId), "").trim(); + if (!stripped) return null; + + const text = stripped.slice(0, SLACK_TEXT_MAX_LENGTH); + const channelLabel = channelMeta?.channelName ? `#${channelMeta.channelName}` : input.channel; + + return { + source: "slack", + eventType: "message.posted", + triggerKey: `slack:msg:${input.channel}:${input.ts}`, + concurrencyKey: `slack:${input.channel}:${input.thread_ts ?? input.ts}`, + channelId: input.channel, + channelName: channelMeta?.channelName, + threadTs: input.thread_ts, + ts: input.ts, + actorUserId: input.user, + text, + contextBlock: buildContextBlock({ + channelLabel, + actorUserId: input.user, + permalink: channelMeta?.permalink, + text, + }), + meta: { + channelId: input.channel, + ts: input.ts, + threadTs: input.thread_ts, + permalink: channelMeta?.permalink, + }, + }; +} diff --git a/packages/shared/src/triggers/testing.ts b/packages/shared/src/triggers/testing.ts index 975aeefa9..84cd13737 100644 --- a/packages/shared/src/triggers/testing.ts +++ b/packages/shared/src/triggers/testing.ts @@ -9,6 +9,7 @@ import type { WebhookAutomationEvent, GitHubAutomationEvent, LinearAutomationEvent, + SlackAutomationEvent, } from "./types"; import type { TriggerCondition } from "./conditions"; import { matchesConditions } from "./conditions"; @@ -63,6 +64,19 @@ const defaults: Record AutomationEvent> = { automationId: "auto-1", body: {}, }) as WebhookAutomationEvent, + slack: () => + ({ + source: "slack", + eventType: "message.posted", + triggerKey: "slack:msg:C123:1700000000.000100", + concurrencyKey: "slack:C123:1700000000.000100", + contextBlock: "Test Slack context", + meta: {}, + channelId: "C123", + ts: "1700000000.000100", + actorUserId: "U999", + text: "test slack message", + }) satisfies SlackAutomationEvent, }; /** diff --git a/packages/shared/src/triggers/types.ts b/packages/shared/src/triggers/types.ts index b3cda7b7d..7a4a60058 100644 --- a/packages/shared/src/triggers/types.ts +++ b/packages/shared/src/triggers/types.ts @@ -7,7 +7,7 @@ import type { ConditionType } from "./conditions"; // ─── Event Sources ──────────────────────────────────────────────────────────── -export type AutomationEventSource = "github" | "linear" | "sentry" | "webhook"; +export type AutomationEventSource = "github" | "linear" | "sentry" | "webhook" | "slack"; /** * Maps AutomationTriggerType → AutomationEventSource. @@ -19,6 +19,7 @@ export const TRIGGER_TYPE_TO_SOURCE: Partial ({ mockAuthTest: vi.fn() })); + +vi.mock("@open-inspect/shared", async () => { + const actual = await vi.importActual("@open-inspect/shared"); + return { ...actual, authTest: mockAuthTest }; +}); + +import { getBotUserId, clearBotUserIdCache } from "./bot-identity"; + +function makeEnv(kv: { get: ReturnType; put: ReturnType }): Env { + return { + SLACK_KV: kv, + SLACK_BOT_TOKEN: "xoxb-test", + } as unknown as Env; +} + +function emptyKv() { + return { + get: vi.fn().mockResolvedValue(null), + put: vi.fn().mockResolvedValue(undefined), + }; +} + +describe("getBotUserId", () => { + beforeEach(() => { + clearBotUserIdCache(); + vi.clearAllMocks(); + }); + + it("resolves the user id via auth.test and persists it to KV", async () => { + mockAuthTest.mockResolvedValue({ ok: true, user_id: "UBOT123" }); + const kv = emptyKv(); + + expect(await getBotUserId(makeEnv(kv))).toBe("UBOT123"); + expect(kv.put).toHaveBeenCalledWith("slack:bot-user-id", "UBOT123"); + }); + + it("caches in-process and does not call auth.test twice", async () => { + mockAuthTest.mockResolvedValue({ ok: true, user_id: "UBOT123" }); + const env = makeEnv(emptyKv()); + + await getBotUserId(env); + await getBotUserId(env); + + expect(mockAuthTest).toHaveBeenCalledTimes(1); + }); + + it("serves the KV last-known-good id when auth.test fails", async () => { + mockAuthTest.mockResolvedValue({ ok: false, error: "ratelimited" }); + const kv = { + get: vi.fn().mockResolvedValue("UCACHED"), + put: vi.fn().mockResolvedValue(undefined), + }; + + expect(await getBotUserId(makeEnv(kv))).toBe("UCACHED"); + expect(kv.get).toHaveBeenCalledWith("slack:bot-user-id"); + }); + + it("fails closed (null) when auth.test fails and KV is empty", async () => { + mockAuthTest.mockResolvedValue({ ok: false, error: "invalid_auth" }); + expect(await getBotUserId(makeEnv(emptyKv()))).toBeNull(); + }); + + it("fails closed (null) when auth.test succeeds without a user_id", async () => { + mockAuthTest.mockResolvedValue({ ok: true }); + expect(await getBotUserId(makeEnv(emptyKv()))).toBeNull(); + }); +}); diff --git a/packages/slack-bot/src/bot-identity.ts b/packages/slack-bot/src/bot-identity.ts new file mode 100644 index 000000000..99ada0205 --- /dev/null +++ b/packages/slack-bot/src/bot-identity.ts @@ -0,0 +1,80 @@ +/** + * Bot identity resolution. + * + * Channel-trigger processing needs the bot's own Slack user id to strip and + * suppress its own mentions. We resolve it once via `auth.test` and cache it — + * the id is effectively static for the lifetime of the Slack app. + */ + +import { authTest, createKvCacheStore } from "@open-inspect/shared"; +import type { Env } from "./types"; +import { createLogger } from "./logger"; + +const log = createLogger("bot-identity"); + +const BOT_USER_ID_KV_KEY = "slack:bot-user-id"; + +/** Bot identity is effectively static; cache it for an hour in-process. */ +const BOT_USER_ID_TTL_MS = 60 * 60 * 1000; + +let botUserIdCache: { + id: string; + timestamp: number; +} | null = null; + +/** + * Resolve the bot's own Slack user id via `auth.test`, cached in-process and in + * KV as last-known-good. + * + * Returns `null` when the id cannot be determined (auth.test failed and no KV + * copy exists). The caller fails **closed** and skips channel-trigger + * processing in that case — mention suppression and self-mention stripping both + * require a known id, so it is never safe to proceed without one. + */ +export async function getBotUserId(env: Env, traceId?: string): Promise { + if (botUserIdCache && Date.now() - botUserIdCache.timestamp < BOT_USER_ID_TTL_MS) { + return botUserIdCache.id; + } + + const result = await authTest(env.SLACK_BOT_TOKEN); + if (result.ok && result.user_id) { + botUserIdCache = { id: result.user_id, timestamp: Date.now() }; + try { + await createKvCacheStore(env.SLACK_KV).put(BOT_USER_ID_KV_KEY, result.user_id); + } catch (e) { + log.warn("kv.put", { + key_prefix: "bot_user_id", + error: e instanceof Error ? e : new Error(String(e)), + }); + } + return result.user_id; + } + + log.warn("slack.auth_test", { + trace_id: traceId, + outcome: "error", + error: result.ok ? "missing_user_id" : result.error, + }); + + // Last-known-good from KV — the id never really changes, so a stale copy is + // safe to serve through a transient auth.test failure. + try { + const cached = await createKvCacheStore(env.SLACK_KV).get(BOT_USER_ID_KV_KEY); + if (cached) { + botUserIdCache = { id: cached, timestamp: Date.now() }; + return cached; + } + } catch (e) { + log.warn("kv.get", { + key_prefix: "bot_user_id", + error: e instanceof Error ? e : new Error(String(e)), + }); + } + + return null; +} + +/** Clear the in-memory bot-id cache (for tests and forced refresh). */ +export function clearBotUserIdCache(): void { + botUserIdCache = null; +} diff --git a/packages/slack-bot/src/callbacks.test.ts b/packages/slack-bot/src/callbacks.test.ts index 3d9bad893..640639412 100644 --- a/packages/slack-bot/src/callbacks.test.ts +++ b/packages/slack-bot/src/callbacks.test.ts @@ -244,3 +244,138 @@ describe("POST /callbacks/tool_call", () => { await flushWaitUntil(ctx); }); }); + +function okFetchMock() { + return vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); +} + +function slackCall( + fetchMock: ReturnType, + endpoint: string +): { url: string; body: Record } | undefined { + const call = fetchMock.mock.calls.find(([url]) => String(url).includes(endpoint)); + if (!call) return undefined; + return { + url: String(call[0]), + body: JSON.parse(String((call[1] as RequestInit).body)) as Record, + }; +} + +async function postCallback(path: string, payload: unknown, env = makeEnv(), ctx = makeCtx()) { + const response = await makeApp().fetch( + new Request(`http://localhost${path}`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-trace-id": "trace-1" }, + body: JSON.stringify(payload), + }), + env, + ctx + ); + return { response, env, ctx }; +} + +describe("POST /callbacks/automation-complete", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function completeData(overrides: Record = {}) { + return { + channel: "C123", + threadTs: "111.222", + reactionMessageTs: "111.222", + sessionId: "sess-1", + success: true, + automationName: "Auto-triage", + ...overrides, + }; + } + + it("rejects an invalid payload", async () => { + const { response, ctx } = await postCallback("/callbacks/automation-complete", { + channel: "C123", + }); + expect(response.status).toBe(400); + expect(ctx.waitUntil).not.toHaveBeenCalled(); + }); + + it("rejects a bad signature", async () => { + const payload = await signPayload(completeData(), "wrong-secret"); + const { response, ctx } = await postCallback("/callbacks/automation-complete", payload); + expect(response.status).toBe(401); + expect(ctx.waitUntil).not.toHaveBeenCalled(); + }); + + it("posts a success result to the thread and clears the eyes reaction", async () => { + const fetchMock = okFetchMock(); + const payload = await signPayload(completeData()); + const { response, ctx } = await postCallback("/callbacks/automation-complete", payload); + + expect(response.status).toBe(200); + await flushWaitUntil(ctx); + + const post = slackCall(fetchMock, "chat.postMessage"); + expect(post).toBeDefined(); + expect(post!.body).toMatchObject({ channel: "C123", thread_ts: "111.222" }); + expect(JSON.stringify(post!.body.blocks)).toContain("Auto-triage"); + expect(JSON.stringify(post!.body.blocks)).toContain("app.test/session/sess-1"); + + const reaction = slackCall(fetchMock, "reactions.remove"); + expect(reaction).toBeDefined(); + expect(reaction!.body).toMatchObject({ channel: "C123", timestamp: "111.222", name: "eyes" }); + }); + + it("renders a failure summary when the run failed", async () => { + const fetchMock = okFetchMock(); + const payload = await signPayload( + completeData({ success: false, summary: "boom: the build broke" }) + ); + await postCallback("/callbacks/automation-complete", payload).then(({ ctx }) => + flushWaitUntil(ctx) + ); + + const post = slackCall(fetchMock, "chat.postMessage"); + expect(JSON.stringify(post!.body.blocks)).toContain("boom: the build broke"); + }); +}); + +describe("POST /callbacks/automation-skip", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function skipData(overrides: Record = {}) { + return { channel: "C123", user: "U9", threadTs: "111.222", ...overrides }; + } + + it("rejects an invalid payload", async () => { + const { response, ctx } = await postCallback("/callbacks/automation-skip", { channel: "C123" }); + expect(response.status).toBe(400); + expect(ctx.waitUntil).not.toHaveBeenCalled(); + }); + + it("rejects a bad signature", async () => { + const payload = await signPayload(skipData(), "wrong-secret"); + const { response, ctx } = await postCallback("/callbacks/automation-skip", payload); + expect(response.status).toBe(401); + expect(ctx.waitUntil).not.toHaveBeenCalled(); + }); + + it("posts an ephemeral notice to the message author", async () => { + const fetchMock = okFetchMock(); + const payload = await signPayload(skipData()); + const { response, ctx } = await postCallback("/callbacks/automation-skip", payload); + + expect(response.status).toBe(200); + await flushWaitUntil(ctx); + + const ephemeral = slackCall(fetchMock, "chat.postEphemeral"); + expect(ephemeral).toBeDefined(); + expect(ephemeral!.body).toMatchObject({ channel: "C123", user: "U9", thread_ts: "111.222" }); + }); +}); diff --git a/packages/slack-bot/src/callbacks.ts b/packages/slack-bot/src/callbacks.ts index 941a6aec5..6c16024c1 100644 --- a/packages/slack-bot/src/callbacks.ts +++ b/packages/slack-bot/src/callbacks.ts @@ -2,8 +2,14 @@ * Callback handlers for control-plane notifications. */ -import { computeHmacHex, postMessage, removeReaction, timingSafeEqual } from "@open-inspect/shared"; -import { Hono } from "hono"; +import { + computeHmacHex, + postEphemeral, + postMessage, + removeReaction, + timingSafeEqual, +} from "@open-inspect/shared"; +import { Hono, type Context } from "hono"; import type { Env, CompletionCallback, ToolCallCallback } from "./types"; import { extractAgentResponse } from "./completion/extractor"; import { buildCompletionBlocks, getFallbackText, truncateError } from "./completion/blocks"; @@ -97,23 +103,117 @@ function isValidToolCallPayload(payload: unknown): payload is ToolCallCallback { ); } -export const callbacksRouter = new Hono<{ Bindings: Env }>(); +/** + * Payload for a scheduler-owned automation completion (Slack-triggered run). + * Posted by the SchedulerDO into the originating thread. + */ +interface AutomationCompletePayload { + channel: string; + threadTs: string; + reactionMessageTs?: string; + sessionId: string | null; + success: boolean; + summary?: string; + automationName: string; + signature: string; +} + +function isValidAutomationCompletePayload(payload: unknown): payload is AutomationCompletePayload { + if (!isPlainRecord(payload)) return false; + const p = payload; + return ( + typeof p.channel === "string" && + typeof p.threadTs === "string" && + typeof p.success === "boolean" && + typeof p.automationName === "string" && + typeof p.signature === "string" && + (p.sessionId === null || typeof p.sessionId === "string") && + (p.summary === undefined || typeof p.summary === "string") && + (p.reactionMessageTs === undefined || typeof p.reactionMessageTs === "string") + ); +} + +/** Payload for a concurrency-skip ephemeral notice. */ +interface AutomationSkipPayload { + channel: string; + user: string; + threadTs: string; + signature: string; +} + +function isValidAutomationSkipPayload(payload: unknown): payload is AutomationSkipPayload { + if (!isPlainRecord(payload)) return false; + const p = payload; + return ( + typeof p.channel === "string" && + typeof p.user === "string" && + typeof p.threadTs === "string" && + typeof p.signature === "string" + ); +} /** - * Callback endpoint for session completion notifications. + * Build the blocks for an automation completion message: a ✅/❌ headline, an + * optional failure summary, and a "View Session" deep link when a session + * exists. Mirrors the compact style of the completion error path. */ -callbacksRouter.post("/complete", async (c) => { - const startTime = Date.now(); - // Use trace_id from control-plane if present, otherwise generate one - const traceId = c.req.header("x-trace-id") || crypto.randomUUID(); - const payload = await c.req.json(); +function buildAutomationCompletionBlocks( + payload: AutomationCompletePayload, + webAppUrl: string +): unknown[] { + const emoji = payload.success ? ":white_check_mark:" : ":x:"; + const verb = payload.success ? "completed" : "failed"; + const blocks: unknown[] = [ + { + type: "section", + text: { type: "mrkdwn", text: `${emoji} *Automation ${verb}:* ${payload.automationName}` }, + }, + ]; + + if (payload.summary) { + blocks.push({ + type: "section", + text: { type: "mrkdwn", text: truncateError(payload.summary, 2000) }, + }); + } - // Validate payload shape - if (!isValidPayload(payload)) { + if (payload.sessionId) { + blocks.push({ + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "View Session" }, + url: `${webAppUrl}/session/${payload.sessionId}`, + action_id: "view_session", + }, + ], + }); + } + + return blocks; +} + +/** + * Shared rejection guard for signed callback routes: validate the payload shape, + * require the signing secret, then verify the in-body HMAC signature. Returns a + * Response to short-circuit on any failure, or null when the request is + * authentic and the caller may proceed. Parsing stays in each route so the + * caller controls how a malformed body is surfaced. + */ +async function rejectInvalidCallback( + c: Context<{ Bindings: Env }>, + payload: unknown, + isValid: (p: unknown) => boolean, + opts: { path: string; traceId: string; startTime: number } +): Promise { + const { path, traceId, startTime } = opts; + + if (!isValid(payload)) { log.warn("http.request", { trace_id: traceId, http_method: "POST", - http_path: "/callbacks/complete", + http_path: path, http_status: 400, outcome: "rejected", reject_reason: "invalid_payload", @@ -122,12 +222,11 @@ callbacksRouter.post("/complete", async (c) => { return c.json({ error: "invalid payload" }, 400); } - // Verify signature (prevents external forgery) if (!c.env.INTERNAL_CALLBACK_SECRET) { log.error("http.request", { trace_id: traceId, http_method: "POST", - http_path: "/callbacks/complete", + http_path: path, http_status: 500, outcome: "error", reject_reason: "secret_not_configured", @@ -136,31 +235,56 @@ callbacksRouter.post("/complete", async (c) => { return c.json({ error: "not configured" }, 500); } - const isValid = await verifyCallbackSignature(payload, c.env.INTERNAL_CALLBACK_SECRET); - if (!isValid) { + const authentic = await verifyCallbackSignature( + payload as { signature: string }, + c.env.INTERNAL_CALLBACK_SECRET + ); + if (!authentic) { log.warn("http.request", { trace_id: traceId, http_method: "POST", - http_path: "/callbacks/complete", + http_path: path, http_status: 401, outcome: "rejected", reject_reason: "invalid_signature", - session_id: payload.sessionId, + session_id: (payload as { sessionId?: string }).sessionId, duration_ms: Date.now() - startTime, }); return c.json({ error: "unauthorized" }, 401); } + return null; +} + +export const callbacksRouter = new Hono<{ Bindings: Env }>(); + +/** + * Callback endpoint for session completion notifications. + */ +callbacksRouter.post("/complete", async (c) => { + const startTime = Date.now(); + // Use trace_id from control-plane if present, otherwise generate one + const traceId = c.req.header("x-trace-id") || crypto.randomUUID(); + const payload = await c.req.json(); + + const rejection = await rejectInvalidCallback(c, payload, isValidPayload, { + path: "/callbacks/complete", + traceId, + startTime, + }); + if (rejection) return rejection; + const valid = payload as CompletionCallback; + // Process in background - c.executionCtx.waitUntil(handleCompletionCallback(payload, c.env, traceId)); + c.executionCtx.waitUntil(handleCompletionCallback(valid, c.env, traceId)); log.info("http.request", { trace_id: traceId, http_method: "POST", http_path: "/callbacks/complete", http_status: 200, - session_id: payload.sessionId, - message_id: payload.messageId, + session_id: valid.sessionId, + message_id: valid.messageId, duration_ms: Date.now() - startTime, }); @@ -190,63 +314,89 @@ callbacksRouter.post("/tool_call", async (c) => { return c.json({ error: "invalid payload" }, 400); } - if (!isValidToolCallPayload(payload)) { - log.warn("http.request", { - trace_id: traceId, - http_method: "POST", - http_path: "/callbacks/tool_call", - http_status: 400, - outcome: "rejected", - reject_reason: "invalid_payload", - duration_ms: Date.now() - startTime, - }); - return c.json({ error: "invalid payload" }, 400); - } - - if (!c.env.INTERNAL_CALLBACK_SECRET) { - log.error("http.request", { - trace_id: traceId, - http_method: "POST", - http_path: "/callbacks/tool_call", - http_status: 500, - outcome: "error", - reject_reason: "secret_not_configured", - duration_ms: Date.now() - startTime, - }); - return c.json({ error: "not configured" }, 500); - } - - const isValid = await verifyCallbackSignature(payload, c.env.INTERNAL_CALLBACK_SECRET); - if (!isValid) { - log.warn("http.request", { - trace_id: traceId, - http_method: "POST", - http_path: "/callbacks/tool_call", - http_status: 401, - outcome: "rejected", - reject_reason: "invalid_signature", - session_id: payload.sessionId, - duration_ms: Date.now() - startTime, - }); - return c.json({ error: "unauthorized" }, 401); - } + const rejection = await rejectInvalidCallback(c, payload, isValidToolCallPayload, { + path: "/callbacks/tool_call", + traceId, + startTime, + }); + if (rejection) return rejection; + const valid = payload as ToolCallCallback; - c.executionCtx.waitUntil(handleToolCallCallback(payload, c.env, traceId)); + c.executionCtx.waitUntil(handleToolCallCallback(valid, c.env, traceId)); log.info("http.request", { trace_id: traceId, http_method: "POST", http_path: "/callbacks/tool_call", http_status: 200, - session_id: payload.sessionId, - tool: payload.tool, - call_id: payload.callId, + session_id: valid.sessionId, + tool: valid.tool, + call_id: valid.callId, duration_ms: Date.now() - startTime, }); return c.json({ ok: true }); }); +/** + * Callback endpoint for Slack-triggered automation completion. Posts the run + * result into the originating thread and clears the `eyes` reaction. The + * SchedulerDO owns this fan-out (it holds the thread coordinates); this route + * just renders and delivers. + */ +callbacksRouter.post("/automation-complete", async (c) => { + const startTime = Date.now(); + const traceId = c.req.header("x-trace-id") || crypto.randomUUID(); + + let payload: unknown; + try { + payload = await c.req.json(); + } catch { + return c.json({ error: "invalid payload" }, 400); + } + + const rejection = await rejectInvalidCallback(c, payload, isValidAutomationCompletePayload, { + path: "/callbacks/automation-complete", + traceId, + startTime, + }); + if (rejection) return rejection; + + c.executionCtx.waitUntil( + handleAutomationComplete(payload as AutomationCompletePayload, c.env, traceId) + ); + + return c.json({ ok: true }); +}); + +/** + * Callback endpoint for a concurrency-skip notice. Posts a best-effort + * ephemeral reply to the message author when their message was dropped because + * a run is already active for the thread. + */ +callbacksRouter.post("/automation-skip", async (c) => { + const startTime = Date.now(); + const traceId = c.req.header("x-trace-id") || crypto.randomUUID(); + + let payload: unknown; + try { + payload = await c.req.json(); + } catch { + return c.json({ error: "invalid payload" }, 400); + } + + const rejection = await rejectInvalidCallback(c, payload, isValidAutomationSkipPayload, { + path: "/callbacks/automation-skip", + traceId, + startTime, + }); + if (rejection) return rejection; + + c.executionCtx.waitUntil(handleAutomationSkip(payload as AutomationSkipPayload, c.env, traceId)); + + return c.json({ ok: true }); +}); + async function handleToolCallCallback( payload: ToolCallCallback, env: Env, @@ -375,3 +525,78 @@ async function handleCompletionCallback( // Don't throw - this is fire-and-forget } } + +/** + * Post an automation completion result into the originating Slack thread and + * clear the `eyes` reaction from the triggering message. Fire-and-forget. + */ +async function handleAutomationComplete( + payload: AutomationCompletePayload, + env: Env, + traceId?: string +): Promise { + const startTime = Date.now(); + const base = { + trace_id: traceId, + channel: payload.channel, + thread_ts: payload.threadTs, + session_id: payload.sessionId, + automation_name: payload.automationName, + }; + + try { + const blocks = buildAutomationCompletionBlocks(payload, env.WEB_APP_URL); + const fallback = `${payload.success ? "Automation completed" : "Automation failed"}: ${payload.automationName}`; + + await postMessage(env.SLACK_BOT_TOKEN, payload.channel, fallback, { + thread_ts: payload.threadTs, + blocks, + }); + + if (payload.reactionMessageTs) { + await clearThinkingReaction(env, payload.channel, payload.reactionMessageTs, traceId); + } + + log.info("callback.automation_complete", { + ...base, + outcome: "success", + agent_success: payload.success, + duration_ms: Date.now() - startTime, + }); + } catch (error) { + log.error("callback.automation_complete", { + ...base, + outcome: "error", + error: error instanceof Error ? error : new Error(String(error)), + duration_ms: Date.now() - startTime, + }); + } +} + +/** + * Post a best-effort ephemeral "a run is already active" notice to the author + * whose message was dropped by the per-thread concurrency guard. + */ +async function handleAutomationSkip( + payload: AutomationSkipPayload, + env: Env, + traceId?: string +): Promise { + const result = await postEphemeral( + env.SLACK_BOT_TOKEN, + payload.channel, + payload.user, + ":hourglass_flowing_sand: A run is already active for this thread — skipping the new trigger.", + { thread_ts: payload.threadTs } + ); + + if (!result.ok) { + log.warn("callback.automation_skip", { + trace_id: traceId, + channel: payload.channel, + user: payload.user, + outcome: "error", + slack_error: result.error, + }); + } +} diff --git a/packages/slack-bot/src/channel-trigger.test.ts b/packages/slack-bot/src/channel-trigger.test.ts new file mode 100644 index 000000000..53dd41333 --- /dev/null +++ b/packages/slack-bot/src/channel-trigger.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type * as SharedModule from "@open-inspect/shared"; +import type { Env } from "./types"; + +const { + mockVerifySlackSignature, + mockAuthTest, + mockGetChannelInfo, + mockGetPermalink, + mockAddReaction, +} = vi.hoisted(() => ({ + mockVerifySlackSignature: vi.fn(), + mockAuthTest: vi.fn(), + mockGetChannelInfo: vi.fn(), + mockGetPermalink: vi.fn(), + mockAddReaction: vi.fn(), +})); + +vi.mock("@open-inspect/shared", async () => { + const actual = await vi.importActual("@open-inspect/shared"); + return { + ...actual, // keep the real normalizeSlackEvent, internal-auth helpers, cache store + verifySlackSignature: mockVerifySlackSignature, + authTest: mockAuthTest, + getChannelInfo: mockGetChannelInfo, + getPermalink: mockGetPermalink, + addReaction: mockAddReaction, + }; +}); + +import app from "./index"; +import { clearLocalCache } from "./classifier/repos"; +import { clearBotUserIdCache } from "./bot-identity"; + +const BOT_USER_ID = "UBOT123"; + +function createMockKV() { + const store = new Map(); + return { + get: vi.fn(async (key: string, type?: string) => { + const value = store.get(key); + if (!value) return null; + return type === "json" ? JSON.parse(value) : value; + }), + put: vi.fn(async (key: string, value: string) => { + store.set(key, value); + }), + delete: vi.fn(async (key: string) => { + store.delete(key); + }), + }; +} + +/** Control-plane fetch mock: serves the watched-channel set and records forwards. */ +function makeControlPlaneFetch(watched: string[], triggered: number) { + return vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : String(input); + if (url.includes("/integration-settings/slack/watched-channels")) { + return new Response(JSON.stringify({ channels: watched }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url.includes("/internal/slack-event")) { + return new Response( + JSON.stringify({ ok: true, triggered, skipped: triggered === 0 ? 1 : 0 }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }); + }); +} + +function makeEnv( + opts: { triggersEnabled?: boolean; watched?: string[]; triggered?: number } = {} +): Env { + return { + SLACK_KV: createMockKV() as unknown as KVNamespace, + CONTROL_PLANE: { + fetch: makeControlPlaneFetch(opts.watched ?? ["C123"], opts.triggered ?? 1), + } as unknown as Fetcher, + DEPLOYMENT_NAME: "test", + CONTROL_PLANE_URL: "https://control-plane.test", + WEB_APP_URL: "https://app.test", + DEFAULT_MODEL: "anthropic/claude-haiku-4-5", + CLASSIFICATION_MODEL: "anthropic/claude-haiku-4-5", + SLACK_BOT_TOKEN: "xoxb-test", + SLACK_SIGNING_SECRET: "secret", + INTERNAL_CALLBACK_SECRET: "internal-secret", + SLACK_TRIGGERS_ENABLED: opts.triggersEnabled ? "true" : undefined, + } as unknown as Env; +} + +function makeCtx() { + return { + props: {}, + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + } as unknown as ExecutionContext & { waitUntil: ReturnType }; +} + +async function flushWaitUntil(ctx: ReturnType, callIndex = 0): Promise { + await ctx.waitUntil.mock.calls[callIndex]?.[0]; +} + +function channelMessageRequest(event: Record): Request { + return new Request("http://localhost/events", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-slack-signature": "v0=test", + "x-slack-request-timestamp": `${Math.floor(Date.now() / 1000)}`, + }, + body: JSON.stringify({ + type: "event_callback", + event_id: crypto.randomUUID(), + event_time: Math.floor(Date.now() / 1000), + team_id: "T123", + event: { + type: "message", + channel_type: "channel", + channel: "C123", + ts: "1700000000.000100", + user: "U999", + text: "the deploy job keeps failing", + ...event, + }, + }), + }); +} + +function forwardedSlackEvents(fetchMock: { mock: { calls: readonly (readonly unknown[])[] } }) { + return fetchMock.mock.calls + .filter(([input]) => String(input).includes("/internal/slack-event")) + .map(([, init]) => JSON.parse(String((init as RequestInit).body)) as Record); +} + +describe("channel-message automation triggers (POST /events)", () => { + beforeEach(() => { + vi.clearAllMocks(); + clearLocalCache(); + clearBotUserIdCache(); + mockVerifySlackSignature.mockResolvedValue(true); + mockAuthTest.mockResolvedValue({ ok: true, user_id: BOT_USER_ID }); + mockGetChannelInfo.mockResolvedValue({ ok: true, channel: { id: "C123", name: "ops" } }); + mockGetPermalink.mockResolvedValue({ + ok: true, + permalink: "https://slack.com/archives/C123/p1700000000000100", + }); + mockAddReaction.mockResolvedValue({ ok: true }); + }); + + it("forwards a normalized event for a candidate message in a watched channel", async () => { + const env = makeEnv({ triggersEnabled: true, watched: ["C123"] }); + const ctx = makeCtx(); + + const res = await app.fetch(channelMessageRequest({}), env, ctx); + expect(res.status).toBe(200); + await flushWaitUntil(ctx); + + const forwarded = forwardedSlackEvents( + env.CONTROL_PLANE.fetch as unknown as { mock: { calls: readonly (readonly unknown[])[] } } + ); + expect(forwarded).toHaveLength(1); + expect(forwarded[0]).toMatchObject({ + source: "slack", + channelId: "C123", + channelName: "ops", + actorUserId: "U999", + text: "the deploy job keeps failing", + triggerKey: "slack:msg:C123:1700000000.000100", + }); + + // A run materialized → mark the triggering message with 👀. + expect(mockAddReaction).toHaveBeenCalledWith("xoxb-test", "C123", "1700000000.000100", "eyes"); + }); + + it("does not react when the forward matches no automation (triggered: 0)", async () => { + const env = makeEnv({ triggersEnabled: true, watched: ["C123"], triggered: 0 }); + const ctx = makeCtx(); + + await app.fetch(channelMessageRequest({}), env, ctx); + await flushWaitUntil(ctx); + + expect(mockAddReaction).not.toHaveBeenCalled(); + }); + + it("does not forward when the kill switch is off (default)", async () => { + const env = makeEnv({ triggersEnabled: false, watched: ["C123"] }); + const ctx = makeCtx(); + + await app.fetch(channelMessageRequest({}), env, ctx); + await flushWaitUntil(ctx); + + const forwarded = forwardedSlackEvents( + env.CONTROL_PLANE.fetch as unknown as { mock: { calls: readonly (readonly unknown[])[] } } + ); + expect(forwarded).toHaveLength(0); + // auth.test isn't even reached when the feature is dark. + expect(mockAuthTest).not.toHaveBeenCalled(); + }); + + it("does not forward a message in an unwatched channel", async () => { + const env = makeEnv({ triggersEnabled: true, watched: ["C-other"] }); + const ctx = makeCtx(); + + await app.fetch(channelMessageRequest({}), env, ctx); + await flushWaitUntil(ctx); + + const forwarded = forwardedSlackEvents( + env.CONTROL_PLANE.fetch as unknown as { mock: { calls: readonly (readonly unknown[])[] } } + ); + expect(forwarded).toHaveLength(0); + }); + + it("suppresses a message that mentions the bot (handled by app_mention)", async () => { + const env = makeEnv({ triggersEnabled: true, watched: ["C123"] }); + const ctx = makeCtx(); + + await app.fetch(channelMessageRequest({ text: `<@${BOT_USER_ID}> please deploy` }), env, ctx); + await flushWaitUntil(ctx); + + const forwarded = forwardedSlackEvents( + env.CONTROL_PLANE.fetch as unknown as { mock: { calls: readonly (readonly unknown[])[] } } + ); + expect(forwarded).toHaveLength(0); + }); +}); diff --git a/packages/slack-bot/src/channel-trigger.ts b/packages/slack-bot/src/channel-trigger.ts new file mode 100644 index 000000000..19e5fdb92 --- /dev/null +++ b/packages/slack-bot/src/channel-trigger.ts @@ -0,0 +1,174 @@ +/** + * Channel-message automation trigger ingress. + * + * Ambient (non-mention) messages in watched Slack channels are normalized and + * forwarded to the control plane, which owns candidate selection, condition + * evaluation, rate limiting, and dedup. All ingress filtering lives here so the + * Slack event ack path stays cheap. + */ + +import { + addReaction, + getChannelInfo, + getPermalink, + normalizeSlackEvent, + type SlackAutomationEvent, + type SlackChannelMeta, +} from "@open-inspect/shared"; +import type { Env } from "./types"; +import { isChannelTriggerCandidate } from "./dm-utils"; +import { getWatchedChannels } from "./classifier/repos"; +import { getBotUserId } from "./bot-identity"; +import { getAuthHeaders } from "./internal-auth"; +import { createLogger } from "./logger"; + +const log = createLogger("channel-trigger"); + +/** + * Ingest an ambient channel message and, if it is a trigger candidate in a + * watched channel, normalize and forward it to the control plane's + * `/internal/slack-event` endpoint for automation matching. + * + * All filtering happens here so the Slack event ack path stays cheap: + * 1. Kill switch (`SLACK_TRIGGERS_ENABLED`) — dark by default. + * 2. Bot identity (fail closed — no id ⇒ skip, since mention suppression needs it). + * 3. Structural candidacy + mention suppression (`isChannelTriggerCandidate`). + * 4. Watched-channel pre-filter (cached) — avoids forwarding every channel message. + * 5. Normalize (+ best-effort channel name/permalink) and forward. + */ +export async function handleChannelTrigger( + event: { + type: string; + subtype?: string; + channel_type?: string; + text?: string; + channel?: string; + ts?: string; + user?: string; + thread_ts?: string; + bot_id?: string; + }, + env: Env, + traceId: string | undefined +): Promise { + if (env.SLACK_TRIGGERS_ENABLED !== "true") { + return; + } + + const botUserId = await getBotUserId(env, traceId); + if (!botUserId) { + log.warn("slack_trigger.skip", { trace_id: traceId, reason: "no_bot_user_id" }); + return; + } + + if (!isChannelTriggerCandidate(event, botUserId)) { + return; + } + + const channel = event.channel!; + const watched = await getWatchedChannels(env, traceId); + if (!watched.has(channel)) { + return; + } + + const channelMeta = await fetchChannelMeta(env, channel, event.ts!); + const normalized = normalizeSlackEvent( + { + channel, + ts: event.ts!, + thread_ts: event.thread_ts, + user: event.user!, + text: event.text!, + }, + botUserId, + channelMeta + ); + // Null when the message was only the bot mention (no usable text after strip). + if (!normalized) { + return; + } + + await forwardSlackEvent(env, normalized, traceId); +} + +/** + * Best-effort fetch of the channel name + message permalink used to enrich the + * agent's context block. Both are optional: on any Slack API failure the + * corresponding field is left undefined and the normalizer falls back to the + * raw channel id. + */ +async function fetchChannelMeta(env: Env, channel: string, ts: string): Promise { + const [info, link] = await Promise.all([ + getChannelInfo(env.SLACK_BOT_TOKEN, channel), + getPermalink(env.SLACK_BOT_TOKEN, channel, ts), + ]); + return { + channelName: info.ok ? info.channel.name : undefined, + permalink: link.ok ? link.permalink : undefined, + }; +} + +/** + * Forward a normalized Slack automation event to the control plane. The + * control plane owns candidate selection, condition evaluation, rate limiting, + * and dedup; the bot's job ends at delivery. + */ +async function forwardSlackEvent( + env: Env, + event: SlackAutomationEvent, + traceId: string | undefined +): Promise { + const startTime = Date.now(); + try { + const headers = await getAuthHeaders(env, traceId); + const response = await env.CONTROL_PLANE.fetch("https://internal/internal/slack-event", { + method: "POST", + headers, + body: JSON.stringify(event), + }); + + if (!response.ok) { + log.error("slack_trigger.forward", { + trace_id: traceId, + outcome: "error", + http_status: response.status, + channel_id: event.channelId, + duration_ms: Date.now() - startTime, + }); + return; + } + + const result = (await response.json()) as { triggered?: number; skipped?: number }; + log.info("slack_trigger.forward", { + trace_id: traceId, + outcome: "success", + channel_id: event.channelId, + triggered: result.triggered ?? 0, + skipped: result.skipped ?? 0, + duration_ms: Date.now() - startTime, + }); + + // React 👀 on the triggering message only when a run actually materialized, + // so unmatched channel chatter doesn't get marked. The scheduler clears it + // via /callbacks/automation-complete when the run finishes. + if ((result.triggered ?? 0) >= 1) { + const reaction = await addReaction(env.SLACK_BOT_TOKEN, event.channelId, event.ts, "eyes"); + if (!reaction.ok && reaction.error !== "already_reacted") { + log.warn("slack_trigger.react", { + trace_id: traceId, + channel_id: event.channelId, + message_ts: event.ts, + slack_error: reaction.error, + }); + } + } + } catch (e) { + log.error("slack_trigger.forward", { + trace_id: traceId, + outcome: "error", + channel_id: event.channelId, + error: e instanceof Error ? e : new Error(String(e)), + duration_ms: Date.now() - startTime, + }); + } +} diff --git a/packages/slack-bot/src/classifier/repos.test.ts b/packages/slack-bot/src/classifier/repos.test.ts index 2b242f663..6339c7e5e 100644 --- a/packages/slack-bot/src/classifier/repos.test.ts +++ b/packages/slack-bot/src/classifier/repos.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Env } from "../types"; -import { clearLocalCache, getAvailableRepos, getRoutingRules } from "./repos"; +import { clearLocalCache, getAvailableRepos, getRoutingRules, getWatchedChannels } from "./repos"; function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { @@ -201,3 +201,62 @@ describe("getAvailableRepos", () => { expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(1); }); }); + +describe("getWatchedChannels", () => { + beforeEach(() => { + clearLocalCache(); + vi.clearAllMocks(); + }); + + it("returns the watched channel set from the control plane and stores it in KV", async () => { + const env = makeEnv(jsonResponse({ channels: ["C1", "C2"] })); + + const channels = await getWatchedChannels(env, "trace"); + + expect(channels).toEqual(new Set(["C1", "C2"])); + expect(env.SLACK_KV.put).toHaveBeenCalledWith( + "slack:watched-channels", + JSON.stringify(["C1", "C2"]), + { expirationTtl: 300 } + ); + }); + + it("returns an empty set when the response has no channels", async () => { + const env = makeEnv(jsonResponse({})); + expect(await getWatchedChannels(env)).toEqual(new Set()); + }); + + it("fails closed to an empty set on a non-OK response with no cache", async () => { + const env = makeEnv(new Response("error", { status: 500 })); + expect(await getWatchedChannels(env)).toEqual(new Set()); + }); + + it("fails closed to an empty set when the fetch throws and no cache exists", async () => { + const env = makeEnv(new Error("control plane unreachable")); + expect(await getWatchedChannels(env)).toEqual(new Set()); + }); + + it("serves the KV last-known-good set on a control-plane failure", async () => { + const env = { + SLACK_KV: { + get: vi.fn().mockResolvedValue(["C7", "C8"]), + put: vi.fn().mockResolvedValue(undefined), + }, + CONTROL_PLANE: { + fetch: vi.fn().mockResolvedValue(new Response("error", { status: 503 })), + }, + } as unknown as Env; + + expect(await getWatchedChannels(env, "trace")).toEqual(new Set(["C7", "C8"])); + expect(env.SLACK_KV.get).toHaveBeenCalledWith("slack:watched-channels", "json"); + }); + + it("uses the in-memory cache after a successful fetch", async () => { + const env = makeEnv(jsonResponse({ channels: ["C1"] })); + + await getWatchedChannels(env); + await getWatchedChannels(env); + + expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/slack-bot/src/classifier/repos.ts b/packages/slack-bot/src/classifier/repos.ts index a42fd9a70..ae3c1a70d 100644 --- a/packages/slack-bot/src/classifier/repos.ts +++ b/packages/slack-bot/src/classifier/repos.ts @@ -46,17 +46,10 @@ let localCache: { timestamp: number; } | null = null; -/** - * Local in-memory cache for Slack routing rules. Same TTL as the repos cache; - * the bot tolerates rules being up to a few minutes stale. - */ -let routingRulesLocalCache: { - rules: SlackRoutingRule[]; - timestamp: number; -} | null = null; - const ROUTING_RULES_CACHE_KEY = "slack:routing-rules"; +const WATCHED_CHANNELS_CACHE_KEY = "slack:watched-channels"; + /** * Convert a control plane repo to a RepoConfig. * Normalizes identifiers to lowercase for consistent comparison. @@ -197,84 +190,160 @@ async function getFromCacheOrFallback(env: Env): Promise { return FALLBACK_REPOS; } +// ─── Cached control-plane list fetch (shared engine) ───────────────────────── + +/** A mutable in-memory cache slot for one list resource. */ +interface CacheSlot { + read(): { value: T; timestamp: number } | null; + write(value: T): void; + clear(): void; +} + +function makeCacheSlot(): CacheSlot { + let slot: { value: T; timestamp: number } | null = null; + return { + read: () => slot, + write: (value) => { + slot = { value, timestamp: Date.now() }; + }, + clear: () => { + slot = null; + }, + }; +} + /** - * Fetch workspace-wide Slack routing rules (keyword → repository) from the - * control plane's GET /integration-settings/slack endpoint. - * - * Mirrors {@link getAvailableRepos}: in-memory cache → control plane → KV cache, - * and **fails open to an empty list** so a settings-fetch problem never blocks - * classification — the bot simply behaves as if no rules were configured. + * A control-plane list resource fetched through the two-tier cache: in-memory + * (LOCAL_CACHE_TTL_MS) → control plane → KV last-known-good. On a fetch failure + * it returns whatever `fromCache` yields from KV (empty on a cold miss), so each + * resource decides via that whether the failure mode is fail-open or fail-closed. */ -export async function getRoutingRules(env: Env, traceId?: string): Promise { - if ( - routingRulesLocalCache && - Date.now() - routingRulesLocalCache.timestamp < LOCAL_CACHE_TTL_MS - ) { - return routingRulesLocalCache.rules; +interface CachedListResource { + path: string; + kvKey: string; + /** Log event name for fetch failures. */ + logEvent: string; + /** `key_prefix` used in KV error logs. */ + kvKeyPrefix: string; + slot: CacheSlot; + /** Extract the list from a fresh control-plane JSON response. */ + fromResponse: (json: unknown) => TItem[]; + /** Extract the list from a KV-cached JSON value (array or otherwise). */ + fromCache: (cached: unknown) => TItem[]; +} + +async function fetchCachedList( + env: Env, + traceId: string | undefined, + resource: CachedListResource +): Promise { + const cached = resource.slot.read(); + if (cached && Date.now() - cached.timestamp < LOCAL_CACHE_TTL_MS) { + return cached.value; } const startTime = Date.now(); try { - const response = await controlPlaneFetch(env, "/integration-settings/slack", traceId); + const response = await controlPlaneFetch(env, resource.path, traceId); if (!response.ok) { - log.warn("control_plane.fetch_routing_rules", { + log.warn(resource.logEvent, { trace_id: traceId, outcome: "error", http_status: response.status, duration_ms: Date.now() - startTime, }); - return getRoutingRulesFromCache(env); + return readListFromKv(env, resource); } - const data = (await response.json()) as { settings?: SlackGlobalConfig | null }; - const rules = normalizeRoutingRules(data.settings?.defaults?.routingRules); - - routingRulesLocalCache = { rules, timestamp: Date.now() }; + const items = resource.fromResponse(await response.json()); + resource.slot.write(items); try { - await createKvCacheStore(env.SLACK_KV).put(ROUTING_RULES_CACHE_KEY, JSON.stringify(rules), { + await createKvCacheStore(env.SLACK_KV).put(resource.kvKey, JSON.stringify(items), { expirationTtl: KV_CACHE_TTL_SECONDS, }); } catch (e) { log.warn("kv.put", { trace_id: traceId, - key_prefix: "routing_rules_cache", + key_prefix: resource.kvKeyPrefix, error: e instanceof Error ? e : new Error(String(e)), }); } - return rules; + return items; } catch (e) { - log.warn("control_plane.fetch_routing_rules", { + log.warn(resource.logEvent, { trace_id: traceId, outcome: "error", error: e instanceof Error ? e : new Error(String(e)), duration_ms: Date.now() - startTime, }); - return getRoutingRulesFromCache(env); + return readListFromKv(env, resource); } } -/** - * Read routing rules from the KV cache, returning an empty list on miss/error. - * Fail open: no rules means no deterministic routing, the safe default. - */ -async function getRoutingRulesFromCache(env: Env): Promise { +async function readListFromKv( + env: Env, + resource: CachedListResource +): Promise { try { - const cached = await createKvCacheStore(env.SLACK_KV).get(ROUTING_RULES_CACHE_KEY, "json"); - if (cached && Array.isArray(cached)) { - // Normalize on read so the KV-fallback path returns the same canonical - // shape as the fresh control-plane path (one uniform contract). - return normalizeRoutingRules(cached as SlackRoutingRule[]); - } + const cached = await createKvCacheStore(env.SLACK_KV).get(resource.kvKey, "json"); + return resource.fromCache(cached); } catch (e) { log.warn("kv.get", { - key_prefix: "routing_rules_cache", + key_prefix: resource.kvKeyPrefix, error: e instanceof Error ? e : new Error(String(e)), }); + return []; } - return []; +} + +/** + * Workspace-wide Slack routing rules (keyword → repository). **Fails open** to + * an empty list so a settings-fetch problem never blocks classification — the + * bot behaves as if no rules were configured. Rules are normalized on both the + * fresh and KV-fallback paths so callers see one canonical shape. + */ +const routingRulesResource: CachedListResource = { + path: "/integration-settings/slack", + kvKey: ROUTING_RULES_CACHE_KEY, + logEvent: "control_plane.fetch_routing_rules", + kvKeyPrefix: "routing_rules_cache", + slot: makeCacheSlot(), + fromResponse: (json) => + normalizeRoutingRules( + (json as { settings?: SlackGlobalConfig | null }).settings?.defaults?.routingRules + ), + fromCache: (cached) => + Array.isArray(cached) ? normalizeRoutingRules(cached as SlackRoutingRule[]) : [], +}; + +/** + * Channel IDs watched by enabled `slack_event` automations. **Fails closed** to + * an empty set: an unknown watch-list means the bot forwards no channel + * messages, so an outage pauses triggers rather than forwarding every message. + * The last-known-good KV copy bridges short outages. + */ +const watchedChannelsResource: CachedListResource = { + path: "/integration-settings/slack/watched-channels", + kvKey: WATCHED_CHANNELS_CACHE_KEY, + logEvent: "control_plane.fetch_watched_channels", + kvKeyPrefix: "watched_channels_cache", + slot: makeCacheSlot(), + fromResponse: (json) => { + const channels = (json as { channels?: string[] }).channels; + return Array.isArray(channels) ? channels : []; + }, + fromCache: (cached) => (Array.isArray(cached) ? (cached as string[]) : []), +}; + +export async function getRoutingRules(env: Env, traceId?: string): Promise { + return fetchCachedList(env, traceId, routingRulesResource); +} + +export async function getWatchedChannels(env: Env, traceId?: string): Promise> { + return new Set(await fetchCachedList(env, traceId, watchedChannelsResource)); } /** @@ -355,5 +424,6 @@ export async function buildRepoDescriptions(env: Env, traceId?: string): Promise */ export function clearLocalCache(): void { localCache = null; - routingRulesLocalCache = null; + routingRulesResource.slot.clear(); + watchedChannelsResource.slot.clear(); } diff --git a/packages/slack-bot/src/dm-utils.test.ts b/packages/slack-bot/src/dm-utils.test.ts index 646f29e12..dece94ff7 100644 --- a/packages/slack-bot/src/dm-utils.test.ts +++ b/packages/slack-bot/src/dm-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { stripMentions, isDmDispatchable } from "./dm-utils"; +import { stripMentions, isDmDispatchable, isChannelTriggerCandidate } from "./dm-utils"; describe("stripMentions", () => { it("removes a single mention", () => { @@ -67,3 +67,70 @@ describe("isDmDispatchable", () => { expect(isDmDispatchable({ ...baseEvent, type: "app_mention" })).toBe(false); }); }); + +describe("isChannelTriggerCandidate", () => { + const BOT = "UBOT123"; + const baseEvent = { + type: "message", + channel_type: "channel", + text: "the deploy job is failing again", + channel: "C12345", + ts: "1234567890.123456", + user: "U99999", + }; + + it("returns true for a plain public-channel message", () => { + expect(isChannelTriggerCandidate(baseEvent, BOT)).toBe(true); + }); + + it("returns true for a private-channel (group) message", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, channel_type: "group" }, BOT)).toBe(true); + }); + + it("returns false for DM and group-DM channel types", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, channel_type: "im" }, BOT)).toBe(false); + expect(isChannelTriggerCandidate({ ...baseEvent, channel_type: "mpim" }, BOT)).toBe(false); + }); + + it("returns false when a subtype is present (edits, joins, bot posts)", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, subtype: "message_changed" }, BOT)).toBe( + false + ); + expect(isChannelTriggerCandidate({ ...baseEvent, subtype: "channel_join" }, BOT)).toBe(false); + }); + + it("returns false when bot_id is set", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, bot_id: "B1" }, BOT)).toBe(false); + }); + + it("returns false when required fields are missing", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, text: undefined }, BOT)).toBe(false); + expect(isChannelTriggerCandidate({ ...baseEvent, channel: undefined }, BOT)).toBe(false); + expect(isChannelTriggerCandidate({ ...baseEvent, ts: undefined }, BOT)).toBe(false); + expect(isChannelTriggerCandidate({ ...baseEvent, user: undefined }, BOT)).toBe(false); + }); + + it("returns false for the bot's own messages", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, user: BOT }, BOT)).toBe(false); + }); + + it("suppresses messages that mention the bot (handled by app_mention)", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, text: `<@${BOT}> please deploy` }, BOT)).toBe( + false + ); + // piped rendering <@UBOT123|assistant> + expect( + isChannelTriggerCandidate({ ...baseEvent, text: `<@${BOT}|assistant> deploy` }, BOT) + ).toBe(false); + }); + + it("still triggers when a different user is mentioned", () => { + expect( + isChannelTriggerCandidate({ ...baseEvent, text: "<@U55555> can you deploy?" }, BOT) + ).toBe(true); + }); + + it("returns false for app_mention event type", () => { + expect(isChannelTriggerCandidate({ ...baseEvent, type: "app_mention" }, BOT)).toBe(false); + }); +}); diff --git a/packages/slack-bot/src/dm-utils.ts b/packages/slack-bot/src/dm-utils.ts index f6ca361bd..ee091a0ee 100644 --- a/packages/slack-bot/src/dm-utils.ts +++ b/packages/slack-bot/src/dm-utils.ts @@ -33,3 +33,51 @@ export function isDmDispatchable(event: { !!event.user ); } + +/** + * Returns true when `text` contains a mention of `userId`, matching both the + * bare `<@U…>` and piped `<@U…|display-name>` renderings. Slack user IDs are + * `[A-Z0-9]+`, so the id is safe to splice into the pattern verbatim. + */ +function mentionsUser(text: string, userId: string): boolean { + return new RegExp(`<@${userId}(?:\\|[^>]*)?>`).test(text); +} + +/** + * Returns true if a Slack channel message should be considered an automation + * trigger candidate (an ambient message that may match a watched automation). + * + * This is the structural pre-filter the bot applies before normalizing and + * forwarding to the control plane. It drops: + * - non-`message` events and any subtype (edits, joins, bot posts, …) + * - DM (`im`) and group-DM (`mpim`) channels — handled by the DM path + * - messages from the bot itself + * - messages that @mention the bot — those are explicit requests dispatched by + * the `app_mention` path; processing them here too would double-handle. + * + * `botUserId` must be the bot's own Slack user id (from `auth.test`); the caller + * resolves it and fails closed when it cannot, so mention suppression is always + * applied against a known id. + */ +export function isChannelTriggerCandidate( + event: { + type: string; + subtype?: string; + channel_type?: string; + text?: string; + channel?: string; + ts?: string; + user?: string; + bot_id?: string; + }, + botUserId: string +): boolean { + if (event.type !== "message") return false; + if (event.subtype) return false; + if (event.bot_id) return false; + if (event.channel_type !== "channel" && event.channel_type !== "group") return false; + if (!event.text || !event.channel || !event.ts || !event.user) return false; + if (event.user === botUserId) return false; + if (mentionsUser(event.text, botUserId)) return false; + return true; +} diff --git a/packages/slack-bot/src/index.ts b/packages/slack-bot/src/index.ts index f880375d9..308203592 100644 --- a/packages/slack-bot/src/index.ts +++ b/packages/slack-bot/src/index.ts @@ -26,8 +26,9 @@ import { import { resolveUserNames } from "@open-inspect/shared"; import { createClassifier } from "./classifier"; import { getAvailableRepos } from "./classifier/repos"; +import { handleChannelTrigger } from "./channel-trigger"; +import { getAuthHeaders } from "./internal-auth"; import { callbacksRouter } from "./callbacks"; -import { buildInternalAuthHeaders } from "@open-inspect/shared"; import { createLogger } from "./logger"; import { createKvCacheStore } from "@open-inspect/shared"; import { getUserRepoBranchPreference } from "./branch-preferences"; @@ -45,16 +46,6 @@ const log = createLogger("handler"); type BackgroundTaskScheduler = (promise: Promise) => void; -/** - * Build authenticated headers for control plane requests. - */ -async function getAuthHeaders(env: Env, traceId?: string): Promise> { - return { - "Content-Type": "application/json", - ...(await buildInternalAuthHeaders(env.INTERNAL_CALLBACK_SECRET, traceId)), - }; -} - /** * Create a session via the control plane. */ @@ -732,6 +723,14 @@ async function handleSlackEvent( // Handle app_mention events if (event.type === "app_mention" && event.text && event.channel && event.ts) { await handleAppMention(event as Required, env, traceId, scheduleBackground); + return; + } + + // Handle ambient channel messages as potential automation triggers. + // `handleChannelTrigger` applies the kill switch, candidacy, and watched-channel + // gates; non-candidates (DMs already handled above, mentions, bot posts) are dropped. + if (event.type === "message") { + await handleChannelTrigger(event, env, traceId); } } diff --git a/packages/slack-bot/src/internal-auth.ts b/packages/slack-bot/src/internal-auth.ts new file mode 100644 index 000000000..74d42feb0 --- /dev/null +++ b/packages/slack-bot/src/internal-auth.ts @@ -0,0 +1,14 @@ +/** + * Build authenticated headers for internal control-plane requests (HMAC auth). + * Shared by every slack-bot → control-plane POST. + */ + +import { buildInternalAuthHeaders } from "@open-inspect/shared"; +import type { Env } from "./types"; + +export async function getAuthHeaders(env: Env, traceId?: string): Promise> { + return { + "Content-Type": "application/json", + ...(await buildInternalAuthHeaders(env.INTERNAL_CALLBACK_SECRET, traceId)), + }; +} diff --git a/packages/slack-bot/src/types/index.ts b/packages/slack-bot/src/types/index.ts index 8a3e10318..1576540d2 100644 --- a/packages/slack-bot/src/types/index.ts +++ b/packages/slack-bot/src/types/index.ts @@ -19,6 +19,12 @@ export interface Env { DEFAULT_MODEL: string; CLASSIFICATION_MODEL: string; APP_NAME?: string; + /** + * Kill switch for Slack channel-message automation triggers. The bot only + * ingests/forwards channel messages when this is exactly "true". Dark by + * default — any other value (or unset) disables the feature entirely. + */ + SLACK_TRIGGERS_ENABLED?: string; // Secrets SLACK_BOT_TOKEN: string; diff --git a/packages/web/src/app/(app)/automations/[id]/edit/page.tsx b/packages/web/src/app/(app)/automations/[id]/edit/page.tsx index 066537f6c..7728231c8 100644 --- a/packages/web/src/app/(app)/automations/[id]/edit/page.tsx +++ b/packages/web/src/app/(app)/automations/[id]/edit/page.tsx @@ -113,6 +113,8 @@ export default function EditAutomationPage({ params }: { params: Promise<{ id: s triggerType: automation.triggerType, eventType: automation.eventType ?? undefined, triggerConfig: automation.triggerConfig ?? undefined, + maxRunsPerHour: automation.maxRunsPerHour ?? null, + replyInThread: automation.replyInThread ?? true, }} onSubmit={handleSubmit} submitting={submitting} diff --git a/packages/web/src/app/(app)/automations/[id]/page.tsx b/packages/web/src/app/(app)/automations/[id]/page.tsx index 6ff4f2246..2c00e2ecb 100644 --- a/packages/web/src/app/(app)/automations/[id]/page.tsx +++ b/packages/web/src/app/(app)/automations/[id]/page.tsx @@ -216,6 +216,7 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: webhook: "Inbound Webhook", github_event: "GitHub Event", linear_event: "Linear Event", + slack_event: "Slack Message", }[automation.triggerType] || automation.triggerType} {automation.eventType && ( ({automation.eventType}) diff --git a/packages/web/src/components/automations/automation-form.test.tsx b/packages/web/src/components/automations/automation-form.test.tsx index 80a0d3b32..3bca7ada5 100644 --- a/packages/web/src/components/automations/automation-form.test.tsx +++ b/packages/web/src/components/automations/automation-form.test.tsx @@ -165,6 +165,119 @@ describe("automation cron submission", () => { }); }); +describe("slack_event automation", () => { + const slackBase = { + name: "Triage Slack reports", + repoOwner: "open-inspect", + repoName: "background-agents", + baseBranch: "main", + model: "openai/gpt-5.4", + instructions: "Triage the reported issue.", + triggerType: "slack_event" as const, + }; + + const validConditions = { + conditions: [ + { type: "slack_channel" as const, operator: "any_of" as const, value: ["C1"] }, + { type: "text_match" as const, operator: "contains" as const, value: { pattern: "deploy" } }, + ], + }; + + it("shows the Slack delivery controls for slack_event", () => { + render( + + ); + expect(screen.getByText("Reply in thread")).toBeInTheDocument(); + expect(screen.getByText("Max runs per hour")).toBeInTheDocument(); + }); + + it("hides the Slack delivery controls for non-slack triggers", () => { + render( + + ); + expect(screen.queryByText("Reply in thread")).not.toBeInTheDocument(); + expect(screen.queryByText("Max runs per hour")).not.toBeInTheDocument(); + }); + + it("blocks submit until a slack_channel and text_match condition exist", () => { + const onSubmit = vi.fn(); + const { container } = render( + + ); + + expect(screen.getByRole("button", { name: "Save Changes" })).toBeDisabled(); + fireEvent.submit(container.querySelector("form")!); + expect(onSubmit).not.toHaveBeenCalled(); + expect( + screen.getByText(/require at least one Slack Channel and one Message Text/) + ).toBeInTheDocument(); + }); + + it("submits replyInThread and maxRunsPerHour for a valid slack_event", () => { + const onSubmit = vi.fn(); + const { container } = render( + + ); + + fireEvent.change(screen.getByPlaceholderText(/Default \(/), { target: { value: "5" } }); + fireEvent.click(screen.getByLabelText("Reply in thread")); // default true -> false + + fireEvent.submit(container.querySelector("form")!); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ + triggerType: "slack_event", + replyInThread: false, + maxRunsPerHour: 5, + triggerConfig: validConditions, + }); + }); + + it("defaults maxRunsPerHour to null when left blank", () => { + const onSubmit = vi.fn(); + const { container } = render( + + ); + + fireEvent.submit(container.querySelector("form")!); + + expect(onSubmit.mock.calls[0][0]).toMatchObject({ + replyInThread: true, + maxRunsPerHour: null, + }); + }); +}); + describe("instructions character counter", () => { const baseInitialValues = { name: "Daily review", diff --git a/packages/web/src/components/automations/automation-form.tsx b/packages/web/src/components/automations/automation-form.tsx index 44eadabea..060ef98bc 100644 --- a/packages/web/src/components/automations/automation-form.tsx +++ b/packages/web/src/components/automations/automation-form.tsx @@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useMemo } from "react"; import { DEFAULT_MODEL, + DEFAULT_MAX_RUNS_PER_HOUR, getReasoningConfig, isValidCron, isValidReasoningEffort, @@ -93,6 +94,8 @@ export interface AutomationFormValues { eventType?: string; triggerConfig?: { conditions: TriggerCondition[] }; sentryClientSecret?: string; + maxRunsPerHour?: number | null; + replyInThread?: boolean; } interface AutomationFormProps { @@ -132,9 +135,19 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au initialValues?.triggerConfig?.conditions ?? [] ); const [sentryClientSecret, setSentryClientSecret] = useState(""); + const [replyInThread, setReplyInThread] = useState(initialValues?.replyInThread ?? true); + const [maxRunsPerHour, setMaxRunsPerHour] = useState( + initialValues?.maxRunsPerHour != null ? String(initialValues.maxRunsPerHour) : "" + ); const isSchedule = triggerType === "schedule"; + const isSlack = triggerType === "slack_event"; const isScheduleValid = !isSchedule || isValidCron(scheduleCron); + // Mirror the server rule: a slack_event needs a slack_channel + a text_match. + const slackConditionsValid = + !isSlack || + (conditions.some((c) => c.type === "slack_channel") && + conditions.some((c) => c.type === "text_match")); // The model we display and submit. The selector only lists enabled models, so // a disabled default (blank create), a disabled saved model (edit), or a @@ -186,6 +199,7 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au if (loadingModels) return; if (!name.trim() || !selectedRepo || !instructions.trim() || !isScheduleValid) return; if (triggerType === "sentry" && mode === "create" && !sentryClientSecret.trim()) return; + if (!slackConditionsValid) return; if (showEventTypeSelector && !eventType) { setEventTypeError("Event type is required."); return; @@ -219,6 +233,11 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au if (triggerType === "sentry" && mode === "create" && sentryClientSecret.trim()) { values.sentryClientSecret = sentryClientSecret.trim(); } + if (isSlack) { + values.replyInThread = replyInThread; + const parsed = Number.parseInt(maxRunsPerHour, 10); + values.maxRunsPerHour = maxRunsPerHour.trim() && parsed > 0 ? parsed : null; + } } if (mode === "edit") { @@ -258,6 +277,7 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au webhook: "Inbound Webhook", github_event: "GitHub Event", linear_event: "Linear Event", + slack_event: "Slack Message", }[triggerType] || triggerType} (cannot be changed) @@ -511,6 +531,45 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au Optional filters on incoming events. When you add conditions, every condition must pass before a run starts. + {isSlack && !slackConditionsValid && ( +

+ Slack triggers require at least one Slack Channel and one Message Text condition. +

+ )} + + )} + + {/* Slack delivery + rate limit (slack_event only) */} + {isSlack && ( +
+ + + Post the run result back into the originating Slack thread when it completes. + +
+ + setMaxRunsPerHour(e.target.value)} + placeholder={`Default (${DEFAULT_MAX_RUNS_PER_HOUR})`} + className="w-40" + /> + + Caps how many runs this automation can start per hour. Extra matching messages are + skipped. Leave blank to use the default ({DEFAULT_MAX_RUNS_PER_HOUR}). + +
)} @@ -568,6 +627,7 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au !selectedRepo || !instructions.trim() || !isScheduleValid || + !slackConditionsValid || (showEventTypeSelector && !eventType) || (triggerType === "sentry" && mode === "create" && !sentryClientSecret.trim()) } diff --git a/packages/web/src/components/automations/condition-builder.test.tsx b/packages/web/src/components/automations/condition-builder.test.tsx new file mode 100644 index 000000000..f7e7d6c6e --- /dev/null +++ b/packages/web/src/components/automations/condition-builder.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +/// + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import type { TriggerCondition } from "@open-inspect/shared"; +import { ConditionBuilder } from "./condition-builder"; + +expect.extend(matchers); +afterEach(cleanup); + +function renderBuilder(conditions: TriggerCondition[]) { + const onChange = vi.fn(); + render(); + return onChange; +} + +describe("ConditionBuilder — slack editors", () => { + it("edits a text_match pattern and toggles case-insensitivity", () => { + const onChange = renderBuilder([ + { type: "text_match", operator: "contains", value: { pattern: "" } }, + ]); + + fireEvent.change(screen.getByPlaceholderText(/Substring to look for/), { + target: { value: "deploy" }, + }); + expect(onChange).toHaveBeenLastCalledWith([ + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ]); + + fireEvent.click(screen.getByLabelText("Case-insensitive")); + expect(onChange).toHaveBeenLastCalledWith([ + { type: "text_match", operator: "contains", value: { pattern: "", flags: "i" } }, + ]); + }); + + it("renders a slack_channel tag input and adds a channel ID", () => { + const onChange = renderBuilder([{ type: "slack_channel", operator: "any_of", value: [] }]); + + const input = screen.getByPlaceholderText(/Add channel ID/); + fireEvent.change(input, { target: { value: "C0123ABCD" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onChange).toHaveBeenLastCalledWith([ + { type: "slack_channel", operator: "any_of", value: ["C0123ABCD"] }, + ]); + }); + + it("renders the slack_actor include/exclude control and user input", () => { + renderBuilder([{ type: "slack_actor", operator: "include", value: [] }]); + expect(screen.getByText("Slack User")).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/Add Slack user ID/)).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/automations/condition-builder.tsx b/packages/web/src/components/automations/condition-builder.tsx index dadd68827..16b4757af 100644 --- a/packages/web/src/components/automations/condition-builder.tsx +++ b/packages/web/src/components/automations/condition-builder.tsx @@ -30,8 +30,13 @@ const CONDITION_LABELS: Record = { actor: "Actor", check_conclusion: "Check Conclusion", linear_status: "Linear Status", + text_match: "Message Text", + slack_channel: "Slack Channel", + slack_actor: "Slack User", }; +const TEXT_MATCH_MODES = ["contains", "exact", "regex"] as const; + const SENTRY_LEVELS = ["warning", "error", "fatal"]; export const CHECK_CONCLUSION_OPTIONS = [ "success", @@ -85,6 +90,15 @@ export function ConditionBuilder({ conditions, onChange, triggerSource }: Condit value: CHECK_CONCLUSION_OPTIONS[0], }; break; + case "text_match": + newCondition = { type: "text_match", operator: "contains", value: { pattern: "" } }; + break; + case "slack_channel": + newCondition = { type: "slack_channel", operator: "any_of", value: [] }; + break; + case "slack_actor": + newCondition = { type: "slack_actor", operator: "include", value: [] }; + break; default: return; } @@ -242,6 +256,89 @@ function ConditionEditor({ ); + case "text_match": { + const flags = condition.value.flags ?? ""; + const caseInsensitive = flags.includes("i"); + return ( +
+ + + onChange({ ...condition, value: { ...condition.value, pattern: e.target.value } }) + } + placeholder={ + condition.operator === "regex" + ? "Regular expression, e.g. \\b(deploy|release)\\b" + : condition.operator === "exact" + ? "Exact message text to match" + : "Substring to look for, e.g. deploy" + } + className="text-xs" + /> + +
+ ); + } + case "slack_channel": + return ( + onChange({ ...condition, value })} + placeholder="Add channel ID (e.g. C0123ABCD)..." + /> + ); + case "slack_actor": + return ( +
+ + onChange({ ...condition, value })} + placeholder="Add Slack user ID (e.g. U0123ABCD)..." + /> +
+ ); default: return
Configuration not available
; } diff --git a/packages/web/src/components/automations/trigger-type-selector.tsx b/packages/web/src/components/automations/trigger-type-selector.tsx index b9d32d69d..3194b6390 100644 --- a/packages/web/src/components/automations/trigger-type-selector.tsx +++ b/packages/web/src/components/automations/trigger-type-selector.tsx @@ -30,6 +30,11 @@ const TRIGGER_OPTIONS: TriggerOption[] = [ label: "GitHub Event", description: "Trigger on PR, issue, or CI events", }, + { + type: "slack_event", + label: "Slack Message", + description: "Trigger on messages in watched channels", + }, { type: "linear_event", label: "Linear Event", diff --git a/packages/web/src/lib/automation-templates.test.ts b/packages/web/src/lib/automation-templates.test.ts index de4a9f6a9..549b41cc2 100644 --- a/packages/web/src/lib/automation-templates.test.ts +++ b/packages/web/src/lib/automation-templates.test.ts @@ -108,9 +108,13 @@ describe("automation templates catalog", () => { } if (t.primaryOutput === "slack") { - it("names a Slack channel in its instructions and carries a setup note", () => { - expect(t.prefill.instructions).toMatch(/#[a-z0-9-]+/i); + it("carries a setup note, and names a Slack channel unless it replies in-thread", () => { expect(t.setupNote).toBeTruthy(); + // slack_event automations deliver the result to the originating thread, so + // they don't name a destination channel. slack-notify templates must. + if (triggerType !== "slack_event") { + expect(t.prefill.instructions).toMatch(/#[a-z0-9-]+/i); + } }); } diff --git a/packages/web/src/lib/automation-templates.ts b/packages/web/src/lib/automation-templates.ts index 9283596a4..ab645c834 100644 --- a/packages/web/src/lib/automation-templates.ts +++ b/packages/web/src/lib/automation-templates.ts @@ -231,6 +231,43 @@ export const automationTemplates: AutomationTemplate[] = [ "open a pull request.", }, }, + { + id: "auto-triage-slack-reports", + title: "Auto-triage Slack reports", + description: + "When a message in a watched Slack channel reports a bug or incident, investigate it and reply in the thread.", + categories: ["incidents"], + primaryOutput: "slack", + setupNote: + "Requires Slack triggers enabled, the bot invited to the channel, and a channel ID filled into the Slack Channel condition.", + prefill: { + name: "Auto-triage Slack reports", + triggerType: "slack_event", + eventType: "message.posted", + // The text_match keeps the trigger from firing on every channel message. + // The Slack Channel condition is intentionally omitted — add your own + // channel ID(s) on the form (a slack_event needs at least one). + triggerConfig: { + conditions: [ + { + type: "text_match", + operator: "regex", + value: { pattern: "\\b(bug|broken|error|failing|down|incident)\\b", flags: "i" }, + }, + ], + }, + replyInThread: true, + instructions: + "A message was posted in a watched Slack channel (the message is shown above). Treat it as a " + + "bug or incident report and triage it against this repository.\n\n" + + "Investigate the most likely cause from the codebase: trace the described symptom to the " + + "responsible code, check recent related changes, and determine whether it is a real defect, a " + + "configuration problem, or expected behavior. When you have a concise, well-supported " + + "assessment — what is wrong, the relevant file references, and a recommended next step — that " + + "becomes the run result posted back into the thread. Do not open a pull request unless the fix " + + "is small and clearly correct.", + }, + }, { id: "dependency-digest", title: "Weekly dependency digest", diff --git a/terraform/d1/migrations/0025_slack_triggers.sql b/terraform/d1/migrations/0025_slack_triggers.sql new file mode 100644 index 000000000..206719f19 --- /dev/null +++ b/terraform/d1/migrations/0025_slack_triggers.sql @@ -0,0 +1,33 @@ +-- Slack message triggers (#716): a new `slack_event` automation source. +-- Additive only. No DO storage-schema change, so no two-phase DO-binding deploy. + +-- Channels watched by each slack_event automation. This join table is the +-- candidate / watched-channel key for channel-keyed selection, replacing a +-- full-scan + JSON parse of every automation's trigger_config. +CREATE TABLE IF NOT EXISTS automation_slack_channels ( + automation_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + PRIMARY KEY (automation_id, channel_id) +); + +CREATE INDEX IF NOT EXISTS idx_slack_channels_channel + ON automation_slack_channels (channel_id); + +-- Per-automation blast-radius controls. +-- max_runs_per_hour: fixed 1-hour windowed cap (NULL = use the app default). +-- reply_in_thread: post the run result back into the originating thread. +ALTER TABLE automations ADD COLUMN max_runs_per_hour INTEGER; +ALTER TABLE automations ADD COLUMN reply_in_thread INTEGER NOT NULL DEFAULT 1; + +-- Slack thread coordinates + posting actor on the run. Nullable; only +-- slack-origin runs populate them. slack_thread_ts is the reply target; +-- slack_message_ts is the triggering message (used to clear the eyes reaction +-- on completion); actor_user_id is the posting Slack user, for attribution. +ALTER TABLE automation_runs ADD COLUMN slack_channel TEXT; +ALTER TABLE automation_runs ADD COLUMN slack_thread_ts TEXT; +ALTER TABLE automation_runs ADD COLUMN slack_message_ts TEXT; +ALTER TABLE automation_runs ADD COLUMN actor_user_id TEXT; + +-- The rate-limit window query (automation_id = ? AND created_at >= ?) is already +-- served by idx_runs_automation_created (automation_id, created_at DESC) from +-- migration 0013, so no new automation_runs index is added here. diff --git a/terraform/environments/production/variables.tf b/terraform/environments/production/variables.tf index 727a36146..c150c6ec9 100644 --- a/terraform/environments/production/variables.tf +++ b/terraform/environments/production/variables.tf @@ -199,6 +199,12 @@ variable "enable_slack_bot" { } } +variable "slack_triggers_enabled" { + description = "Kill switch for Slack channel-message automation triggers. When false (default), the slack-bot ignores channel messages and forwards nothing — the feature ships dark. Flip to true only after completing the rollout verification." + type = bool + default = false +} + variable "slack_bot_token" { description = "Slack Bot OAuth token (xoxb-...)" type = string diff --git a/terraform/environments/production/workers-slack.tf b/terraform/environments/production/workers-slack.tf index 0433bbed1..9605e2d8a 100644 --- a/terraform/environments/production/workers-slack.tf +++ b/terraform/environments/production/workers-slack.tf @@ -50,6 +50,9 @@ module "slack_bot_worker" { { name = "APP_NAME", value = var.app_name }, { name = "DEFAULT_MODEL", value = "claude-haiku-4-5" }, { name = "CLASSIFICATION_MODEL", value = "claude-haiku-4-5" }, + # Kill switch for Slack channel-message triggers; the bot only ingests/ + # forwards channel messages when this is exactly "true" (dark by default). + { name = "SLACK_TRIGGERS_ENABLED", value = var.slack_triggers_enabled ? "true" : "false" }, ] secrets = [ From 6f28d5288a83b28fd4217d37b34ceb0177cc06e2 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Tue, 23 Jun 2026 19:57:08 -0700 Subject: [PATCH 02/19] fix(slack): respect reply_in_thread and harden automation inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-review feedback on #819 (PR review by open-inspect + CodeRabbit bots). Correctness: - Honor the stored reply_in_thread setting on Slack completion. The flag was persisted and shown in the UI but ignored: replyInThread=false still posted. Thread the flag from the scheduler through to the bot, which now skips the thread post but still clears the eyes reaction. Input validation / boundaries (untrusted JSON): - Validate maxRunsPerHour as null-or-positive-integer on create and update before it reaches the scheduler's rate-limit comparison. - Guard validateSlackRequiredConditions against a non-array `conditions` so the update path returns 400 instead of throwing a 500. - Reject non-object JSON bodies in the automation-event webhook factory (a null body returned 500 instead of 400). Robustness: - handleAutomationComplete now checks postMessage ok:false before logging success; handleAutomationSkip wraps its Slack call in try/catch (it runs in waitUntil); the scheduler's skip callback logs non-2xx responses. - Narrow isDuplicateKeyError to the trigger_key dedup index so unrelated UNIQUE violations surface as real errors instead of being swallowed. Atomicity: - Persist an automation and its watched-channel index in a single db.batch via createWithSlackChannels / updateWithSlackChannels, so trigger_config and the scheduler's channel index can never drift apart on a partial write. Web: - Preserve other regex flags when toggling case-insensitive; constrain the max-runs-per-hour input to integers. Deferred (follow-up): refactoring the scheduler's per-source Slack branches behind a source-policy boundary — premature with a single specialized source. --- .../src/db/automation-store.test.ts | 25 ++++++ .../control-plane/src/db/automation-store.ts | 84 ++++++++++++++++--- .../control-plane/src/routes/automations.ts | 68 ++++++++++++--- .../src/scheduler/durable-object.ts | 12 ++- .../src/scheduler/slack-completion.test.ts | 20 +++++ .../src/scheduler/slack-completion.ts | 7 ++ .../src/webhooks/automation-event.ts | 3 + .../test/integration/automation-store.test.ts | 20 +++++ .../automations-slack-route.test.ts | 76 +++++++++++++++++ .../test/integration/webhooks-slack.test.ts | 13 +++ packages/slack-bot/src/callbacks.test.ts | 50 +++++++++++ packages/slack-bot/src/callbacks.ts | 69 +++++++++++---- .../automations/automation-form.tsx | 1 + .../automations/condition-builder.tsx | 12 ++- 14 files changed, 412 insertions(+), 48 deletions(-) diff --git a/packages/control-plane/src/db/automation-store.test.ts b/packages/control-plane/src/db/automation-store.test.ts index 2924b3df3..f75486080 100644 --- a/packages/control-plane/src/db/automation-store.test.ts +++ b/packages/control-plane/src/db/automation-store.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, vi } from "vitest"; import { AutomationStore, + isDuplicateKeyError, toAutomation, toAutomationRun, type AutomationRow, @@ -443,3 +444,27 @@ describe("AutomationStore", () => { }); }); }); + +describe("isDuplicateKeyError", () => { + it("matches the trigger-key dedup index violation", () => { + expect( + isDuplicateKeyError( + new Error( + "D1_ERROR: UNIQUE constraint failed: automation_runs.automation_id, automation_runs.trigger_key" + ) + ) + ).toBe(true); + }); + + it("ignores unrelated UNIQUE violations so they surface as real errors", () => { + expect(isDuplicateKeyError(new Error("UNIQUE constraint failed: automation_runs.id"))).toBe( + false + ); + expect(isDuplicateKeyError(new Error("some other write failure"))).toBe(false); + }); + + it("handles non-Error throwables", () => { + expect(isDuplicateKeyError("UNIQUE constraint failed: x.trigger_key")).toBe(true); + expect(isDuplicateKeyError(null)).toBe(false); + }); +}); diff --git a/packages/control-plane/src/db/automation-store.ts b/packages/control-plane/src/db/automation-store.ts index 0cc152880..2e4938095 100644 --- a/packages/control-plane/src/db/automation-store.ts +++ b/packages/control-plane/src/db/automation-store.ts @@ -126,8 +126,8 @@ export class AutomationStore { // --- Automation CRUD --- - async create(row: AutomationRow): Promise { - await this.db + private bindAutomationInsert(row: AutomationRow): D1PreparedStatement { + return this.db .prepare( `INSERT INTO automations (id, name, repo_owner, repo_name, base_branch, repo_id, instructions, @@ -162,8 +162,23 @@ export class AutomationStore { row.trigger_auth_data, row.max_runs_per_hour ?? null, row.reply_in_thread ?? 1 - ) - .run(); + ); + } + + async create(row: AutomationRow): Promise { + await this.bindAutomationInsert(row).run(); + } + + /** + * Atomically insert an automation and its watched slack channels in one + * `db.batch`, so a slack_event automation can never be committed without its + * channel-index rows (which would leave it invisible to the scheduler). + */ + async createWithSlackChannels(row: AutomationRow, channelIds: string[]): Promise { + await this.db.batch([ + this.bindAutomationInsert(row), + ...this.bindSlackChannelStatements(row.id, channelIds), + ]); } async getById(id: string): Promise { @@ -199,7 +214,14 @@ export class AutomationStore { return { automations, total: automations.length }; } - async update(id: string, fields: Partial): Promise { + /** + * Build the dynamic UPDATE statement for the allowed automation fields, or + * null when `fields` carries nothing to write. + */ + private bindAutomationUpdate( + id: string, + fields: Partial + ): D1PreparedStatement | null { const setClauses: string[] = []; const params: unknown[] = []; @@ -228,19 +250,40 @@ export class AutomationStore { } } - if (setClauses.length === 0) return this.getById(id); + if (setClauses.length === 0) return null; setClauses.push("updated_at = ?"); params.push(Date.now()); params.push(id); - await this.db + return this.db .prepare( `UPDATE automations SET ${setClauses.join(", ")} WHERE id = ? AND deleted_at IS NULL` ) - .bind(...params) - .run(); + .bind(...params); + } + async update(id: string, fields: Partial): Promise { + const statement = this.bindAutomationUpdate(id, fields); + if (statement) await statement.run(); + return this.getById(id); + } + + /** + * Atomically update an automation and re-sync its watched slack channels in + * one `db.batch`, so the canonical `trigger_config` and the channel index can + * never drift apart on a partial failure. + */ + async updateWithSlackChannels( + id: string, + fields: Partial, + channelIds: string[] + ): Promise { + const updateStatement = this.bindAutomationUpdate(id, fields); + const channelStatements = this.bindSlackChannelStatements(id, channelIds); + await this.db.batch( + updateStatement ? [updateStatement, ...channelStatements] : channelStatements + ); return this.getById(id); } @@ -484,7 +527,11 @@ export class AutomationStore { } /** Replace an automation's watched-channel set atomically. */ - async setSlackChannels(automationId: string, channelIds: string[]): Promise { + /** Statements that replace an automation's watched-channel set (DELETE + re-INSERT). */ + private bindSlackChannelStatements( + automationId: string, + channelIds: string[] + ): D1PreparedStatement[] { const statements: D1PreparedStatement[] = [ this.db .prepare("DELETE FROM automation_slack_channels WHERE automation_id = ?") @@ -499,7 +546,11 @@ export class AutomationStore { .bind(automationId, channelId) ); } - await this.db.batch(statements); + return statements; + } + + async setSlackChannels(automationId: string, channelIds: string[]): Promise { + await this.db.batch(this.bindSlackChannelStatements(automationId, channelIds)); } /** @@ -640,8 +691,15 @@ export class AutomationStore { } } -/** True when a D1 write failed because it violated a UNIQUE constraint. */ +/** + * True when a D1 write failed because it violated the trigger-key dedup index + * (`idx_runs_trigger_key` on `automation_id, trigger_key` — the only UNIQUE on + * `automation_runs`). Scoping to the `trigger_key` column keeps unrelated UNIQUE + * violations surfacing as real errors instead of being silently swallowed as + * duplicates, while matching the column name (not D1's full + * `table.col, table.col` string) stays robust to exact message formatting. + */ export function isDuplicateKeyError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); - return message.includes("UNIQUE constraint failed"); + return message.includes("UNIQUE constraint failed") && message.includes("trigger_key"); } diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts index a9ede8c84..b5683ad7d 100644 --- a/packages/control-plane/src/routes/automations.ts +++ b/packages/control-plane/src/routes/automations.ts @@ -17,7 +17,12 @@ import { type AutomationTriggerType, type TriggerConfig, } from "@open-inspect/shared"; -import { AutomationStore, toAutomation, toAutomationRun } from "../db/automation-store"; +import { + AutomationStore, + toAutomation, + toAutomationRun, + type AutomationRow, +} from "../db/automation-store"; import { UserStore } from "../db/user-store"; import { resolveProviderIdentity, type SessionIdentityFields } from "../session/identity"; import { generateId } from "../auth/crypto"; @@ -85,7 +90,14 @@ function extractSlackChannels(triggerConfig: TriggerConfig | null | undefined): function validateSlackRequiredConditions( triggerConfig: TriggerConfig | null | undefined ): string | null { - const conditions = triggerConfig?.conditions ?? []; + // Guard the shape here too: this runs before the generic array-shape check in + // the update path, so a non-array `conditions` would otherwise throw on + // `.some()` and surface as a 500 instead of a 400. + const rawConditions = triggerConfig?.conditions; + if (rawConditions !== undefined && !Array.isArray(rawConditions)) { + return "triggerConfig.conditions must be an array"; + } + const conditions = rawConditions ?? []; if (!conditions.some((c) => c.type === "slack_channel")) { return "slack_event triggers require a slack_channel condition"; } @@ -95,6 +107,21 @@ function validateSlackRequiredConditions( return null; } +/** + * `maxRunsPerHour` is the Slack-only per-automation hourly cap. It feeds the + * scheduler's `recentRuns >= maxRuns` comparison directly, so reject anything + * that is not `null`/absent (use the app default) or a positive integer — + * untrusted JSON could otherwise persist `0`, a negative, a fractional, or a + * non-number value and corrupt the rate-limit branch. + */ +function validateMaxRunsPerHour(value: unknown): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { + return "maxRunsPerHour must be a positive integer or null"; + } + return null; +} + // ─── Handlers ──────────────────────────────────────────────────────────────── async function handleListAutomations( @@ -210,6 +237,9 @@ async function handleCreateAutomation( if (slackError) return error(slackError, 400); } + const maxRunsError = validateMaxRunsPerHour(body.maxRunsPerHour); + if (maxRunsError) return error(maxRunsError, 400); + // Validate model const model = getValidModelOrDefault(body.model); const reasoningEffort = resolveReasoningEffort(model, body.reasoningEffort); @@ -275,7 +305,7 @@ async function handleCreateAutomation( } const store = new AutomationStore(env.DB); - await store.create({ + const row: AutomationRow = { id, name: body.name.trim(), repo_owner: repoOwner, @@ -302,12 +332,16 @@ async function handleCreateAutomation( // Slack-only knobs; harmless defaults for other sources (they never read these). max_runs_per_hour: body.maxRunsPerHour ?? null, reply_in_thread: body.replyInThread === false ? 0 : 1, - }); + }; - // Mirror the slack_channel condition into the join table that drives - // channel-keyed candidate selection in the scheduler. + // Persist the automation and (for slack_event) its watched-channel index in a + // single atomic write, so the canonical trigger_config and the channel index + // that drives scheduler candidate selection can never drift apart on a partial + // failure. if (triggerType === "slack_event") { - await store.setSlackChannels(id, extractSlackChannels(body.triggerConfig)); + await store.createWithSlackChannels(row, extractSlackChannels(body.triggerConfig)); + } else { + await store.create(row); } const automation = toAutomation((await store.getById(id))!); @@ -485,6 +519,8 @@ async function handleUpdateAutomation( } // Slack-only knobs + const maxRunsError = validateMaxRunsPerHour(body.maxRunsPerHour); + if (maxRunsError) return error(maxRunsError, 400); if (body.maxRunsPerHour !== undefined) { updateFields.max_runs_per_hour = body.maxRunsPerHour; } @@ -505,14 +541,20 @@ async function handleUpdateAutomation( updateFields.next_run_at = nextCronOccurrence(cron, tz).getTime(); } - const updated = await store.update(id, updateFields); + // Apply the update and, when a slack_event automation's conditions changed, + // re-sync its watched-channel index in the same atomic write so trigger_config + // and the channel index can never drift apart on a partial failure. + const resyncSlackChannels = + existing.trigger_type === "slack_event" && body.triggerConfig !== undefined; + const updated = resyncSlackChannels + ? await store.updateWithSlackChannels( + id, + updateFields, + extractSlackChannels(body.triggerConfig) + ) + : await store.update(id, updateFields); if (!updated) return error("Automation not found", 404); - // Re-sync the watched-channel join table when slack conditions change. - if (existing.trigger_type === "slack_event" && body.triggerConfig !== undefined) { - await store.setSlackChannels(id, extractSlackChannels(body.triggerConfig)); - } - logger.info("automation.updated", { event: "automation.updated", automation_id: id, diff --git a/packages/control-plane/src/scheduler/durable-object.ts b/packages/control-plane/src/scheduler/durable-object.ts index 8d3d677ba..cde2e7fa7 100644 --- a/packages/control-plane/src/scheduler/durable-object.ts +++ b/packages/control-plane/src/scheduler/durable-object.ts @@ -628,6 +628,9 @@ export class SchedulerDO extends DurableObject { automationName: automation?.name ?? "Automation", success, error, + // Honor the stored reply_in_thread setting (NOT NULL DEFAULT 1). The bot + // skips the thread post when this is false but still clears the reaction. + replyInThread: automation?.reply_in_thread !== 0, }); if (!body) return; @@ -676,11 +679,18 @@ export class SchedulerDO extends DurableObject { try { const signature = await computeHmacHex(JSON.stringify(body), secret); - await binding.fetch("https://internal/callbacks/automation-skip", { + const response = await binding.fetch("https://internal/callbacks/automation-skip", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...body, signature }), }); + if (!response.ok) { + this.log.warn("Slack skip callback failed", { + event: "scheduler.slack_skip_failed", + channel: event.channelId, + http_status: response.status, + }); + } } catch (e) { this.log.warn("Slack skip callback errored", { event: "scheduler.slack_skip_failed", diff --git a/packages/control-plane/src/scheduler/slack-completion.test.ts b/packages/control-plane/src/scheduler/slack-completion.test.ts index e2ea1b8e7..9264b2b45 100644 --- a/packages/control-plane/src/scheduler/slack-completion.test.ts +++ b/packages/control-plane/src/scheduler/slack-completion.test.ts @@ -22,6 +22,7 @@ describe("buildSlackCompletionNotification", () => { run: coords({ slack_channel: null }), automationName: "Triage", success: true, + replyInThread: true, }) ).toBeNull(); }); @@ -32,6 +33,7 @@ describe("buildSlackCompletionNotification", () => { run: coords({ slack_thread_ts: null, slack_message_ts: null }), automationName: "Triage", success: true, + replyInThread: true, }) ).toBeNull(); }); @@ -41,6 +43,7 @@ describe("buildSlackCompletionNotification", () => { run: coords({ slack_thread_ts: "1699999999.000001" }), automationName: "Triage", success: true, + replyInThread: true, }); expect(n).toMatchObject({ channel: "C1", @@ -49,6 +52,7 @@ describe("buildSlackCompletionNotification", () => { sessionId: "sess-1", success: true, automationName: "Triage", + replyInThread: true, }); expect(n?.summary).toBeUndefined(); }); @@ -58,6 +62,7 @@ describe("buildSlackCompletionNotification", () => { run: coords({ slack_thread_ts: null }), automationName: "Triage", success: true, + replyInThread: true, }); expect(n?.threadTs).toBe("1700000000.000100"); }); @@ -69,10 +74,25 @@ describe("buildSlackCompletionNotification", () => { automationName: "Triage", success: false, error: longError, + replyInThread: true, }); expect(n?.success).toBe(false); expect(n?.summary?.length).toBe(1500); }); + + it("threads replyInThread=false through so the bot suppresses the thread post", () => { + const n = buildSlackCompletionNotification({ + run: coords(), + automationName: "Triage", + success: true, + replyInThread: false, + }); + // Still produced (the bot needs it to clear the eyes reaction), but flagged + // so the bot posts no message. + expect(n).not.toBeNull(); + expect(n?.replyInThread).toBe(false); + expect(n?.reactionMessageTs).toBe("1700000000.000100"); + }); }); describe("buildSlackSkipNotification", () => { diff --git a/packages/control-plane/src/scheduler/slack-completion.ts b/packages/control-plane/src/scheduler/slack-completion.ts index e8c64d7b5..2059e0d44 100644 --- a/packages/control-plane/src/scheduler/slack-completion.ts +++ b/packages/control-plane/src/scheduler/slack-completion.ts @@ -30,6 +30,11 @@ export interface SlackCompletionNotification { /** Short failure summary; omitted on success (the bot renders a generic ✅). */ summary?: string; automationName: string; + /** + * The automation's `reply_in_thread` setting. When false, the bot still clears + * the `eyes` reaction but posts no completion message into the thread. + */ + replyInThread: boolean; } export function buildSlackCompletionNotification(params: { @@ -37,6 +42,7 @@ export function buildSlackCompletionNotification(params: { automationName: string; success: boolean; error?: string; + replyInThread: boolean; }): SlackCompletionNotification | null { const { run } = params; if (!run.slack_channel) return null; @@ -51,6 +57,7 @@ export function buildSlackCompletionNotification(params: { success: params.success, summary: params.error ? params.error.slice(0, SUMMARY_MAX_LENGTH) : undefined, automationName: params.automationName, + replyInThread: params.replyInThread, }; } diff --git a/packages/control-plane/src/webhooks/automation-event.ts b/packages/control-plane/src/webhooks/automation-event.ts index bb15ee010..e06a6300e 100644 --- a/packages/control-plane/src/webhooks/automation-event.ts +++ b/packages/control-plane/src/webhooks/automation-event.ts @@ -49,6 +49,9 @@ export function createAutomationEventRoute(opts: { // 2. Validate envelope — source, then source-specific fields, then the // common dispatch keys every event must carry. + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return error("Invalid event: body must be a JSON object", 400); + } const event = body as Record; if (event.source !== opts.source) { return error(`Invalid event: source must be '${opts.source}'`, 400); diff --git a/packages/control-plane/test/integration/automation-store.test.ts b/packages/control-plane/test/integration/automation-store.test.ts index c556e6e9b..5617274b5 100644 --- a/packages/control-plane/test/integration/automation-store.test.ts +++ b/packages/control-plane/test/integration/automation-store.test.ts @@ -779,6 +779,26 @@ describe("AutomationStore (D1 integration)", () => { expect((await store.getWatchedSlackChannels()).sort()).toEqual(["C2", "C3"]); }); + it("createWithSlackChannels writes the automation and its channels atomically", async () => { + const store = new AutomationStore(env.DB); + await store.createWithSlackChannels(makeSlackAutomation({ id: "auto-sc" }), ["C1", "C2"]); + + expect(await store.getById("auto-sc")).not.toBeNull(); + expect((await store.getWatchedSlackChannels()).sort()).toEqual(["C1", "C2"]); + }); + + it("updateWithSlackChannels updates the row and re-syncs channels atomically", async () => { + const store = new AutomationStore(env.DB); + await store.createWithSlackChannels(makeSlackAutomation({ id: "auto-su" }), ["C1"]); + + const updated = await store.updateWithSlackChannels("auto-su", { instructions: "updated" }, [ + "C2", + "C3", + ]); + expect(updated?.instructions).toBe("updated"); + expect((await store.getWatchedSlackChannels()).sort()).toEqual(["C2", "C3"]); + }); + it("getSlackAutomationsForChannel returns only enabled, non-deleted slack automations", async () => { const store = new AutomationStore(env.DB); await store.create(makeSlackAutomation({ id: "auto-s2" })); diff --git a/packages/control-plane/test/integration/automations-slack-route.test.ts b/packages/control-plane/test/integration/automations-slack-route.test.ts index 60c56943d..84f975022 100644 --- a/packages/control-plane/test/integration/automations-slack-route.test.ts +++ b/packages/control-plane/test/integration/automations-slack-route.test.ts @@ -125,6 +125,82 @@ describe("POST /automations — slack_event validation (integration)", () => { ); expect(await res.text()).not.toContain("triggerType must be one of"); }); + + const validSlackConditions = [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ]; + + it.each([0, -3, 1.5, "5", true])( + "rejects a non-positive/non-integer maxRunsPerHour=%p on create (400)", + async (bad) => { + const res = await postAutomation( + createBody({ triggerConfig: { conditions: validSlackConditions }, maxRunsPerHour: bad }) + ); + expect(res.status).toBe(400); + expect(await res.text()).toContain("maxRunsPerHour"); + } + ); + + it("accepts a null maxRunsPerHour (use the app default)", async () => { + const res = await postAutomation( + createBody({ triggerConfig: { conditions: validSlackConditions }, maxRunsPerHour: null }) + ); + // Passes validation; only later fails at repo resolution in the test env. + expect(res.status).not.toBe(400); + }); +}); + +describe("PUT /automations/:id — slack_event validation (integration)", () => { + beforeEach(cleanD1Tables); + + async function putAutomation(id: string, body: Record): Promise { + return SELF.fetch(`https://test.local/automations/${id}`, { + method: "PUT", + headers: await authHeaders(), + body: JSON.stringify(body), + }); + } + + it("rejects a non-array conditions on update with 400, not 500", async () => { + const store = new AutomationStore(env.DB); + const auto = makeSlackAutomation(); + await store.create(auto); + + const res = await putAutomation(auto.id, { triggerConfig: { conditions: "not-an-array" } }); + expect(res.status).toBe(400); + expect(await res.text()).toContain("must be an array"); + }); + + it("rejects a non-positive maxRunsPerHour on update (400)", async () => { + const store = new AutomationStore(env.DB); + const auto = makeSlackAutomation(); + await store.create(auto); + + const res = await putAutomation(auto.id, { maxRunsPerHour: 0 }); + expect(res.status).toBe(400); + expect(await res.text()).toContain("maxRunsPerHour"); + }); + + it("atomically updates conditions and re-syncs the watched-channel index", async () => { + const store = new AutomationStore(env.DB); + const auto = makeSlackAutomation(); + await store.create(auto); + await store.setSlackChannels(auto.id, ["C1"]); + + const res = await putAutomation(auto.id, { + triggerConfig: { + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C2", "C3"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }, + }); + expect(res.status).toBe(200); + + const watched = await store.getWatchedSlackChannels(); + expect([...watched].sort()).toEqual(["C2", "C3"]); + }); }); describe("GET /integration-settings/slack/watched-channels (integration)", () => { diff --git a/packages/control-plane/test/integration/webhooks-slack.test.ts b/packages/control-plane/test/integration/webhooks-slack.test.ts index 861f2f6f1..2fee94181 100644 --- a/packages/control-plane/test/integration/webhooks-slack.test.ts +++ b/packages/control-plane/test/integration/webhooks-slack.test.ts @@ -109,6 +109,19 @@ describe("POST /internal/slack-event (integration)", () => { expect(res.status).toBe(400); }); + it.each(["null", "[]", "42"])( + "returns 400 when the JSON body is not an object (%s)", + async (raw) => { + const res = await SELF.fetch("https://test.local/internal/slack-event", { + method: "POST", + headers: await authHeaders(), + body: raw, + }); + expect(res.status).toBe(400); + expect(await res.text()).toContain("must be a JSON object"); + } + ); + it("returns 400 when source is not 'slack'", async () => { const res = await postEvent(makeSlackEventBody({ source: "github" })); expect(res.status).toBe(400); diff --git a/packages/slack-bot/src/callbacks.test.ts b/packages/slack-bot/src/callbacks.test.ts index 640639412..0e020d729 100644 --- a/packages/slack-bot/src/callbacks.test.ts +++ b/packages/slack-bot/src/callbacks.test.ts @@ -342,6 +342,46 @@ describe("POST /callbacks/automation-complete", () => { const post = slackCall(fetchMock, "chat.postMessage"); expect(JSON.stringify(post!.body.blocks)).toContain("boom: the build broke"); }); + + it("suppresses the thread post but still clears the reaction when replyInThread is false", async () => { + const fetchMock = okFetchMock(); + const payload = await signPayload(completeData({ replyInThread: false })); + await postCallback("/callbacks/automation-complete", payload).then(({ ctx }) => + flushWaitUntil(ctx) + ); + + expect(slackCall(fetchMock, "chat.postMessage")).toBeUndefined(); + const reaction = slackCall(fetchMock, "reactions.remove"); + expect(reaction).toBeDefined(); + expect(reaction!.body).toMatchObject({ channel: "C123", timestamp: "111.222", name: "eyes" }); + }); + + it("does not clear the reaction when Slack rejects the post (ok:false)", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ ok: false, error: "channel_not_found" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + const payload = await signPayload(completeData()); + const { response, ctx } = await postCallback("/callbacks/automation-complete", payload); + + expect(response.status).toBe(200); + await flushWaitUntil(ctx); + + expect(slackCall(fetchMock, "chat.postMessage")).toBeDefined(); + expect(slackCall(fetchMock, "reactions.remove")).toBeUndefined(); + }); + + it("does not crash when the Slack post throws", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")); + const payload = await signPayload(completeData()); + const { response, ctx } = await postCallback("/callbacks/automation-complete", payload); + + expect(response.status).toBe(200); + // The fire-and-forget handler must swallow the throw, not reject in waitUntil. + await expect(flushWaitUntil(ctx)).resolves.toBeUndefined(); + }); }); describe("POST /callbacks/automation-skip", () => { @@ -378,4 +418,14 @@ describe("POST /callbacks/automation-skip", () => { expect(ephemeral).toBeDefined(); expect(ephemeral!.body).toMatchObject({ channel: "C123", user: "U9", thread_ts: "111.222" }); }); + + it("does not crash when the ephemeral post throws", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")); + const payload = await signPayload(skipData()); + const { response, ctx } = await postCallback("/callbacks/automation-skip", payload); + + expect(response.status).toBe(200); + // The fire-and-forget handler must swallow the throw, not reject in waitUntil. + await expect(flushWaitUntil(ctx)).resolves.toBeUndefined(); + }); }); diff --git a/packages/slack-bot/src/callbacks.ts b/packages/slack-bot/src/callbacks.ts index 6c16024c1..be1f9de62 100644 --- a/packages/slack-bot/src/callbacks.ts +++ b/packages/slack-bot/src/callbacks.ts @@ -115,6 +115,12 @@ interface AutomationCompletePayload { success: boolean; summary?: string; automationName: string; + /** + * The automation's reply_in_thread setting. When false, clear the eyes + * reaction but post no thread reply. Optional/defaulting-to-true so a payload + * from an older scheduler still posts (preserving prior behavior). + */ + replyInThread?: boolean; signature: string; } @@ -129,7 +135,8 @@ function isValidAutomationCompletePayload(payload: unknown): payload is Automati typeof p.signature === "string" && (p.sessionId === null || typeof p.sessionId === "string") && (p.summary === undefined || typeof p.summary === "string") && - (p.reactionMessageTs === undefined || typeof p.reactionMessageTs === "string") + (p.reactionMessageTs === undefined || typeof p.reactionMessageTs === "string") && + (p.replyInThread === undefined || typeof p.replyInThread === "boolean") ); } @@ -545,13 +552,28 @@ async function handleAutomationComplete( }; try { - const blocks = buildAutomationCompletionBlocks(payload, env.WEB_APP_URL); - const fallback = `${payload.success ? "Automation completed" : "Automation failed"}: ${payload.automationName}`; + // Honor reply_in_thread: when false, skip the thread post but still clear + // the eyes reaction below so the triggering message isn't left marked 👀. + if (payload.replyInThread !== false) { + const blocks = buildAutomationCompletionBlocks(payload, env.WEB_APP_URL); + const fallback = `${payload.success ? "Automation completed" : "Automation failed"}: ${payload.automationName}`; + + const postResult = await postMessage(env.SLACK_BOT_TOKEN, payload.channel, fallback, { + thread_ts: payload.threadTs, + blocks, + }); - await postMessage(env.SLACK_BOT_TOKEN, payload.channel, fallback, { - thread_ts: payload.threadTs, - blocks, - }); + // postMessage can return ok:false without throwing; don't log success then. + if (!postResult.ok) { + log.warn("callback.automation_complete", { + ...base, + outcome: "error", + slack_error: postResult.error, + duration_ms: Date.now() - startTime, + }); + return; + } + } if (payload.reactionMessageTs) { await clearThinkingReaction(env, payload.channel, payload.reactionMessageTs, traceId); @@ -561,6 +583,7 @@ async function handleAutomationComplete( ...base, outcome: "success", agent_success: payload.success, + replied: payload.replyInThread !== false, duration_ms: Date.now() - startTime, }); } catch (error) { @@ -582,21 +605,33 @@ async function handleAutomationSkip( env: Env, traceId?: string ): Promise { - const result = await postEphemeral( - env.SLACK_BOT_TOKEN, - payload.channel, - payload.user, - ":hourglass_flowing_sand: A run is already active for this thread — skipping the new trigger.", - { thread_ts: payload.threadTs } - ); - - if (!result.ok) { + // Runs in waitUntil — postEphemeral can throw (network/runtime), so catch here + // or the background task rejects without route-level logging. + try { + const result = await postEphemeral( + env.SLACK_BOT_TOKEN, + payload.channel, + payload.user, + ":hourglass_flowing_sand: A run is already active for this thread — skipping the new trigger.", + { thread_ts: payload.threadTs } + ); + + if (!result.ok) { + log.warn("callback.automation_skip", { + trace_id: traceId, + channel: payload.channel, + user: payload.user, + outcome: "error", + slack_error: result.error, + }); + } + } catch (error) { log.warn("callback.automation_skip", { trace_id: traceId, channel: payload.channel, user: payload.user, outcome: "error", - slack_error: result.error, + error: error instanceof Error ? error : new Error(String(error)), }); } } diff --git a/packages/web/src/components/automations/automation-form.tsx b/packages/web/src/components/automations/automation-form.tsx index 060ef98bc..887a66b5e 100644 --- a/packages/web/src/components/automations/automation-form.tsx +++ b/packages/web/src/components/automations/automation-form.tsx @@ -560,6 +560,7 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au setMaxRunsPerHour(e.target.value)} placeholder={`Default (${DEFAULT_MAX_RUNS_PER_HOUR})`} diff --git a/packages/web/src/components/automations/condition-builder.tsx b/packages/web/src/components/automations/condition-builder.tsx index 16b4757af..35ac78f1e 100644 --- a/packages/web/src/components/automations/condition-builder.tsx +++ b/packages/web/src/components/automations/condition-builder.tsx @@ -297,12 +297,16 @@ function ConditionEditor({ + onChange={(e) => { + // Toggle only the `i` flag, preserving any other valid flag + // (e.g. `m`) instead of overwriting the whole string. + const withoutI = (condition.value.flags ?? "").replace(/i/g, ""); + const nextFlags = e.target.checked ? `${withoutI}i` : withoutI; onChange({ ...condition, - value: { ...condition.value, flags: e.target.checked ? "i" : "" }, - }) - } + value: { ...condition.value, flags: nextFlags || undefined }, + }); + }} /> Case-insensitive From 050afc2ca5a7d42ce17dc8162e3f009864769d63 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 24 Jun 2026 00:06:53 -0700 Subject: [PATCH 03/19] refactor(slack): store reply_in_thread in trigger_config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reply_in_thread was a dedicated NOT NULL column on the shared automations row, but it is slack_event-only and never SQL-queried — a Single-Table- Inheritance nullable-column smell that also wrote a meaningless value onto every cron/github automation. Move it into trigger_config JSON (keyed by trigger_type, like conditions) and drop the column from migration 0025, which is still unmerged so there is no data migration. - shared: add optional TriggerConfig.replyInThread - store: drop the column from AutomationRow/INSERT/UPDATE; parse once in toAutomation; add getReplyInThread() as the single default-true source - routes: fold the top-level API flag into the stored config (slack only); the update path preserves it across a conditions-only edit - scheduler: read reply-in-thread from the parsed trigger_config - keep max_runs_per_hour a column, recommented as a generic rate limit - drop the now-unneeded #716 issue citations from inline comments The camelCase API (Automation.replyInThread) is unchanged, so web and bot consumers are unaffected. Behavior-preserving: the prior column default is now a default-in-code (?? true). --- .../src/db/automation-store.test.ts | 21 +++++++- .../control-plane/src/db/automation-store.ts | 39 ++++++++++----- .../control-plane/src/routes/automations.ts | 50 +++++++++++++------ .../src/scheduler/durable-object.ts | 15 ++++-- .../src/scheduler/slack-completion.ts | 5 +- .../test/integration/automation-store.test.ts | 8 ++- .../automations-slack-route.test.ts | 47 ++++++++++++++++- packages/shared/src/triggers/conditions.ts | 6 +++ .../d1/migrations/0025_slack_triggers.sql | 9 ++-- 9 files changed, 154 insertions(+), 46 deletions(-) diff --git a/packages/control-plane/src/db/automation-store.test.ts b/packages/control-plane/src/db/automation-store.test.ts index f75486080..2c2163006 100644 --- a/packages/control-plane/src/db/automation-store.test.ts +++ b/packages/control-plane/src/db/automation-store.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, vi } from "vitest"; import { AutomationStore, isDuplicateKeyError, + getReplyInThread, toAutomation, toAutomationRun, type AutomationRow, @@ -150,13 +151,29 @@ describe("toAutomation", () => { expect(automation.replyInThread).toBe(true); }); - it("maps explicit slack knobs (cap + reply_in_thread=0 → false)", () => { - const automation = toAutomation({ ...sampleRow, max_runs_per_hour: 5, reply_in_thread: 0 }); + it("maps an explicit cap and reads reply-in-thread from trigger_config", () => { + const automation = toAutomation({ + ...sampleRow, + max_runs_per_hour: 5, + trigger_config: JSON.stringify({ conditions: [], replyInThread: false }), + }); expect(automation.maxRunsPerHour).toBe(5); expect(automation.replyInThread).toBe(false); }); }); +describe("getReplyInThread", () => { + it("defaults to true for a null config or absent flag", () => { + expect(getReplyInThread(null)).toBe(true); + expect(getReplyInThread({ conditions: [] })).toBe(true); + }); + + it("respects an explicit flag", () => { + expect(getReplyInThread({ conditions: [], replyInThread: false })).toBe(false); + expect(getReplyInThread({ conditions: [], replyInThread: true })).toBe(true); + }); +}); + describe("toAutomationRun", () => { it("converts enriched row to camelCase AutomationRun", () => { const enriched: EnrichedRunRow = { diff --git a/packages/control-plane/src/db/automation-store.ts b/packages/control-plane/src/db/automation-store.ts index 2e4938095..8af2e069d 100644 --- a/packages/control-plane/src/db/automation-store.ts +++ b/packages/control-plane/src/db/automation-store.ts @@ -5,7 +5,12 @@ * snake_case rows in the database, camelCase types at the API boundary. */ -import type { Automation, AutomationRun, AutomationRunStatus } from "@open-inspect/shared"; +import type { + Automation, + AutomationRun, + AutomationRunStatus, + TriggerConfig, +} from "@open-inspect/shared"; // ─── Internal row types ────────────────────────────────────────────────────── @@ -33,10 +38,8 @@ export interface AutomationRow { event_type: string | null; trigger_config: string | null; // JSON-serialized TriggerConfig trigger_auth_data: string | null; - /** Slack triggers (#716): per-automation hourly run cap (null = app default). */ + /** Per-automation hourly run cap (null = app default). Enforced for slack_event today. */ max_runs_per_hour?: number | null; - /** Slack triggers (#716): post the run result back into the thread (1/0). */ - reply_in_thread?: number; } export interface AutomationRunRow { @@ -52,7 +55,7 @@ export interface AutomationRunRow { created_at: number; trigger_key: string | null; concurrency_key: string | null; - // Slack triggers (#716): thread coordinates + posting actor (slack-origin runs only). + // Slack triggers: thread coordinates + posting actor (slack-origin runs only). slack_channel?: string | null; slack_thread_ts?: string | null; slack_message_ts?: string | null; @@ -72,7 +75,19 @@ export type SlackRunColumns = Pick< // ─── Mappers ───────────────────────────────────────────────────────────────── +/** + * The effective reply-in-thread setting from a parsed trigger_config. Stored in + * trigger_config (slack_event only); defaults to true — matching the prior + * `reply_in_thread NOT NULL DEFAULT 1` column and the camelCase API default. + */ +export function getReplyInThread(config: TriggerConfig | null): boolean { + return config?.replyInThread ?? true; +} + export function toAutomation(row: AutomationRow): Automation { + const triggerConfig: TriggerConfig | null = row.trigger_config + ? JSON.parse(row.trigger_config) + : null; return { id: row.id, name: row.name, @@ -94,9 +109,9 @@ export function toAutomation(row: AutomationRow): Automation { updatedAt: row.updated_at, deletedAt: row.deleted_at, eventType: row.event_type ?? null, - triggerConfig: row.trigger_config ? JSON.parse(row.trigger_config) : null, + triggerConfig, maxRunsPerHour: row.max_runs_per_hour ?? null, - replyInThread: row.reply_in_thread == null ? true : row.reply_in_thread === 1, + replyInThread: getReplyInThread(triggerConfig), }; } @@ -133,8 +148,8 @@ export class AutomationStore { (id, name, repo_owner, repo_name, base_branch, repo_id, instructions, trigger_type, schedule_cron, schedule_tz, model, reasoning_effort, enabled, next_run_at, consecutive_failures, created_by, user_id, created_at, updated_at, deleted_at, - event_type, trigger_config, trigger_auth_data, max_runs_per_hour, reply_in_thread) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + event_type, trigger_config, trigger_auth_data, max_runs_per_hour) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( row.id, @@ -160,8 +175,7 @@ export class AutomationStore { row.event_type, row.trigger_config, row.trigger_auth_data, - row.max_runs_per_hour ?? null, - row.reply_in_thread ?? 1 + row.max_runs_per_hour ?? null ); } @@ -240,7 +254,6 @@ export class AutomationStore { "trigger_config", "trigger_auth_data", "max_runs_per_hour", - "reply_in_thread", ]; for (const field of allowedFields) { @@ -498,7 +511,7 @@ export class AutomationStore { return result.results || []; } - // --- Slack trigger queries (#716) --- + // --- Slack trigger queries --- /** Enabled, non-deleted slack_event automations watching a channel (indexed by channel_id). */ async getSlackAutomationsForChannel(channelId: string): Promise { diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts index b5683ad7d..d2966bd9d 100644 --- a/packages/control-plane/src/routes/automations.ts +++ b/packages/control-plane/src/routes/automations.ts @@ -305,6 +305,12 @@ async function handleCreateAutomation( } const store = new AutomationStore(env.DB); + // reply_in_thread lives inside trigger_config (slack_event only), not a column; + // fold the top-level API flag in here, defaulting true (the prior column default). + const storedTriggerConfig: TriggerConfig | undefined = + triggerType === "slack_event" && body.triggerConfig + ? { ...body.triggerConfig, replyInThread: body.replyInThread !== false } + : body.triggerConfig; const row: AutomationRow = { id, name: body.name.trim(), @@ -327,11 +333,10 @@ async function handleCreateAutomation( updated_at: now, deleted_at: null, event_type: body.eventType ?? null, - trigger_config: body.triggerConfig ? JSON.stringify(body.triggerConfig) : null, + trigger_config: storedTriggerConfig ? JSON.stringify(storedTriggerConfig) : null, trigger_auth_data: triggerAuthData, - // Slack-only knobs; harmless defaults for other sources (they never read these). + // Generic per-automation rate cap (consumed by slack today; harmless for others). max_runs_per_hour: body.maxRunsPerHour ?? null, - reply_in_thread: body.replyInThread === false ? 0 : 1, }; // Persist the automation and (for slack_event) its watched-channel index in a @@ -486,18 +491,16 @@ async function handleUpdateAutomation( updateFields.event_type = body.eventType; } - // Update trigger config (conditions) — only for non-schedule types + // Validate trigger config (conditions) — only for non-schedule types if (body.triggerConfig !== undefined) { if (existing.trigger_type === "schedule") { return error("Cannot set triggerConfig on schedule automations", 400); } - if (existing.trigger_type === "slack_event") { - const slackError = validateSlackRequiredConditions(body.triggerConfig); - if (slackError) return error(slackError, 400); - } - if (body.triggerConfig === null) { - updateFields.trigger_config = null; - } else { + if (body.triggerConfig !== null) { + if (existing.trigger_type === "slack_event") { + const slackError = validateSlackRequiredConditions(body.triggerConfig); + if (slackError) return error(slackError, 400); + } if (body.triggerConfig.conditions) { if (!Array.isArray(body.triggerConfig.conditions)) { return error("triggerConfig.conditions must be an array", 400); @@ -514,18 +517,35 @@ async function handleUpdateAutomation( } } } - updateFields.trigger_config = JSON.stringify(body.triggerConfig); } } - // Slack-only knobs const maxRunsError = validateMaxRunsPerHour(body.maxRunsPerHour); if (maxRunsError) return error(maxRunsError, 400); if (body.maxRunsPerHour !== undefined) { updateFields.max_runs_per_hour = body.maxRunsPerHour; } - if (body.replyInThread !== undefined) { - updateFields.reply_in_thread = body.replyInThread ? 1 : 0; + + // Persist trigger_config, folding the slack-only replyInThread flag into it + // (reply_in_thread is no longer a column). A null clears it; otherwise for + // slack_event we merge replyInThread — the explicit flag, else the previously + // stored value, else the true default — so a conditions-only edit preserves it. + const isSlackEvent = existing.trigger_type === "slack_event"; + const previousConfig: TriggerConfig | null = existing.trigger_config + ? JSON.parse(existing.trigger_config) + : null; + if (body.triggerConfig === null) { + updateFields.trigger_config = null; + } else if ( + body.triggerConfig !== undefined || + (isSlackEvent && body.replyInThread !== undefined) + ) { + const base = body.triggerConfig ?? previousConfig ?? { conditions: [] }; + updateFields.trigger_config = JSON.stringify( + isSlackEvent + ? { ...base, replyInThread: body.replyInThread ?? previousConfig?.replyInThread ?? true } + : base + ); } // Recompute next_run_at if schedule changed (only for schedule types) diff --git a/packages/control-plane/src/scheduler/durable-object.ts b/packages/control-plane/src/scheduler/durable-object.ts index cde2e7fa7..9b72923ae 100644 --- a/packages/control-plane/src/scheduler/durable-object.ts +++ b/packages/control-plane/src/scheduler/durable-object.ts @@ -24,6 +24,7 @@ import { AutomationStore, toAutomationRun, isDuplicateKeyError, + getReplyInThread, type AutomationRow, type AutomationRunRow, type SlackRunColumns, @@ -623,14 +624,22 @@ export class SchedulerDO extends DurableObject { if (!binding || !secret) return; const automation = await store.getById(run.automation_id); + let triggerConfig: TriggerConfig | null = null; + if (automation?.trigger_config) { + try { + triggerConfig = JSON.parse(automation.trigger_config) as TriggerConfig; + } catch { + triggerConfig = null; + } + } const body = buildSlackCompletionNotification({ run, automationName: automation?.name ?? "Automation", success, error, - // Honor the stored reply_in_thread setting (NOT NULL DEFAULT 1). The bot - // skips the thread post when this is false but still clears the reaction. - replyInThread: automation?.reply_in_thread !== 0, + // Honor the stored reply-in-thread setting (in trigger_config; defaults + // true). The bot skips the thread post when false but still clears the 👀. + replyInThread: getReplyInThread(triggerConfig), }); if (!body) return; diff --git a/packages/control-plane/src/scheduler/slack-completion.ts b/packages/control-plane/src/scheduler/slack-completion.ts index 2059e0d44..43bb2cb53 100644 --- a/packages/control-plane/src/scheduler/slack-completion.ts +++ b/packages/control-plane/src/scheduler/slack-completion.ts @@ -31,8 +31,9 @@ export interface SlackCompletionNotification { summary?: string; automationName: string; /** - * The automation's `reply_in_thread` setting. When false, the bot still clears - * the `eyes` reaction but posts no completion message into the thread. + * The automation's reply-in-thread setting (stored in trigger_config). When + * false, the bot still clears the `eyes` reaction but posts no completion + * message into the thread. */ replyInThread: boolean; } diff --git a/packages/control-plane/test/integration/automation-store.test.ts b/packages/control-plane/test/integration/automation-store.test.ts index 5617274b5..879c5a04c 100644 --- a/packages/control-plane/test/integration/automation-store.test.ts +++ b/packages/control-plane/test/integration/automation-store.test.ts @@ -758,7 +758,7 @@ describe("AutomationStore (D1 integration)", () => { }); }); - // ─── Slack triggers (#716) ────────────────────────────────────────────────── + // ─── Slack triggers ────────────────────────────────────────────────── describe("slack triggers", () => { const makeSlackAutomation = (overrides?: Partial) => @@ -934,18 +934,16 @@ describe("AutomationStore (D1 integration)", () => { expect(run!.actor_user_id).toBe("U1"); }); - it("persists max_runs_per_hour and reply_in_thread on the automation", async () => { + it("persists max_runs_per_hour on the automation", async () => { const store = new AutomationStore(env.DB); await store.create(makeSlackAutomation({ id: "auto-s11", max_runs_per_hour: 5 })); const row = await store.getById("auto-s11"); expect(row!.max_runs_per_hour).toBe(5); - expect(row!.reply_in_thread).toBe(1); // DB default - await store.update("auto-s11", { max_runs_per_hour: 20, reply_in_thread: 0 }); + await store.update("auto-s11", { max_runs_per_hour: 20 }); const updated = await store.getById("auto-s11"); expect(updated!.max_runs_per_hour).toBe(20); - expect(updated!.reply_in_thread).toBe(0); }); }); }); diff --git a/packages/control-plane/test/integration/automations-slack-route.test.ts b/packages/control-plane/test/integration/automations-slack-route.test.ts index 84f975022..b4045bbf0 100644 --- a/packages/control-plane/test/integration/automations-slack-route.test.ts +++ b/packages/control-plane/test/integration/automations-slack-route.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; import { SELF, env } from "cloudflare:test"; -import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; +import { AutomationStore, toAutomation, type AutomationRow } from "../../src/db/automation-store"; import { generateInternalToken } from "../../src/auth/internal"; import { cleanD1Tables } from "./cleanup"; +import type { TriggerConfig } from "@open-inspect/shared"; async function authHeaders(): Promise> { const token = await generateInternalToken(env.INTERNAL_CALLBACK_SECRET!); @@ -201,6 +202,50 @@ describe("PUT /automations/:id — slack_event validation (integration)", () => const watched = await store.getWatchedSlackChannels(); expect([...watched].sort()).toEqual(["C2", "C3"]); }); + + it("folds replyInThread into trigger_config (no reply_in_thread column)", async () => { + const store = new AutomationStore(env.DB); + const auto = makeSlackAutomation({ + trigger_config: JSON.stringify({ + conditions: [{ type: "slack_channel", operator: "any_of", value: ["C1"] }], + }), + }); + await store.create(auto); + + const res = await putAutomation(auto.id, { replyInThread: false }); + expect(res.status).toBe(200); + + const row = await store.getById(auto.id); + const config = JSON.parse(row!.trigger_config!) as TriggerConfig; + expect(config.replyInThread).toBe(false); + // Conditions survive the merge, and the camelCase API mapper surfaces the flag. + expect(config.conditions.some((c) => c.type === "slack_channel")).toBe(true); + expect(toAutomation(row!).replyInThread).toBe(false); + }); + + it("preserves a stored replyInThread across a conditions-only edit", async () => { + const store = new AutomationStore(env.DB); + const auto = makeSlackAutomation({ + trigger_config: JSON.stringify({ + conditions: [{ type: "slack_channel", operator: "any_of", value: ["C1"] }], + replyInThread: false, + }), + }); + await store.create(auto); + + const res = await putAutomation(auto.id, { + triggerConfig: { + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C9"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }, + }); + expect(res.status).toBe(200); + + const config = JSON.parse((await store.getById(auto.id))!.trigger_config!) as TriggerConfig; + expect(config.replyInThread).toBe(false); // preserved, not reset to the default + }); }); describe("GET /integration-settings/slack/watched-channels (integration)", () => { diff --git a/packages/shared/src/triggers/conditions.ts b/packages/shared/src/triggers/conditions.ts index 9c7067e1a..c597e0595 100644 --- a/packages/shared/src/triggers/conditions.ts +++ b/packages/shared/src/triggers/conditions.ts @@ -105,4 +105,10 @@ export function validateConditions( export interface TriggerConfig { conditions: TriggerCondition[]; + /** + * slack_event only: post the run result back into the originating thread. + * Like `conditions`, this blob is source-specific and interpreted via the + * automation's `trigger_type`; absent for other sources. Defaults to true. + */ + replyInThread?: boolean; } diff --git a/terraform/d1/migrations/0025_slack_triggers.sql b/terraform/d1/migrations/0025_slack_triggers.sql index 206719f19..bd8aa14c7 100644 --- a/terraform/d1/migrations/0025_slack_triggers.sql +++ b/terraform/d1/migrations/0025_slack_triggers.sql @@ -1,4 +1,4 @@ --- Slack message triggers (#716): a new `slack_event` automation source. +-- Slack message triggers: a new `slack_event` automation source. -- Additive only. No DO storage-schema change, so no two-phase DO-binding deploy. -- Channels watched by each slack_event automation. This join table is the @@ -13,11 +13,10 @@ CREATE TABLE IF NOT EXISTS automation_slack_channels ( CREATE INDEX IF NOT EXISTS idx_slack_channels_channel ON automation_slack_channels (channel_id); --- Per-automation blast-radius controls. --- max_runs_per_hour: fixed 1-hour windowed cap (NULL = use the app default). --- reply_in_thread: post the run result back into the originating thread. +-- Per-automation rate limit (generic; consumed by slack_event today). A fixed +-- 1-hour windowed cap; NULL means use the app default. reply_in_thread is NOT a +-- column — it lives in the automation's trigger_config JSON, keyed by trigger_type. ALTER TABLE automations ADD COLUMN max_runs_per_hour INTEGER; -ALTER TABLE automations ADD COLUMN reply_in_thread INTEGER NOT NULL DEFAULT 1; -- Slack thread coordinates + posting actor on the run. Nullable; only -- slack-origin runs populate them. slack_thread_ts is the reply target; From 7b59e8f30dc95a950b097d7ee46cb6e3f078e721 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 24 Jun 2026 12:14:16 -0700 Subject: [PATCH 04/19] refactor(slack): extract SlackChannelStore from AutomationStore Move all slack_event watched-channel persistence out of the generic AutomationStore into a dedicated SlackChannelStore, so trigger-source- specific code no longer leaks into the canonical automation store (slack was the only source with bespoke store methods). The automation+channels write stays atomic: the create/update routes compose one db.batch from the two single-table stores' prepared-statement builders (AutomationStore.bindAutomationInsert/Update + SlackChannelStore.bindChannelStatements), replacing the former createWithSlackChannels/updateWithSlackChannels helpers. The automation_slack_channels table and migration 0025 are unchanged. --- .../control-plane/src/db/automation-store.ts | 99 ++----------------- .../src/db/slack-channel-store.ts | 75 ++++++++++++++ .../control-plane/src/routes/automations.ts | 38 ++++--- .../src/scheduler/durable-object.ts | 5 +- .../test/integration/automation-store.test.ts | 64 ------------ .../automations-slack-route.test.ts | 14 ++- .../scheduler-slack-events.test.ts | 3 +- .../integration/slack-channel-store.test.ts | 93 +++++++++++++++++ .../test/integration/webhooks-slack.test.ts | 3 +- 9 files changed, 219 insertions(+), 175 deletions(-) create mode 100644 packages/control-plane/src/db/slack-channel-store.ts create mode 100644 packages/control-plane/test/integration/slack-channel-store.test.ts diff --git a/packages/control-plane/src/db/automation-store.ts b/packages/control-plane/src/db/automation-store.ts index 8af2e069d..c962d6081 100644 --- a/packages/control-plane/src/db/automation-store.ts +++ b/packages/control-plane/src/db/automation-store.ts @@ -141,7 +141,11 @@ export class AutomationStore { // --- Automation CRUD --- - private bindAutomationInsert(row: AutomationRow): D1PreparedStatement { + /** + * Prepared INSERT for an automation row. Public so a route can compose it with + * `SlackChannelStore.bindChannelStatements` into one atomic `db.batch`. + */ + bindAutomationInsert(row: AutomationRow): D1PreparedStatement { return this.db .prepare( `INSERT INTO automations @@ -183,18 +187,6 @@ export class AutomationStore { await this.bindAutomationInsert(row).run(); } - /** - * Atomically insert an automation and its watched slack channels in one - * `db.batch`, so a slack_event automation can never be committed without its - * channel-index rows (which would leave it invisible to the scheduler). - */ - async createWithSlackChannels(row: AutomationRow, channelIds: string[]): Promise { - await this.db.batch([ - this.bindAutomationInsert(row), - ...this.bindSlackChannelStatements(row.id, channelIds), - ]); - } - async getById(id: string): Promise { return this.db .prepare("SELECT * FROM automations WHERE id = ? AND deleted_at IS NULL") @@ -230,12 +222,10 @@ export class AutomationStore { /** * Build the dynamic UPDATE statement for the allowed automation fields, or - * null when `fields` carries nothing to write. + * null when `fields` carries nothing to write. Public so a route can compose it + * with `SlackChannelStore.bindChannelStatements` into one atomic `db.batch`. */ - private bindAutomationUpdate( - id: string, - fields: Partial - ): D1PreparedStatement | null { + bindAutomationUpdate(id: string, fields: Partial): D1PreparedStatement | null { const setClauses: string[] = []; const params: unknown[] = []; @@ -282,24 +272,6 @@ export class AutomationStore { return this.getById(id); } - /** - * Atomically update an automation and re-sync its watched slack channels in - * one `db.batch`, so the canonical `trigger_config` and the channel index can - * never drift apart on a partial failure. - */ - async updateWithSlackChannels( - id: string, - fields: Partial, - channelIds: string[] - ): Promise { - const updateStatement = this.bindAutomationUpdate(id, fields); - const channelStatements = this.bindSlackChannelStatements(id, channelIds); - await this.db.batch( - updateStatement ? [updateStatement, ...channelStatements] : channelStatements - ); - return this.getById(id); - } - async softDelete(id: string): Promise { const now = Date.now(); const result = await this.db @@ -511,61 +483,6 @@ export class AutomationStore { return result.results || []; } - // --- Slack trigger queries --- - - /** Enabled, non-deleted slack_event automations watching a channel (indexed by channel_id). */ - async getSlackAutomationsForChannel(channelId: string): Promise { - const result = await this.db - .prepare( - `SELECT a.* FROM automations a - JOIN automation_slack_channels c ON c.automation_id = a.id - WHERE c.channel_id = ? AND a.enabled = 1 AND a.deleted_at IS NULL - AND a.trigger_type = 'slack_event'` - ) - .bind(channelId) - .all(); - return result.results || []; - } - - /** Distinct channel IDs watched by any enabled slack_event automation. */ - async getWatchedSlackChannels(): Promise { - const result = await this.db - .prepare( - `SELECT DISTINCT c.channel_id FROM automation_slack_channels c - JOIN automations a ON a.id = c.automation_id - WHERE a.enabled = 1 AND a.deleted_at IS NULL AND a.trigger_type = 'slack_event'` - ) - .all<{ channel_id: string }>(); - return (result.results || []).map((r) => r.channel_id); - } - - /** Replace an automation's watched-channel set atomically. */ - /** Statements that replace an automation's watched-channel set (DELETE + re-INSERT). */ - private bindSlackChannelStatements( - automationId: string, - channelIds: string[] - ): D1PreparedStatement[] { - const statements: D1PreparedStatement[] = [ - this.db - .prepare("DELETE FROM automation_slack_channels WHERE automation_id = ?") - .bind(automationId), - ]; - for (const channelId of channelIds) { - statements.push( - this.db - .prepare( - "INSERT OR IGNORE INTO automation_slack_channels (automation_id, channel_id) VALUES (?, ?)" - ) - .bind(automationId, channelId) - ); - } - return statements; - } - - async setSlackChannels(automationId: string, channelIds: string[]): Promise { - await this.db.batch(this.bindSlackChannelStatements(automationId, channelIds)); - } - /** * Count runs for an automation since `sinceEpochMs`, for the rate-limit window. * Excludes `skipped` rows — only runs that materialized a session count (a diff --git a/packages/control-plane/src/db/slack-channel-store.ts b/packages/control-plane/src/db/slack-channel-store.ts new file mode 100644 index 000000000..26fdff98e --- /dev/null +++ b/packages/control-plane/src/db/slack-channel-store.ts @@ -0,0 +1,75 @@ +/** + * SlackChannelStore — D1 persistence for the slack_event watched-channel index. + * + * `automation_slack_channels` maps each slack_event automation to the Slack + * channels it watches. It backs scheduler candidate selection (which automations + * fire for an incoming channel message) and the watched-channels endpoint the + * slack-bot polls to pre-filter messages before forwarding them. + * + * Kept out of AutomationStore so trigger-source-specific persistence doesn't leak + * into the generic automation store — slack is the only source that needs a + * dedicated index. The index is a denormalized copy of each automation's + * `slack_channel` condition (held in trigger_config); a route writes the + * automation row and the channel rows in one `db.batch` (via bindChannelStatements) + * so the two can't drift apart on a partial failure. + */ + +import type { AutomationRow } from "./automation-store"; + +export class SlackChannelStore { + constructor(private readonly db: D1Database) {} + + /** Enabled, non-deleted slack_event automations watching a channel (indexed by channel_id). */ + async getSlackAutomationsForChannel(channelId: string): Promise { + const result = await this.db + .prepare( + `SELECT a.* FROM automations a + JOIN automation_slack_channels c ON c.automation_id = a.id + WHERE c.channel_id = ? AND a.enabled = 1 AND a.deleted_at IS NULL + AND a.trigger_type = 'slack_event'` + ) + .bind(channelId) + .all(); + return result.results || []; + } + + /** Distinct channel IDs watched by any enabled slack_event automation. */ + async getWatchedSlackChannels(): Promise { + const result = await this.db + .prepare( + `SELECT DISTINCT c.channel_id FROM automation_slack_channels c + JOIN automations a ON a.id = c.automation_id + WHERE a.enabled = 1 AND a.deleted_at IS NULL AND a.trigger_type = 'slack_event'` + ) + .all<{ channel_id: string }>(); + return (result.results || []).map((r) => r.channel_id); + } + + /** + * Statements that replace an automation's watched-channel set (DELETE + re-INSERT). + * Public so a route can compose them with the automation insert/update into one + * `db.batch`, keeping the canonical trigger_config and this index atomic. + */ + bindChannelStatements(automationId: string, channelIds: string[]): D1PreparedStatement[] { + const statements: D1PreparedStatement[] = [ + this.db + .prepare("DELETE FROM automation_slack_channels WHERE automation_id = ?") + .bind(automationId), + ]; + for (const channelId of channelIds) { + statements.push( + this.db + .prepare( + "INSERT OR IGNORE INTO automation_slack_channels (automation_id, channel_id) VALUES (?, ?)" + ) + .bind(automationId, channelId) + ); + } + return statements; + } + + /** Replace an automation's watched-channel set atomically. */ + async setSlackChannels(automationId: string, channelIds: string[]): Promise { + await this.db.batch(this.bindChannelStatements(automationId, channelIds)); + } +} diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts index d2966bd9d..41d0b328d 100644 --- a/packages/control-plane/src/routes/automations.ts +++ b/packages/control-plane/src/routes/automations.ts @@ -23,6 +23,7 @@ import { toAutomationRun, type AutomationRow, } from "../db/automation-store"; +import { SlackChannelStore } from "../db/slack-channel-store"; import { UserStore } from "../db/user-store"; import { resolveProviderIdentity, type SessionIdentityFields } from "../session/identity"; import { generateId } from "../auth/crypto"; @@ -342,9 +343,13 @@ async function handleCreateAutomation( // Persist the automation and (for slack_event) its watched-channel index in a // single atomic write, so the canonical trigger_config and the channel index // that drives scheduler candidate selection can never drift apart on a partial - // failure. + // failure. The batch composes the two single-table stores' prepared statements. if (triggerType === "slack_event") { - await store.createWithSlackChannels(row, extractSlackChannels(body.triggerConfig)); + const slackStore = new SlackChannelStore(env.DB); + await env.DB.batch([ + store.bindAutomationInsert(row), + ...slackStore.bindChannelStatements(row.id, extractSlackChannels(body.triggerConfig)), + ]); } else { await store.create(row); } @@ -563,16 +568,26 @@ async function handleUpdateAutomation( // Apply the update and, when a slack_event automation's conditions changed, // re-sync its watched-channel index in the same atomic write so trigger_config - // and the channel index can never drift apart on a partial failure. + // and the channel index can never drift apart on a partial failure. The batch + // composes the two single-table stores' prepared statements, tolerating a null + // update statement (no automation fields changed, channels-only re-sync). const resyncSlackChannels = existing.trigger_type === "slack_event" && body.triggerConfig !== undefined; - const updated = resyncSlackChannels - ? await store.updateWithSlackChannels( - id, - updateFields, - extractSlackChannels(body.triggerConfig) - ) - : await store.update(id, updateFields); + let updated: AutomationRow | null; + if (resyncSlackChannels) { + const slackStore = new SlackChannelStore(env.DB); + const updateStatement = store.bindAutomationUpdate(id, updateFields); + const channelStatements = slackStore.bindChannelStatements( + id, + extractSlackChannels(body.triggerConfig) + ); + await env.DB.batch( + updateStatement ? [updateStatement, ...channelStatements] : channelStatements + ); + updated = await store.getById(id); + } else { + updated = await store.update(id, updateFields); + } if (!updated) return error("Automation not found", 404); logger.info("automation.updated", { @@ -856,8 +871,7 @@ async function handleGetWatchedSlackChannels( _match: RegExpMatchArray, _ctx: RequestContext ): Promise { - const store = new AutomationStore(env.DB); - const channels = await store.getWatchedSlackChannels(); + const channels = await new SlackChannelStore(env.DB).getWatchedSlackChannels(); return json({ channels }); } diff --git a/packages/control-plane/src/scheduler/durable-object.ts b/packages/control-plane/src/scheduler/durable-object.ts index 9b72923ae..d8c2d8b1b 100644 --- a/packages/control-plane/src/scheduler/durable-object.ts +++ b/packages/control-plane/src/scheduler/durable-object.ts @@ -29,6 +29,7 @@ import { type AutomationRunRow, type SlackRunColumns, } from "../db/automation-store"; +import { SlackChannelStore } from "../db/slack-channel-store"; import { buildSlackCompletionNotification, buildSlackSkipNotification } from "./slack-completion"; import { UserStore } from "../db/user-store"; import { createRequestMetrics } from "../db/instrumented-d1"; @@ -304,7 +305,9 @@ export class SchedulerDO extends DurableObject { ); break; case "slack": - candidates = await store.getSlackAutomationsForChannel(event.channelId); + candidates = await new SlackChannelStore(this.env.DB).getSlackAutomationsForChannel( + event.channelId + ); break; } diff --git a/packages/control-plane/test/integration/automation-store.test.ts b/packages/control-plane/test/integration/automation-store.test.ts index 879c5a04c..5f5a60d61 100644 --- a/packages/control-plane/test/integration/automation-store.test.ts +++ b/packages/control-plane/test/integration/automation-store.test.ts @@ -768,70 +768,6 @@ describe("AutomationStore (D1 integration)", () => { ...overrides, }); - it("setSlackChannels writes and replaces the channel set", async () => { - const store = new AutomationStore(env.DB); - await store.create(makeSlackAutomation({ id: "auto-s1" })); - - await store.setSlackChannels("auto-s1", ["C1", "C2"]); - expect((await store.getWatchedSlackChannels()).sort()).toEqual(["C1", "C2"]); - - await store.setSlackChannels("auto-s1", ["C2", "C3"]); - expect((await store.getWatchedSlackChannels()).sort()).toEqual(["C2", "C3"]); - }); - - it("createWithSlackChannels writes the automation and its channels atomically", async () => { - const store = new AutomationStore(env.DB); - await store.createWithSlackChannels(makeSlackAutomation({ id: "auto-sc" }), ["C1", "C2"]); - - expect(await store.getById("auto-sc")).not.toBeNull(); - expect((await store.getWatchedSlackChannels()).sort()).toEqual(["C1", "C2"]); - }); - - it("updateWithSlackChannels updates the row and re-syncs channels atomically", async () => { - const store = new AutomationStore(env.DB); - await store.createWithSlackChannels(makeSlackAutomation({ id: "auto-su" }), ["C1"]); - - const updated = await store.updateWithSlackChannels("auto-su", { instructions: "updated" }, [ - "C2", - "C3", - ]); - expect(updated?.instructions).toBe("updated"); - expect((await store.getWatchedSlackChannels()).sort()).toEqual(["C2", "C3"]); - }); - - it("getSlackAutomationsForChannel returns only enabled, non-deleted slack automations", async () => { - const store = new AutomationStore(env.DB); - await store.create(makeSlackAutomation({ id: "auto-s2" })); - await store.create(makeSlackAutomation({ id: "auto-s3", enabled: 0 })); - await store.create( - makeAutomation({ - id: "auto-s4", - trigger_type: "github_event", - event_type: "pull_request.opened", - }) - ); - - await store.setSlackChannels("auto-s2", ["C1"]); - await store.setSlackChannels("auto-s3", ["C1"]); // disabled → excluded - await store.setSlackChannels("auto-s4", ["C1"]); // wrong trigger_type → excluded - - const matches = await store.getSlackAutomationsForChannel("C1"); - expect(matches.map((m) => m.id)).toEqual(["auto-s2"]); - }); - - it("getWatchedSlackChannels dedups and excludes disabled automations", async () => { - const store = new AutomationStore(env.DB); - await store.create(makeSlackAutomation({ id: "auto-s5" })); - await store.create(makeSlackAutomation({ id: "auto-s6" })); - await store.create(makeSlackAutomation({ id: "auto-s7", enabled: 0 })); - - await store.setSlackChannels("auto-s5", ["C1", "C2"]); - await store.setSlackChannels("auto-s6", ["C2", "C3"]); // C2 duplicated across automations - await store.setSlackChannels("auto-s7", ["C9"]); // disabled → excluded - - expect((await store.getWatchedSlackChannels()).sort()).toEqual(["C1", "C2", "C3"]); - }); - it("countRunsInWindow honors the window and excludes skipped runs", async () => { const store = new AutomationStore(env.DB); const now = Date.now(); diff --git a/packages/control-plane/test/integration/automations-slack-route.test.ts b/packages/control-plane/test/integration/automations-slack-route.test.ts index b4045bbf0..cc729e0c2 100644 --- a/packages/control-plane/test/integration/automations-slack-route.test.ts +++ b/packages/control-plane/test/integration/automations-slack-route.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { SELF, env } from "cloudflare:test"; import { AutomationStore, toAutomation, type AutomationRow } from "../../src/db/automation-store"; +import { SlackChannelStore } from "../../src/db/slack-channel-store"; import { generateInternalToken } from "../../src/auth/internal"; import { cleanD1Tables } from "./cleanup"; import type { TriggerConfig } from "@open-inspect/shared"; @@ -185,9 +186,10 @@ describe("PUT /automations/:id — slack_event validation (integration)", () => it("atomically updates conditions and re-syncs the watched-channel index", async () => { const store = new AutomationStore(env.DB); + const channels = new SlackChannelStore(env.DB); const auto = makeSlackAutomation(); await store.create(auto); - await store.setSlackChannels(auto.id, ["C1"]); + await channels.setSlackChannels(auto.id, ["C1"]); const res = await putAutomation(auto.id, { triggerConfig: { @@ -199,7 +201,7 @@ describe("PUT /automations/:id — slack_event validation (integration)", () => }); expect(res.status).toBe(200); - const watched = await store.getWatchedSlackChannels(); + const watched = await channels.getWatchedSlackChannels(); expect([...watched].sort()).toEqual(["C2", "C3"]); }); @@ -267,12 +269,13 @@ describe("GET /integration-settings/slack/watched-channels (integration)", () => it("returns the distinct watched channels for enabled slack automations", async () => { const store = new AutomationStore(env.DB); + const channels = new SlackChannelStore(env.DB); const a = makeSlackAutomation(); const b = makeSlackAutomation(); await store.create(a); await store.create(b); - await store.setSlackChannels(a.id, ["C1", "C2"]); - await store.setSlackChannels(b.id, ["C2", "C3"]); + await channels.setSlackChannels(a.id, ["C1", "C2"]); + await channels.setSlackChannels(b.id, ["C2", "C3"]); const res = await getWatchedChannels(); expect(res.status).toBe(200); @@ -282,9 +285,10 @@ describe("GET /integration-settings/slack/watched-channels (integration)", () => it("excludes channels of disabled automations and returns an empty list when none", async () => { const store = new AutomationStore(env.DB); + const channels = new SlackChannelStore(env.DB); const disabled = makeSlackAutomation({ enabled: 0 }); await store.create(disabled); - await store.setSlackChannels(disabled.id, ["C9"]); + await channels.setSlackChannels(disabled.id, ["C9"]); const res = await getWatchedChannels(); expect(res.status).toBe(200); diff --git a/packages/control-plane/test/integration/scheduler-slack-events.test.ts b/packages/control-plane/test/integration/scheduler-slack-events.test.ts index 738875c31..27d594bab 100644 --- a/packages/control-plane/test/integration/scheduler-slack-events.test.ts +++ b/packages/control-plane/test/integration/scheduler-slack-events.test.ts @@ -5,6 +5,7 @@ import { type AutomationRow, type AutomationRunRow, } from "../../src/db/automation-store"; +import { SlackChannelStore } from "../../src/db/slack-channel-store"; import type { SlackAutomationEvent } from "@open-inspect/shared"; import { cleanD1Tables } from "./cleanup"; @@ -107,7 +108,7 @@ async function seedSlackAutomation( ): Promise { const id = `auto-slack-${Math.random().toString(36).slice(2, 8)}`; await store.create(makeAutomation({ id, ...overrides })); - await store.setSlackChannels(id, ["C1"]); + await new SlackChannelStore(env.DB).setSlackChannels(id, ["C1"]); return id; } diff --git a/packages/control-plane/test/integration/slack-channel-store.test.ts b/packages/control-plane/test/integration/slack-channel-store.test.ts new file mode 100644 index 000000000..5424139f5 --- /dev/null +++ b/packages/control-plane/test/integration/slack-channel-store.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { env } from "cloudflare:test"; +import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; +import { SlackChannelStore } from "../../src/db/slack-channel-store"; +import { cleanD1Tables } from "./cleanup"; + +function makeAutomation(overrides?: Partial): AutomationRow { + const now = Date.now(); + return { + id: `auto-${Math.random().toString(36).slice(2, 8)}`, + name: "Test Automation", + repo_owner: "acme", + repo_name: "web-app", + base_branch: "main", + repo_id: 12345, + instructions: "Run tests", + trigger_type: "schedule", + schedule_cron: "0 9 * * *", + schedule_tz: "UTC", + model: "anthropic/claude-sonnet-4-6", + reasoning_effort: null, + enabled: 1, + next_run_at: now + 86400000, + consecutive_failures: 0, + created_by: "user-1", + user_id: null, + created_at: now, + updated_at: now, + deleted_at: null, + event_type: null, + trigger_config: null, + trigger_auth_data: null, + ...overrides, + }; +} + +const makeSlackAutomation = (overrides?: Partial) => + makeAutomation({ + trigger_type: "slack_event", + event_type: "message.posted", + ...overrides, + }); + +describe("SlackChannelStore (D1 integration)", () => { + beforeEach(cleanD1Tables); + + it("setSlackChannels writes and replaces the channel set", async () => { + const store = new AutomationStore(env.DB); + const channels = new SlackChannelStore(env.DB); + await store.create(makeSlackAutomation({ id: "auto-s1" })); + + await channels.setSlackChannels("auto-s1", ["C1", "C2"]); + expect((await channels.getWatchedSlackChannels()).sort()).toEqual(["C1", "C2"]); + + await channels.setSlackChannels("auto-s1", ["C2", "C3"]); + expect((await channels.getWatchedSlackChannels()).sort()).toEqual(["C2", "C3"]); + }); + + it("getSlackAutomationsForChannel returns only enabled, non-deleted slack automations", async () => { + const store = new AutomationStore(env.DB); + const channels = new SlackChannelStore(env.DB); + await store.create(makeSlackAutomation({ id: "auto-s2" })); + await store.create(makeSlackAutomation({ id: "auto-s3", enabled: 0 })); + await store.create( + makeAutomation({ + id: "auto-s4", + trigger_type: "github_event", + event_type: "pull_request.opened", + }) + ); + + await channels.setSlackChannels("auto-s2", ["C1"]); + await channels.setSlackChannels("auto-s3", ["C1"]); // disabled → excluded + await channels.setSlackChannels("auto-s4", ["C1"]); // wrong trigger_type → excluded + + const matches = await channels.getSlackAutomationsForChannel("C1"); + expect(matches.map((m) => m.id)).toEqual(["auto-s2"]); + }); + + it("getWatchedSlackChannels dedups and excludes disabled automations", async () => { + const store = new AutomationStore(env.DB); + const channels = new SlackChannelStore(env.DB); + await store.create(makeSlackAutomation({ id: "auto-s5" })); + await store.create(makeSlackAutomation({ id: "auto-s6" })); + await store.create(makeSlackAutomation({ id: "auto-s7", enabled: 0 })); + + await channels.setSlackChannels("auto-s5", ["C1", "C2"]); + await channels.setSlackChannels("auto-s6", ["C2", "C3"]); // C2 duplicated across automations + await channels.setSlackChannels("auto-s7", ["C9"]); // disabled → excluded + + expect((await channels.getWatchedSlackChannels()).sort()).toEqual(["C1", "C2", "C3"]); + }); +}); diff --git a/packages/control-plane/test/integration/webhooks-slack.test.ts b/packages/control-plane/test/integration/webhooks-slack.test.ts index 2fee94181..5993b1226 100644 --- a/packages/control-plane/test/integration/webhooks-slack.test.ts +++ b/packages/control-plane/test/integration/webhooks-slack.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { SELF, env } from "cloudflare:test"; import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; +import { SlackChannelStore } from "../../src/db/slack-channel-store"; import { generateInternalToken } from "../../src/auth/internal"; import { cleanD1Tables } from "./cleanup"; @@ -67,7 +68,7 @@ async function seedSlackAutomation(): Promise { const store = new AutomationStore(env.DB); const automation = makeSlackAutomation(); await store.create(automation); - await store.setSlackChannels(automation.id, ["C1"]); + await new SlackChannelStore(env.DB).setSlackChannels(automation.id, ["C1"]); return automation.id; } From bb5cb8832839eaa95980e19ceb0a2e8ab3468e37 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 24 Jun 2026 12:48:29 -0700 Subject: [PATCH 05/19] refactor(slack): store run coordinates in trigger_run_metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The automation_runs ledger is shared by all six trigger sources, but it carried four slack-only columns (slack_channel, slack_thread_ts, slack_message_ts, actor_user_id) that were NULL for the other five — a Single-Table-Inheritance nullable-column smell. None were ever queried (no WHERE/JOIN/index); they are pure payload read back only to post a run's result into its originating Slack thread. actor_user_id was write-only (nothing read it). Collapse the three live coordinates into one generic, source-agnostic trigger_run_metadata TEXT column holding {channel, threadTs?, messageTs} for slack runs (absent otherwise), and drop actor_user_id. The canonical run-row layer (AutomationRunRow) is now free of slack specifics; interpretation lives in the slack files — slackRunMetadata() serializes on the write side, getSlackRunMetadata() parses on the read side. Keeps the single-row INSERT (no atomicity change) and mirrors the earlier reply_in_thread -> trigger_config move. Migration 0026 reshaped in place (branch unmerged; no backfill). --- .../control-plane/src/db/automation-store.ts | 29 ++++-------- .../src/scheduler/durable-object.ts | 36 +++++++++----- .../src/scheduler/slack-completion.test.ts | 32 +++++++------ .../src/scheduler/slack-completion.ts | 47 ++++++++++++++----- .../test/integration/automation-store.test.ts | 39 ++++++++------- .../scheduler-slack-events.test.ts | 8 ++-- .../d1/migrations/0026_slack_triggers.sql | 14 +++--- 7 files changed, 117 insertions(+), 88 deletions(-) diff --git a/packages/control-plane/src/db/automation-store.ts b/packages/control-plane/src/db/automation-store.ts index 63d207dbc..71a7d00fc 100644 --- a/packages/control-plane/src/db/automation-store.ts +++ b/packages/control-plane/src/db/automation-store.ts @@ -55,11 +55,9 @@ export interface AutomationRunRow { created_at: number; trigger_key: string | null; concurrency_key: string | null; - // Slack triggers: thread coordinates + posting actor (slack-origin runs only). - slack_channel?: string | null; - slack_thread_ts?: string | null; - slack_message_ts?: string | null; - actor_user_id?: string | null; + // Source-specific run metadata as JSON (slack-origin runs only today; absent + // otherwise). Opaque to the store — interpreted by the owning source's layer. + trigger_run_metadata?: string | null; } export interface EnrichedRunRow extends AutomationRunRow { @@ -67,12 +65,6 @@ export interface EnrichedRunRow extends AutomationRunRow { artifact_summary: string | null; } -/** The slack thread-coordinate columns of a run row, set together for slack-origin runs. */ -export type SlackRunColumns = Pick< - AutomationRunRow, - "slack_channel" | "slack_thread_ts" | "slack_message_ts" | "actor_user_id" ->; - // ─── Mappers ───────────────────────────────────────────────────────────────── /** @@ -341,8 +333,8 @@ export class AutomationStore { `INSERT INTO automation_runs (id, automation_id, session_id, status, skip_reason, failure_reason, scheduled_at, started_at, completed_at, created_at, trigger_key, concurrency_key, - slack_channel, slack_thread_ts, slack_message_ts, actor_user_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + trigger_run_metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( run.id, @@ -357,10 +349,7 @@ export class AutomationStore { run.created_at, run.trigger_key ?? null, run.concurrency_key ?? null, - run.slack_channel ?? null, - run.slack_thread_ts ?? null, - run.slack_message_ts ?? null, - run.actor_user_id ?? null + run.trigger_run_metadata ?? null ); } @@ -551,7 +540,7 @@ export class AutomationStore { /** * Record a `skipped` run for observability (rate-limited / concurrency skips). * Leaves trigger_key null so the skip stays excluded from countRunsInWindow. - * `slackColumns` carries the run's thread coordinates as a unit — the same + * `runMetadata` carries the run's source-specific metadata as a unit — the same * value a materialized run is inserted with — so there is no re-mapping. */ async recordSkippedRun(params: { @@ -559,7 +548,7 @@ export class AutomationStore { automationId: string; skipReason: string; concurrencyKey?: string | null; - slackColumns?: SlackRunColumns; + runMetadata?: Pick; }): Promise { const now = Date.now(); try { @@ -576,7 +565,7 @@ export class AutomationStore { created_at: now, trigger_key: null, concurrency_key: params.concurrencyKey ?? null, - ...params.slackColumns, + ...params.runMetadata, }); } catch (e) { // Defensive only: the insert uses a fresh unique id and a null trigger_key diff --git a/packages/control-plane/src/scheduler/durable-object.ts b/packages/control-plane/src/scheduler/durable-object.ts index 8e8204c1d..2ca86c53b 100644 --- a/packages/control-plane/src/scheduler/durable-object.ts +++ b/packages/control-plane/src/scheduler/durable-object.ts @@ -27,10 +27,14 @@ import { getReplyInThread, type AutomationRow, type AutomationRunRow, - type SlackRunColumns, } from "../db/automation-store"; import { SlackChannelStore } from "../db/slack-channel-store"; -import { buildSlackCompletionNotification, buildSlackSkipNotification } from "./slack-completion"; +import { + buildSlackCompletionNotification, + buildSlackSkipNotification, + getSlackRunMetadata, + type SlackRunMetadata, +} from "./slack-completion"; import { UserStore } from "../db/user-store"; import { createRequestMetrics } from "../db/instrumented-d1"; import { generateId } from "../auth/crypto"; @@ -495,7 +499,7 @@ export class SchedulerDO extends DurableObject { created_at: now, trigger_key: event.triggerKey, concurrency_key: event.concurrencyKey, - ...(event.source === "slack" ? slackRunColumns(event) : {}), + ...(event.source === "slack" ? slackRunMetadata(event) : {}), }); } catch (e) { if (isDuplicateKeyError(e)) { @@ -697,10 +701,12 @@ export class SchedulerDO extends DurableObject { // Slack-triggered runs deliver their result back into the originating // thread. The scheduler owns this fan-out (not the session callback path) // because the thread coordinates live on the run row. Best-effort. - if (run.slack_channel) { + const slackMeta = getSlackRunMetadata(run); + if (slackMeta) { await this.notifySlackCompletion( store, run, + slackMeta, body.success, body.success ? undefined : body.error ); @@ -728,7 +734,7 @@ export class SchedulerDO extends DurableObject { automationId, skipReason: reason, concurrencyKey: event.concurrencyKey, - slackColumns: slackRunColumns(event), + runMetadata: slackRunMetadata(event), }); } @@ -742,6 +748,7 @@ export class SchedulerDO extends DurableObject { private async notifySlackCompletion( store: AutomationStore, run: AutomationRunRow, + meta: SlackRunMetadata, success: boolean, error?: string ): Promise { @@ -759,7 +766,8 @@ export class SchedulerDO extends DurableObject { } } const body = buildSlackCompletionNotification({ - run, + meta, + sessionId: run.session_id, automationName: automation?.name ?? "Automation", success, error, @@ -948,12 +956,14 @@ export class SchedulerDO extends DurableObject { } } -/** Run-row slack columns for a slack-origin event — shared by insertRun and recordSkippedRun. */ -function slackRunColumns(event: SlackAutomationEvent): SlackRunColumns { - return { - slack_channel: event.channelId, - slack_thread_ts: event.threadTs ?? null, - slack_message_ts: event.ts, - actor_user_id: event.actorUserId, +/** Serialized slack run metadata for a slack-origin event — shared by insertRun and recordSkippedRun. */ +function slackRunMetadata( + event: SlackAutomationEvent +): Pick { + const metadata: SlackRunMetadata = { + channel: event.channelId, + threadTs: event.threadTs ?? undefined, + messageTs: event.ts, }; + return { trigger_run_metadata: JSON.stringify(metadata) }; } diff --git a/packages/control-plane/src/scheduler/slack-completion.test.ts b/packages/control-plane/src/scheduler/slack-completion.test.ts index 9264b2b45..5973ab71c 100644 --- a/packages/control-plane/src/scheduler/slack-completion.test.ts +++ b/packages/control-plane/src/scheduler/slack-completion.test.ts @@ -2,24 +2,23 @@ import { describe, it, expect } from "vitest"; import { buildSlackCompletionNotification, buildSlackSkipNotification, - type SlackRunCoords, + type SlackRunMetadata, } from "./slack-completion"; -function coords(overrides?: Partial): SlackRunCoords { +function meta(overrides?: Partial): SlackRunMetadata { return { - slack_channel: "C1", - slack_thread_ts: null, - slack_message_ts: "1700000000.000100", - session_id: "sess-1", + channel: "C1", + messageTs: "1700000000.000100", ...overrides, }; } describe("buildSlackCompletionNotification", () => { - it("returns null for a non-slack run (no channel)", () => { + it("returns null for a non-slack run (no metadata)", () => { expect( buildSlackCompletionNotification({ - run: coords({ slack_channel: null }), + meta: null, + sessionId: "sess-1", automationName: "Triage", success: true, replyInThread: true, @@ -27,10 +26,11 @@ describe("buildSlackCompletionNotification", () => { ).toBeNull(); }); - it("returns null when a slack run has no thread anchor", () => { + it("returns null when the metadata has no usable thread anchor", () => { expect( buildSlackCompletionNotification({ - run: coords({ slack_thread_ts: null, slack_message_ts: null }), + meta: meta({ messageTs: "" }), + sessionId: "sess-1", automationName: "Triage", success: true, replyInThread: true, @@ -40,7 +40,8 @@ describe("buildSlackCompletionNotification", () => { it("anchors to the thread ts when present", () => { const n = buildSlackCompletionNotification({ - run: coords({ slack_thread_ts: "1699999999.000001" }), + meta: meta({ threadTs: "1699999999.000001" }), + sessionId: "sess-1", automationName: "Triage", success: true, replyInThread: true, @@ -59,7 +60,8 @@ describe("buildSlackCompletionNotification", () => { it("falls back to the message ts as the thread anchor", () => { const n = buildSlackCompletionNotification({ - run: coords({ slack_thread_ts: null }), + meta: meta(), + sessionId: "sess-1", automationName: "Triage", success: true, replyInThread: true, @@ -70,7 +72,8 @@ describe("buildSlackCompletionNotification", () => { it("includes a truncated error summary on failure", () => { const longError = "x".repeat(5000); const n = buildSlackCompletionNotification({ - run: coords(), + meta: meta(), + sessionId: "sess-1", automationName: "Triage", success: false, error: longError, @@ -82,7 +85,8 @@ describe("buildSlackCompletionNotification", () => { it("threads replyInThread=false through so the bot suppresses the thread post", () => { const n = buildSlackCompletionNotification({ - run: coords(), + meta: meta(), + sessionId: "sess-1", automationName: "Triage", success: true, replyInThread: false, diff --git a/packages/control-plane/src/scheduler/slack-completion.ts b/packages/control-plane/src/scheduler/slack-completion.ts index 43bb2cb53..78de45c76 100644 --- a/packages/control-plane/src/scheduler/slack-completion.ts +++ b/packages/control-plane/src/scheduler/slack-completion.ts @@ -11,11 +11,33 @@ import type { AutomationRunRow } from "../db/automation-store"; -/** Run-row coordinate subset the slack completion notification needs. */ -export type SlackRunCoords = Pick< - AutomationRunRow, - "slack_channel" | "slack_thread_ts" | "slack_message_ts" | "session_id" ->; +/** + * Slack run coordinates captured at trigger time, serialized into the run's + * generic `trigger_run_metadata` column (slack-origin runs only). `threadTs` is + * the reply target (falls back to `messageTs`); `messageTs` is the triggering + * message, used to clear the `eyes` reaction on completion. + */ +export interface SlackRunMetadata { + channel: string; + threadTs?: string; + messageTs: string; +} + +/** + * Parse a run row's `trigger_run_metadata` as slack coordinates — null when + * absent (a non-slack run) or malformed. Completion is best-effort, so a parse + * failure silently no-ops rather than throwing into the dispatch path. + */ +export function getSlackRunMetadata( + row: Pick +): SlackRunMetadata | null { + if (!row.trigger_run_metadata) return null; + try { + return JSON.parse(row.trigger_run_metadata) as SlackRunMetadata; + } catch { + return null; + } +} /** Max characters of an error surfaced inline; the full transcript is one click away. */ const SUMMARY_MAX_LENGTH = 1500; @@ -39,22 +61,23 @@ export interface SlackCompletionNotification { } export function buildSlackCompletionNotification(params: { - run: SlackRunCoords; + meta: SlackRunMetadata | null; + sessionId: string | null; automationName: string; success: boolean; error?: string; replyInThread: boolean; }): SlackCompletionNotification | null { - const { run } = params; - if (!run.slack_channel) return null; - const threadTs = run.slack_thread_ts ?? run.slack_message_ts ?? undefined; + const { meta } = params; + if (!meta) return null; + const threadTs = meta.threadTs ?? meta.messageTs; if (!threadTs) return null; return { - channel: run.slack_channel, + channel: meta.channel, threadTs, - reactionMessageTs: run.slack_message_ts ?? undefined, - sessionId: run.session_id ?? null, + reactionMessageTs: meta.messageTs, + sessionId: params.sessionId, success: params.success, summary: params.error ? params.error.slice(0, SUMMARY_MAX_LENGTH) : undefined, automationName: params.automationName, diff --git a/packages/control-plane/test/integration/automation-store.test.ts b/packages/control-plane/test/integration/automation-store.test.ts index 4cd02d25e..c4193dec1 100644 --- a/packages/control-plane/test/integration/automation-store.test.ts +++ b/packages/control-plane/test/integration/automation-store.test.ts @@ -863,7 +863,7 @@ describe("AutomationStore (D1 integration)", () => { expect(count).toBe(2); }); - it("recordSkippedRun persists a skip with slack coords, no trigger_key, uncounted", async () => { + it("recordSkippedRun persists a skip with run metadata, no trigger_key, uncounted", async () => { const store = new AutomationStore(env.DB); await store.create(makeSlackAutomation({ id: "auto-s9" })); @@ -872,11 +872,12 @@ describe("AutomationStore (D1 integration)", () => { automationId: "auto-s9", skipReason: "rate_limited", concurrencyKey: "slack:C1:111", - slackColumns: { - slack_channel: "C1", - slack_thread_ts: "111", - slack_message_ts: "222", - actor_user_id: "U1", + runMetadata: { + trigger_run_metadata: JSON.stringify({ + channel: "C1", + threadTs: "111", + messageTs: "222", + }), }, }); @@ -884,15 +885,17 @@ describe("AutomationStore (D1 integration)", () => { expect(run).not.toBeNull(); expect(run!.status).toBe("skipped"); expect(run!.skip_reason).toBe("rate_limited"); - expect(run!.slack_channel).toBe("C1"); - expect(run!.slack_message_ts).toBe("222"); + expect(JSON.parse(run!.trigger_run_metadata!)).toMatchObject({ + channel: "C1", + messageTs: "222", + }); expect(run!.trigger_key).toBeNull(); // A recorded skip must not count toward the rate-limit window. expect(await store.countRunsInWindow("auto-s9", Date.now() - 3_600_000)).toBe(0); }); - it("insertRun persists slack thread coordinates on a materialized run", async () => { + it("insertRun persists trigger_run_metadata on a materialized run", async () => { const store = new AutomationStore(env.DB); await store.create(makeSlackAutomation({ id: "auto-s10" })); @@ -902,18 +905,20 @@ describe("AutomationStore (D1 integration)", () => { status: "starting", trigger_key: "slack:msg:C1:222", concurrency_key: "slack:C1:111", - slack_channel: "C1", - slack_thread_ts: "111", - slack_message_ts: "222", - actor_user_id: "U1", + trigger_run_metadata: JSON.stringify({ + channel: "C1", + threadTs: "111", + messageTs: "222", + }), }) ); const run = await store.getRunById("auto-s10", "r-mat"); - expect(run!.slack_channel).toBe("C1"); - expect(run!.slack_thread_ts).toBe("111"); - expect(run!.slack_message_ts).toBe("222"); - expect(run!.actor_user_id).toBe("U1"); + expect(JSON.parse(run!.trigger_run_metadata!)).toEqual({ + channel: "C1", + threadTs: "111", + messageTs: "222", + }); }); it("persists max_runs_per_hour on the automation", async () => { diff --git a/packages/control-plane/test/integration/scheduler-slack-events.test.ts b/packages/control-plane/test/integration/scheduler-slack-events.test.ts index 27d594bab..4270ecbe5 100644 --- a/packages/control-plane/test/integration/scheduler-slack-events.test.ts +++ b/packages/control-plane/test/integration/scheduler-slack-events.test.ts @@ -127,9 +127,9 @@ describe("SchedulerDO /internal/event — slack (integration)", () => { expect(runs.total).toBeGreaterThanOrEqual(1); const run = runs.runs.find((r) => r.trigger_key === event.triggerKey)!; expect(run).toBeDefined(); - expect(run.slack_channel).toBe("C1"); - expect(run.slack_message_ts).toBe(event.ts); - expect(run.actor_user_id).toBe("U1"); + const metadata = JSON.parse(run.trigger_run_metadata!); + expect(metadata.channel).toBe("C1"); + expect(metadata.messageTs).toBe(event.ts); }); it("does not trigger when the text_match condition fails", async () => { @@ -263,7 +263,7 @@ describe("SchedulerDO /internal/event — slack (integration)", () => { const runs = await store.listRunsForAutomation(id, { limit: 20, offset: 0 }); const skip = runs.runs.find((r) => r.skip_reason === "concurrent_run_active"); expect(skip).toBeDefined(); - expect(skip!.slack_channel).toBe("C1"); + expect(JSON.parse(skip!.trigger_run_metadata!).channel).toBe("C1"); }); it("dedups a duplicate slack message with the same trigger_key", async () => { diff --git a/terraform/d1/migrations/0026_slack_triggers.sql b/terraform/d1/migrations/0026_slack_triggers.sql index bd8aa14c7..a0dbbf29d 100644 --- a/terraform/d1/migrations/0026_slack_triggers.sql +++ b/terraform/d1/migrations/0026_slack_triggers.sql @@ -18,14 +18,12 @@ CREATE INDEX IF NOT EXISTS idx_slack_channels_channel -- column — it lives in the automation's trigger_config JSON, keyed by trigger_type. ALTER TABLE automations ADD COLUMN max_runs_per_hour INTEGER; --- Slack thread coordinates + posting actor on the run. Nullable; only --- slack-origin runs populate them. slack_thread_ts is the reply target; --- slack_message_ts is the triggering message (used to clear the eyes reaction --- on completion); actor_user_id is the posting Slack user, for attribution. -ALTER TABLE automation_runs ADD COLUMN slack_channel TEXT; -ALTER TABLE automation_runs ADD COLUMN slack_thread_ts TEXT; -ALTER TABLE automation_runs ADD COLUMN slack_message_ts TEXT; -ALTER TABLE automation_runs ADD COLUMN actor_user_id TEXT; +-- Source-specific run metadata as JSON; only slack-origin runs populate it +-- today. For slack: {channel, threadTs?, messageTs} — threadTs is the reply +-- target (falls back to messageTs), messageTs is the triggering message used to +-- clear the eyes reaction on completion. Read back only to post the result into +-- the originating thread; never queried, so no index. +ALTER TABLE automation_runs ADD COLUMN trigger_run_metadata TEXT; -- The rate-limit window query (automation_id = ? AND created_at >= ?) is already -- served by idx_runs_automation_created (automation_id, created_at DESC) from From e43848257e6b73239f3f1c9ef3203218a2097a29 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 24 Jun 2026 14:06:39 -0700 Subject: [PATCH 06/19] refactor(slack): move replyInThread into trigger_config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the duplicate top-level replyInThread from the shared Automation, CreateAutomationRequest, and UpdateAutomationRequest types. The setting now lives only inside TriggerConfig, interpreted via trigger_type like conditions. Delete the server-side fold (create) and preservation-merge (update): the web form owns the full trigger_config blob and always submits replyInThread within it. toAutomation no longer echoes the field; getReplyInThread stays the single read-path accessor (scheduler and edit page read straight from trigger_config). Also align maxRunsPerHour doc comments — a generic per-automation hourly cap enforced for slack_event today, not a slack-only field. --- .../src/db/automation-store.test.ts | 4 +- .../control-plane/src/db/automation-store.ts | 8 ++-- .../control-plane/src/routes/automations.ts | 42 ++++++------------- .../automations-slack-route.test.ts | 30 +++++++++---- packages/shared/src/types/index.ts | 6 +-- .../app/(app)/automations/[id]/edit/page.tsx | 1 - .../automations/automation-form.test.tsx | 5 +-- .../automations/automation-form.tsx | 14 ++++--- packages/web/src/lib/automation-templates.ts | 2 +- 9 files changed, 53 insertions(+), 59 deletions(-) diff --git a/packages/control-plane/src/db/automation-store.test.ts b/packages/control-plane/src/db/automation-store.test.ts index d13ccf57c..7a968bb13 100644 --- a/packages/control-plane/src/db/automation-store.test.ts +++ b/packages/control-plane/src/db/automation-store.test.ts @@ -148,7 +148,7 @@ describe("toAutomation", () => { it("defaults the slack knobs (null cap, reply-in-thread on) when unset", () => { const automation = toAutomation(sampleRow); expect(automation.maxRunsPerHour).toBeNull(); - expect(automation.replyInThread).toBe(true); + expect(getReplyInThread(automation.triggerConfig)).toBe(true); }); it("maps an explicit cap and reads reply-in-thread from trigger_config", () => { @@ -158,7 +158,7 @@ describe("toAutomation", () => { trigger_config: JSON.stringify({ conditions: [], replyInThread: false }), }); expect(automation.maxRunsPerHour).toBe(5); - expect(automation.replyInThread).toBe(false); + expect(getReplyInThread(automation.triggerConfig)).toBe(false); }); }); diff --git a/packages/control-plane/src/db/automation-store.ts b/packages/control-plane/src/db/automation-store.ts index 71a7d00fc..76f7beeb3 100644 --- a/packages/control-plane/src/db/automation-store.ts +++ b/packages/control-plane/src/db/automation-store.ts @@ -68,9 +68,10 @@ export interface EnrichedRunRow extends AutomationRunRow { // ─── Mappers ───────────────────────────────────────────────────────────────── /** - * The effective reply-in-thread setting from a parsed trigger_config. Stored in - * trigger_config (slack_event only); defaults to true — matching the prior - * `reply_in_thread NOT NULL DEFAULT 1` column and the camelCase API default. + * The effective reply-in-thread setting from a parsed trigger_config (slack_event + * only); defaults to true — matching the prior `reply_in_thread NOT NULL DEFAULT 1` + * column. The single read-path accessor: the setting lives only inside + * trigger_config, with no parallel top-level field. */ export function getReplyInThread(config: TriggerConfig | null): boolean { return config?.replyInThread ?? true; @@ -103,7 +104,6 @@ export function toAutomation(row: AutomationRow): Automation { eventType: row.event_type ?? null, triggerConfig, maxRunsPerHour: row.max_runs_per_hour ?? null, - replyInThread: getReplyInThread(triggerConfig), }; } diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts index 41d0b328d..9d0c656a6 100644 --- a/packages/control-plane/src/routes/automations.ts +++ b/packages/control-plane/src/routes/automations.ts @@ -109,11 +109,11 @@ function validateSlackRequiredConditions( } /** - * `maxRunsPerHour` is the Slack-only per-automation hourly cap. It feeds the - * scheduler's `recentRuns >= maxRuns` comparison directly, so reject anything - * that is not `null`/absent (use the app default) or a positive integer — - * untrusted JSON could otherwise persist `0`, a negative, a fractional, or a - * non-number value and corrupt the rate-limit branch. + * `maxRunsPerHour` is a generic per-automation hourly cap (enforced for slack_event + * today). It feeds the scheduler's `recentRuns >= maxRuns` comparison directly, so + * reject anything that is not `null`/absent (use the app default) or a positive + * integer — untrusted JSON could otherwise persist `0`, a negative, a fractional, or + * a non-number value and corrupt the rate-limit branch. */ function validateMaxRunsPerHour(value: unknown): string | null { if (value === undefined || value === null) return null; @@ -306,12 +306,6 @@ async function handleCreateAutomation( } const store = new AutomationStore(env.DB); - // reply_in_thread lives inside trigger_config (slack_event only), not a column; - // fold the top-level API flag in here, defaulting true (the prior column default). - const storedTriggerConfig: TriggerConfig | undefined = - triggerType === "slack_event" && body.triggerConfig - ? { ...body.triggerConfig, replyInThread: body.replyInThread !== false } - : body.triggerConfig; const row: AutomationRow = { id, name: body.name.trim(), @@ -334,7 +328,7 @@ async function handleCreateAutomation( updated_at: now, deleted_at: null, event_type: body.eventType ?? null, - trigger_config: storedTriggerConfig ? JSON.stringify(storedTriggerConfig) : null, + trigger_config: body.triggerConfig ? JSON.stringify(body.triggerConfig) : null, trigger_auth_data: triggerAuthData, // Generic per-automation rate cap (consumed by slack today; harmless for others). max_runs_per_hour: body.maxRunsPerHour ?? null, @@ -531,26 +525,14 @@ async function handleUpdateAutomation( updateFields.max_runs_per_hour = body.maxRunsPerHour; } - // Persist trigger_config, folding the slack-only replyInThread flag into it - // (reply_in_thread is no longer a column). A null clears it; otherwise for - // slack_event we merge replyInThread — the explicit flag, else the previously - // stored value, else the true default — so a conditions-only edit preserves it. - const isSlackEvent = existing.trigger_type === "slack_event"; - const previousConfig: TriggerConfig | null = existing.trigger_config - ? JSON.parse(existing.trigger_config) - : null; + // trigger_config is a single source-interpreted JSON blob — slack_event's + // replyInThread rides inside it alongside conditions, so a PUT replaces it + // wholesale (null clears it). The caller owns the full blob; the web form + // always re-submits replyInThread within triggerConfig. if (body.triggerConfig === null) { updateFields.trigger_config = null; - } else if ( - body.triggerConfig !== undefined || - (isSlackEvent && body.replyInThread !== undefined) - ) { - const base = body.triggerConfig ?? previousConfig ?? { conditions: [] }; - updateFields.trigger_config = JSON.stringify( - isSlackEvent - ? { ...base, replyInThread: body.replyInThread ?? previousConfig?.replyInThread ?? true } - : base - ); + } else if (body.triggerConfig !== undefined) { + updateFields.trigger_config = JSON.stringify(body.triggerConfig); } // Recompute next_run_at if schedule changed (only for schedule types) diff --git a/packages/control-plane/test/integration/automations-slack-route.test.ts b/packages/control-plane/test/integration/automations-slack-route.test.ts index cc729e0c2..40b785f0d 100644 --- a/packages/control-plane/test/integration/automations-slack-route.test.ts +++ b/packages/control-plane/test/integration/automations-slack-route.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, beforeEach } from "vitest"; import { SELF, env } from "cloudflare:test"; -import { AutomationStore, toAutomation, type AutomationRow } from "../../src/db/automation-store"; +import { + AutomationStore, + getReplyInThread, + toAutomation, + type AutomationRow, +} from "../../src/db/automation-store"; import { SlackChannelStore } from "../../src/db/slack-channel-store"; import { generateInternalToken } from "../../src/auth/internal"; import { cleanD1Tables } from "./cleanup"; @@ -205,7 +210,7 @@ describe("PUT /automations/:id — slack_event validation (integration)", () => expect([...watched].sort()).toEqual(["C2", "C3"]); }); - it("folds replyInThread into trigger_config (no reply_in_thread column)", async () => { + it("persists replyInThread inside trigger_config on update", async () => { const store = new AutomationStore(env.DB); const auto = makeSlackAutomation({ trigger_config: JSON.stringify({ @@ -214,18 +219,26 @@ describe("PUT /automations/:id — slack_event validation (integration)", () => }); await store.create(auto); - const res = await putAutomation(auto.id, { replyInThread: false }); + const res = await putAutomation(auto.id, { + triggerConfig: { + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + replyInThread: false, + }, + }); expect(res.status).toBe(200); const row = await store.getById(auto.id); const config = JSON.parse(row!.trigger_config!) as TriggerConfig; expect(config.replyInThread).toBe(false); - // Conditions survive the merge, and the camelCase API mapper surfaces the flag. expect(config.conditions.some((c) => c.type === "slack_channel")).toBe(true); - expect(toAutomation(row!).replyInThread).toBe(false); + // The read-path accessor surfaces the stored flag (no top-level field). + expect(getReplyInThread(toAutomation(row!).triggerConfig)).toBe(false); }); - it("preserves a stored replyInThread across a conditions-only edit", async () => { + it("replaces trigger_config wholesale on update (replyInThread is not auto-merged)", async () => { const store = new AutomationStore(env.DB); const auto = makeSlackAutomation({ trigger_config: JSON.stringify({ @@ -235,6 +248,8 @@ describe("PUT /automations/:id — slack_event validation (integration)", () => }); await store.create(auto); + // A PUT replaces the whole blob; omitting replyInThread drops it. The client + // owns the full trigger_config (the web form always re-submits the flag). const res = await putAutomation(auto.id, { triggerConfig: { conditions: [ @@ -246,7 +261,8 @@ describe("PUT /automations/:id — slack_event validation (integration)", () => expect(res.status).toBe(200); const config = JSON.parse((await store.getById(auto.id))!.trigger_config!) as TriggerConfig; - expect(config.replyInThread).toBe(false); // preserved, not reset to the default + expect(config.replyInThread).toBeUndefined(); // replaced, not merged + expect(getReplyInThread(config)).toBe(true); // resolves to the default on read }); }); diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 0615140b9..47b5b127a 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -793,10 +793,8 @@ export interface Automation { deletedAt: number | null; eventType: string | null; triggerConfig: TriggerConfig | null; - /** Per-hour run cap for slack_event automations; null falls back to the default. */ + /** Generic per-automation hourly run cap (enforced for slack_event today); null = app default. */ maxRunsPerHour?: number | null; - /** Whether slack_event run results are posted back into the originating thread. */ - replyInThread?: boolean; } export interface CreateAutomationRequest { @@ -814,7 +812,6 @@ export interface CreateAutomationRequest { triggerConfig?: TriggerConfig; sentryClientSecret?: string; maxRunsPerHour?: number | null; - replyInThread?: boolean; } export interface UpdateAutomationRequest { @@ -828,7 +825,6 @@ export interface UpdateAutomationRequest { eventType?: string; triggerConfig?: TriggerConfig; maxRunsPerHour?: number | null; - replyInThread?: boolean; } export interface AutomationRun { diff --git a/packages/web/src/app/(app)/automations/[id]/edit/page.tsx b/packages/web/src/app/(app)/automations/[id]/edit/page.tsx index 7728231c8..a20a20af8 100644 --- a/packages/web/src/app/(app)/automations/[id]/edit/page.tsx +++ b/packages/web/src/app/(app)/automations/[id]/edit/page.tsx @@ -114,7 +114,6 @@ export default function EditAutomationPage({ params }: { params: Promise<{ id: s eventType: automation.eventType ?? undefined, triggerConfig: automation.triggerConfig ?? undefined, maxRunsPerHour: automation.maxRunsPerHour ?? null, - replyInThread: automation.replyInThread ?? true, }} onSubmit={handleSubmit} submitting={submitting} diff --git a/packages/web/src/components/automations/automation-form.test.tsx b/packages/web/src/components/automations/automation-form.test.tsx index 3bca7ada5..e694b812c 100644 --- a/packages/web/src/components/automations/automation-form.test.tsx +++ b/packages/web/src/components/automations/automation-form.test.tsx @@ -252,9 +252,8 @@ describe("slack_event automation", () => { expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0]).toMatchObject({ triggerType: "slack_event", - replyInThread: false, maxRunsPerHour: 5, - triggerConfig: validConditions, + triggerConfig: { ...validConditions, replyInThread: false }, }); }); @@ -272,8 +271,8 @@ describe("slack_event automation", () => { fireEvent.submit(container.querySelector("form")!); expect(onSubmit.mock.calls[0][0]).toMatchObject({ - replyInThread: true, maxRunsPerHour: null, + triggerConfig: { ...validConditions, replyInThread: true }, }); }); }); diff --git a/packages/web/src/components/automations/automation-form.tsx b/packages/web/src/components/automations/automation-form.tsx index 887a66b5e..f939d2173 100644 --- a/packages/web/src/components/automations/automation-form.tsx +++ b/packages/web/src/components/automations/automation-form.tsx @@ -12,6 +12,7 @@ import { type AutomationTriggerType, type AutomationEventSource, type TriggerCondition, + type TriggerConfig, } from "@open-inspect/shared"; import { useRepos } from "@/hooks/use-repos"; import { useBranches } from "@/hooks/use-branches"; @@ -92,10 +93,9 @@ export interface AutomationFormValues { instructions: string; triggerType: AutomationTriggerType; eventType?: string; - triggerConfig?: { conditions: TriggerCondition[] }; + triggerConfig?: TriggerConfig; sentryClientSecret?: string; maxRunsPerHour?: number | null; - replyInThread?: boolean; } interface AutomationFormProps { @@ -135,7 +135,9 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au initialValues?.triggerConfig?.conditions ?? [] ); const [sentryClientSecret, setSentryClientSecret] = useState(""); - const [replyInThread, setReplyInThread] = useState(initialValues?.replyInThread ?? true); + const [replyInThread, setReplyInThread] = useState( + initialValues?.triggerConfig?.replyInThread ?? true + ); const [maxRunsPerHour, setMaxRunsPerHour] = useState( initialValues?.maxRunsPerHour != null ? String(initialValues.maxRunsPerHour) : "" ); @@ -228,13 +230,13 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au if (eventType) values.eventType = eventType; // Always send triggerConfig so clearing all conditions persists (PUT skips - // trigger_config when triggerConfig is omitted). - values.triggerConfig = { conditions }; + // trigger_config when triggerConfig is omitted). For slack_event the + // reply-in-thread flag rides inside it (source-interpreted, like conditions). + values.triggerConfig = isSlack ? { conditions, replyInThread } : { conditions }; if (triggerType === "sentry" && mode === "create" && sentryClientSecret.trim()) { values.sentryClientSecret = sentryClientSecret.trim(); } if (isSlack) { - values.replyInThread = replyInThread; const parsed = Number.parseInt(maxRunsPerHour, 10); values.maxRunsPerHour = maxRunsPerHour.trim() && parsed > 0 ? parsed : null; } diff --git a/packages/web/src/lib/automation-templates.ts b/packages/web/src/lib/automation-templates.ts index ab645c834..e48ee3337 100644 --- a/packages/web/src/lib/automation-templates.ts +++ b/packages/web/src/lib/automation-templates.ts @@ -255,8 +255,8 @@ export const automationTemplates: AutomationTemplate[] = [ value: { pattern: "\\b(bug|broken|error|failing|down|incident)\\b", flags: "i" }, }, ], + replyInThread: true, }, - replyInThread: true, instructions: "A message was posted in a watched Slack channel (the message is shown above). Treat it as a " + "bug or incident report and triage it against this repository.\n\n" + From 33c640f727647d30564d9ab90dca1afcb518d919 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 24 Jun 2026 19:44:44 -0700 Subject: [PATCH 07/19] refactor(slack): drop CacheSlot abstraction, KV-back watched channels Revert getRoutingRules to its main form (inline routingRulesLocalCache + getRoutingRulesFromCache) and remove the generic CacheSlot/fetchCachedList engine this PR introduced. Implement getWatchedChannels as a KV-backed read-through with no in-memory tier: serve the KV last-known-good set, refresh from the control plane on a miss, write KV, and fail closed to an empty set. KV absorbs the per-message hot path. bot-identity's cache is left unchanged. --- .../slack-bot/src/classifier/repos.test.ts | 25 +- packages/slack-bot/src/classifier/repos.ts | 219 +++++++++--------- 2 files changed, 129 insertions(+), 115 deletions(-) diff --git a/packages/slack-bot/src/classifier/repos.test.ts b/packages/slack-bot/src/classifier/repos.test.ts index 6339c7e5e..5e61415ac 100644 --- a/packages/slack-bot/src/classifier/repos.test.ts +++ b/packages/slack-bot/src/classifier/repos.test.ts @@ -236,7 +236,7 @@ describe("getWatchedChannels", () => { expect(await getWatchedChannels(env)).toEqual(new Set()); }); - it("serves the KV last-known-good set on a control-plane failure", async () => { + it("serves the watch-list from the KV cache without hitting the control plane", async () => { const env = { SLACK_KV: { get: vi.fn().mockResolvedValue(["C7", "C8"]), @@ -249,14 +249,29 @@ describe("getWatchedChannels", () => { expect(await getWatchedChannels(env, "trace")).toEqual(new Set(["C7", "C8"])); expect(env.SLACK_KV.get).toHaveBeenCalledWith("slack:watched-channels", "json"); + // KV is the cache: a hit short-circuits before the control plane is consulted. + expect(env.CONTROL_PLANE.fetch).not.toHaveBeenCalled(); }); - it("uses the in-memory cache after a successful fetch", async () => { - const env = makeEnv(jsonResponse({ channels: ["C1"] })); + it("reads through the KV cache on a subsequent call (no in-memory tier)", async () => { + let stored: unknown = null; + const env = { + SLACK_KV: { + get: vi.fn().mockImplementation(async () => stored), + put: vi.fn().mockImplementation(async (_key: string, value: string) => { + stored = JSON.parse(value); + }), + }, + CONTROL_PLANE: { + fetch: vi.fn().mockResolvedValue(jsonResponse({ channels: ["C1"] })), + }, + } as unknown as Env; - await getWatchedChannels(env); - await getWatchedChannels(env); + expect(await getWatchedChannels(env)).toEqual(new Set(["C1"])); + expect(await getWatchedChannels(env)).toEqual(new Set(["C1"])); + // First call misses KV and hits the control plane; the second is served from KV. expect(env.CONTROL_PLANE.fetch).toHaveBeenCalledTimes(1); + expect(env.SLACK_KV.get).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/slack-bot/src/classifier/repos.ts b/packages/slack-bot/src/classifier/repos.ts index ae3c1a70d..760defd80 100644 --- a/packages/slack-bot/src/classifier/repos.ts +++ b/packages/slack-bot/src/classifier/repos.ts @@ -33,8 +33,8 @@ const FALLBACK_REPOS: RepoConfig[] = []; const LOCAL_CACHE_TTL_MS = 60 * 1000; /** - * Expiration for the shared KV caches (repos + routing rules), in seconds — - * the unit Cloudflare KV's `expirationTtl` expects. + * Expiration for the shared KV caches (repos, routing rules, watched channels), + * in seconds — the unit Cloudflare KV's `expirationTtl` expects. */ const KV_CACHE_TTL_SECONDS = 300; @@ -46,6 +46,15 @@ let localCache: { timestamp: number; } | null = null; +/** + * Local in-memory cache for Slack routing rules. Same TTL as the repos cache; + * the bot tolerates rules being up to a few minutes stale. + */ +let routingRulesLocalCache: { + rules: SlackRoutingRule[]; + timestamp: number; +} | null = null; + const ROUTING_RULES_CACHE_KEY = "slack:routing-rules"; const WATCHED_CHANNELS_CACHE_KEY = "slack:watched-channels"; @@ -190,160 +199,151 @@ async function getFromCacheOrFallback(env: Env): Promise { return FALLBACK_REPOS; } -// ─── Cached control-plane list fetch (shared engine) ───────────────────────── - -/** A mutable in-memory cache slot for one list resource. */ -interface CacheSlot { - read(): { value: T; timestamp: number } | null; - write(value: T): void; - clear(): void; -} - -function makeCacheSlot(): CacheSlot { - let slot: { value: T; timestamp: number } | null = null; - return { - read: () => slot, - write: (value) => { - slot = { value, timestamp: Date.now() }; - }, - clear: () => { - slot = null; - }, - }; -} - /** - * A control-plane list resource fetched through the two-tier cache: in-memory - * (LOCAL_CACHE_TTL_MS) → control plane → KV last-known-good. On a fetch failure - * it returns whatever `fromCache` yields from KV (empty on a cold miss), so each - * resource decides via that whether the failure mode is fail-open or fail-closed. + * Fetch workspace-wide Slack routing rules (keyword → repository) from the + * control plane's GET /integration-settings/slack endpoint. + * + * Mirrors {@link getAvailableRepos}: in-memory cache → control plane → KV cache, + * and **fails open to an empty list** so a settings-fetch problem never blocks + * classification — the bot simply behaves as if no rules were configured. */ -interface CachedListResource { - path: string; - kvKey: string; - /** Log event name for fetch failures. */ - logEvent: string; - /** `key_prefix` used in KV error logs. */ - kvKeyPrefix: string; - slot: CacheSlot; - /** Extract the list from a fresh control-plane JSON response. */ - fromResponse: (json: unknown) => TItem[]; - /** Extract the list from a KV-cached JSON value (array or otherwise). */ - fromCache: (cached: unknown) => TItem[]; -} - -async function fetchCachedList( - env: Env, - traceId: string | undefined, - resource: CachedListResource -): Promise { - const cached = resource.slot.read(); - if (cached && Date.now() - cached.timestamp < LOCAL_CACHE_TTL_MS) { - return cached.value; +export async function getRoutingRules(env: Env, traceId?: string): Promise { + if ( + routingRulesLocalCache && + Date.now() - routingRulesLocalCache.timestamp < LOCAL_CACHE_TTL_MS + ) { + return routingRulesLocalCache.rules; } const startTime = Date.now(); try { - const response = await controlPlaneFetch(env, resource.path, traceId); + const response = await controlPlaneFetch(env, "/integration-settings/slack", traceId); if (!response.ok) { - log.warn(resource.logEvent, { + log.warn("control_plane.fetch_routing_rules", { trace_id: traceId, outcome: "error", http_status: response.status, duration_ms: Date.now() - startTime, }); - return readListFromKv(env, resource); + return getRoutingRulesFromCache(env); } - const items = resource.fromResponse(await response.json()); - resource.slot.write(items); + const data = (await response.json()) as { settings?: SlackGlobalConfig | null }; + const rules = normalizeRoutingRules(data.settings?.defaults?.routingRules); + + routingRulesLocalCache = { rules, timestamp: Date.now() }; try { - await createKvCacheStore(env.SLACK_KV).put(resource.kvKey, JSON.stringify(items), { + await createKvCacheStore(env.SLACK_KV).put(ROUTING_RULES_CACHE_KEY, JSON.stringify(rules), { expirationTtl: KV_CACHE_TTL_SECONDS, }); } catch (e) { log.warn("kv.put", { trace_id: traceId, - key_prefix: resource.kvKeyPrefix, + key_prefix: "routing_rules_cache", error: e instanceof Error ? e : new Error(String(e)), }); } - return items; + return rules; } catch (e) { - log.warn(resource.logEvent, { + log.warn("control_plane.fetch_routing_rules", { trace_id: traceId, outcome: "error", error: e instanceof Error ? e : new Error(String(e)), duration_ms: Date.now() - startTime, }); - return readListFromKv(env, resource); + return getRoutingRulesFromCache(env); } } -async function readListFromKv( - env: Env, - resource: CachedListResource -): Promise { +/** + * Read routing rules from the KV cache, returning an empty list on miss/error. + * Fail open: no rules means no deterministic routing, the safe default. + */ +async function getRoutingRulesFromCache(env: Env): Promise { try { - const cached = await createKvCacheStore(env.SLACK_KV).get(resource.kvKey, "json"); - return resource.fromCache(cached); + const cached = await createKvCacheStore(env.SLACK_KV).get(ROUTING_RULES_CACHE_KEY, "json"); + if (cached && Array.isArray(cached)) { + // Normalize on read so the KV-fallback path returns the same canonical + // shape as the fresh control-plane path (one uniform contract). + return normalizeRoutingRules(cached as SlackRoutingRule[]); + } } catch (e) { log.warn("kv.get", { - key_prefix: resource.kvKeyPrefix, + key_prefix: "routing_rules_cache", error: e instanceof Error ? e : new Error(String(e)), }); - return []; } + return []; } /** - * Workspace-wide Slack routing rules (keyword → repository). **Fails open** to - * an empty list so a settings-fetch problem never blocks classification — the - * bot behaves as if no rules were configured. Rules are normalized on both the - * fresh and KV-fallback paths so callers see one canonical shape. - */ -const routingRulesResource: CachedListResource = { - path: "/integration-settings/slack", - kvKey: ROUTING_RULES_CACHE_KEY, - logEvent: "control_plane.fetch_routing_rules", - kvKeyPrefix: "routing_rules_cache", - slot: makeCacheSlot(), - fromResponse: (json) => - normalizeRoutingRules( - (json as { settings?: SlackGlobalConfig | null }).settings?.defaults?.routingRules - ), - fromCache: (cached) => - Array.isArray(cached) ? normalizeRoutingRules(cached as SlackRoutingRule[]) : [], -}; - -/** - * Channel IDs watched by enabled `slack_event` automations. **Fails closed** to - * an empty set: an unknown watch-list means the bot forwards no channel + * Channel IDs watched by enabled `slack_event` automations, used to pre-filter + * inbound channel messages. KV-backed with no in-memory tier: served from the + * KV last-known-good copy and refreshed from the control plane on a miss. + * **Fails closed** to an empty set — an unknown watch-list forwards no channel * messages, so an outage pauses triggers rather than forwarding every message. - * The last-known-good KV copy bridges short outages. */ -const watchedChannelsResource: CachedListResource = { - path: "/integration-settings/slack/watched-channels", - kvKey: WATCHED_CHANNELS_CACHE_KEY, - logEvent: "control_plane.fetch_watched_channels", - kvKeyPrefix: "watched_channels_cache", - slot: makeCacheSlot(), - fromResponse: (json) => { - const channels = (json as { channels?: string[] }).channels; - return Array.isArray(channels) ? channels : []; - }, - fromCache: (cached) => (Array.isArray(cached) ? (cached as string[]) : []), -}; +export async function getWatchedChannels(env: Env, traceId?: string): Promise> { + const kv = createKvCacheStore(env.SLACK_KV); -export async function getRoutingRules(env: Env, traceId?: string): Promise { - return fetchCachedList(env, traceId, routingRulesResource); -} + try { + const cached = await kv.get(WATCHED_CHANNELS_CACHE_KEY, "json"); + if (Array.isArray(cached)) { + return new Set(cached as string[]); + } + } catch (e) { + log.warn("kv.get", { + key_prefix: "watched_channels_cache", + error: e instanceof Error ? e : new Error(String(e)), + }); + } -export async function getWatchedChannels(env: Env, traceId?: string): Promise> { - return new Set(await fetchCachedList(env, traceId, watchedChannelsResource)); + const startTime = Date.now(); + try { + const response = await controlPlaneFetch( + env, + "/integration-settings/slack/watched-channels", + traceId + ); + + if (!response.ok) { + log.warn("control_plane.fetch_watched_channels", { + trace_id: traceId, + outcome: "error", + http_status: response.status, + duration_ms: Date.now() - startTime, + }); + return new Set(); + } + + const data = (await response.json()) as { channels?: string[] }; + const channels = Array.isArray(data.channels) ? data.channels : []; + + try { + await kv.put(WATCHED_CHANNELS_CACHE_KEY, JSON.stringify(channels), { + expirationTtl: KV_CACHE_TTL_SECONDS, + }); + } catch (e) { + log.warn("kv.put", { + trace_id: traceId, + key_prefix: "watched_channels_cache", + error: e instanceof Error ? e : new Error(String(e)), + }); + } + + return new Set(channels); + } catch (e) { + log.warn("control_plane.fetch_watched_channels", { + trace_id: traceId, + outcome: "error", + error: e instanceof Error ? e : new Error(String(e)), + duration_ms: Date.now() - startTime, + }); + return new Set(); + } } /** @@ -424,6 +424,5 @@ export async function buildRepoDescriptions(env: Env, traceId?: string): Promise */ export function clearLocalCache(): void { localCache = null; - routingRulesResource.slot.clear(); - watchedChannelsResource.slot.clear(); + routingRulesLocalCache = null; } From 3459eb13c5f62496af3003a0069bf78b58d07654 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 24 Jun 2026 20:18:57 -0700 Subject: [PATCH 08/19] fix(slack): harden trigger-config validation before persistence Address PR review on slack_event automations: - Validate slack condition value shapes at the handler level (untrusted JSON is type-asserted, not checked): a string slack_channel value passed the type's .length check and was then iterated character-by- character into the watched-channel index. slack_channel/slack_actor now require a non-empty string array; text_match requires an object value with a string pattern (a null/primitive value previously threw a 500). - Reject a non-boolean replyInThread on write and coerce any non-boolean to the default on read, so the scheduler -> bot completion payload is always the boolean the callback validator requires. - Reject clearing triggerConfig to null on slack_event updates, which would leave the automation enabled but untriggerable. - Mark setSlackChannels @internal (test-only); production writes batch bindChannelStatements with the automation row. - Web form validates maxRunsPerHour with Number/Number.isInteger instead of parseInt, so 1.5 is rejected rather than truncated to 1. --- .../control-plane/src/db/automation-store.ts | 6 +- .../src/db/slack-channel-store.ts | 10 +++- .../control-plane/src/routes/automations.ts | 35 +++++++++--- .../automations-slack-route.test.ts | 55 +++++++++++++++++++ .../src/triggers/slack/conditions.test.ts | 50 +++++++++++++++++ .../shared/src/triggers/slack/conditions.ts | 32 +++++++++-- .../automations/automation-form.tsx | 17 +++++- 7 files changed, 188 insertions(+), 17 deletions(-) diff --git a/packages/control-plane/src/db/automation-store.ts b/packages/control-plane/src/db/automation-store.ts index 76f7beeb3..f5e461e07 100644 --- a/packages/control-plane/src/db/automation-store.ts +++ b/packages/control-plane/src/db/automation-store.ts @@ -71,10 +71,12 @@ export interface EnrichedRunRow extends AutomationRunRow { * The effective reply-in-thread setting from a parsed trigger_config (slack_event * only); defaults to true — matching the prior `reply_in_thread NOT NULL DEFAULT 1` * column. The single read-path accessor: the setting lives only inside - * trigger_config, with no parallel top-level field. + * trigger_config, with no parallel top-level field. Any non-boolean stored value + * resolves to the default, guaranteeing the boolean the scheduler → bot + * completion payload requires regardless of how the config was written. */ export function getReplyInThread(config: TriggerConfig | null): boolean { - return config?.replyInThread ?? true; + return typeof config?.replyInThread === "boolean" ? config.replyInThread : true; } export function toAutomation(row: AutomationRow): Automation { diff --git a/packages/control-plane/src/db/slack-channel-store.ts b/packages/control-plane/src/db/slack-channel-store.ts index 26fdff98e..9ede3d783 100644 --- a/packages/control-plane/src/db/slack-channel-store.ts +++ b/packages/control-plane/src/db/slack-channel-store.ts @@ -68,7 +68,15 @@ export class SlackChannelStore { return statements; } - /** Replace an automation's watched-channel set atomically. */ + /** + * Replace an automation's watched-channel set atomically. Test-support only — + * production writes compose `bindChannelStatements` into the same `db.batch` as + * the automation row so the index stays coupled to the canonical trigger_config. + * A standalone write here would let the two drift, so it is kept off the + * production path. + * + * @internal + */ async setSlackChannels(automationId: string, channelIds: string[]): Promise { await this.db.batch(this.bindChannelStatements(automationId, channelIds)); } diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts index 9d0c656a6..2834bad76 100644 --- a/packages/control-plane/src/routes/automations.ts +++ b/packages/control-plane/src/routes/automations.ts @@ -83,12 +83,15 @@ function extractSlackChannels(triggerConfig: TriggerConfig | null | undefined): } /** - * Slack triggers must be scoped: an explicit channel set + at least one - * `text_match`. This is net-new validation — the engine otherwise skips - * condition validation entirely when none are present. Returns an error message, - * or null when valid. + * Validate a slack_event trigger config before persistence. It must be scoped — + * an explicit channel set + at least one `text_match` (net-new validation; the + * engine otherwise skips condition validation entirely when none are present) — + * and `replyInThread`, when present, must be a boolean: it rides verbatim into + * the scheduler → bot completion payload, which the bot rejects if it is not a + * boolean (dropping the run's thread reply and leaving the 👀 reaction stuck). + * Returns an error message, or null when valid. */ -function validateSlackRequiredConditions( +function validateSlackTriggerConfig( triggerConfig: TriggerConfig | null | undefined ): string | null { // Guard the shape here too: this runs before the generic array-shape check in @@ -105,6 +108,10 @@ function validateSlackRequiredConditions( if (!conditions.some((c) => c.type === "text_match")) { return "slack_event triggers require at least one text_match condition"; } + const replyInThread = triggerConfig?.replyInThread as unknown; + if (replyInThread !== undefined && typeof replyInThread !== "boolean") { + return "triggerConfig.replyInThread must be a boolean"; + } return null; } @@ -234,7 +241,7 @@ async function handleCreateAutomation( // Slack triggers require explicit scoping (a channel set + at least one text_match). if (triggerType === "slack_event") { - const slackError = validateSlackRequiredConditions(body.triggerConfig); + const slackError = validateSlackTriggerConfig(body.triggerConfig); if (slackError) return error(slackError, 400); } @@ -495,9 +502,21 @@ async function handleUpdateAutomation( if (existing.trigger_type === "schedule") { return error("Cannot set triggerConfig on schedule automations", 400); } - if (body.triggerConfig !== null) { + if (body.triggerConfig === null) { + // A slack_event's trigger_config holds its required scoping (channel + + // text_match) and the watched-channel index is derived from it. Clearing + // it would leave the automation enabled but untriggerable, so reject null + // — pause or delete instead. (Other sources may clear conditions to a + // match-all, so null stays allowed for them.) + if (existing.trigger_type === "slack_event") { + return error( + "Cannot clear triggerConfig on slack_event automations; pause or delete instead", + 400 + ); + } + } else { if (existing.trigger_type === "slack_event") { - const slackError = validateSlackRequiredConditions(body.triggerConfig); + const slackError = validateSlackTriggerConfig(body.triggerConfig); if (slackError) return error(slackError, 400); } if (body.triggerConfig.conditions) { diff --git a/packages/control-plane/test/integration/automations-slack-route.test.ts b/packages/control-plane/test/integration/automations-slack-route.test.ts index 40b785f0d..6ce4af0e9 100644 --- a/packages/control-plane/test/integration/automations-slack-route.test.ts +++ b/packages/control-plane/test/integration/automations-slack-route.test.ts @@ -86,6 +86,37 @@ describe("POST /automations — slack_event validation (integration)", () => { expect(await res.text()).toContain("text_match"); }); + it("rejects a slack_channel value that is not an array of strings (400)", async () => { + const res = await postAutomation( + createBody({ + triggerConfig: { + conditions: [ + { type: "slack_channel", operator: "any_of", value: "C1" }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }, + }) + ); + expect(res.status).toBe(400); + expect(await res.text()).toContain("slack_channel"); + }); + + it("rejects a non-boolean replyInThread (400)", async () => { + const res = await postAutomation( + createBody({ + triggerConfig: { + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + replyInThread: "yes", + }, + }) + ); + expect(res.status).toBe(400); + expect(await res.text()).toContain("replyInThread"); + }); + it("rejects an invalid regex text_match at save time (400)", async () => { const res = await postAutomation( createBody({ @@ -264,6 +295,30 @@ describe("PUT /automations/:id — slack_event validation (integration)", () => expect(config.replyInThread).toBeUndefined(); // replaced, not merged expect(getReplyInThread(config)).toBe(true); // resolves to the default on read }); + + it("rejects clearing trigger_config to null on a slack_event automation (400)", async () => { + const store = new AutomationStore(env.DB); + const channels = new SlackChannelStore(env.DB); + const auto = makeSlackAutomation({ + trigger_config: JSON.stringify({ + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }), + }); + await store.create(auto); + await channels.setSlackChannels(auto.id, ["C1"]); + + const res = await putAutomation(auto.id, { triggerConfig: null }); + expect(res.status).toBe(400); + expect(await res.text()).toContain("Cannot clear triggerConfig"); + + // The rejected request left both the config and the derived index intact — + // an enabled-but-untriggerable state never materializes. + expect((await store.getById(auto.id))!.trigger_config).not.toBeNull(); + expect([...(await channels.getWatchedSlackChannels())]).toEqual(["C1"]); + }); }); describe("GET /integration-settings/slack/watched-channels (integration)", () => { diff --git a/packages/shared/src/triggers/slack/conditions.test.ts b/packages/shared/src/triggers/slack/conditions.test.ts index 82b9df65f..0f29e61bd 100644 --- a/packages/shared/src/triggers/slack/conditions.test.ts +++ b/packages/shared/src/triggers/slack/conditions.test.ts @@ -204,6 +204,56 @@ describe("validateConditions (slack)", () => { expect(errors.length).toBeGreaterThan(0); }); + it("rejects a slack_channel value that is a string, not an array", () => { + // A bare string passes the TS type's `.length` check but would be iterated + // character-by-character when the watched-channel index is built. + const errors = validateConditions( + [{ type: "slack_channel", operator: "any_of", value: "C123" } as unknown as TriggerCondition], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it("rejects a slack_channel array with a non-string element", () => { + const errors = validateConditions( + [ + { + type: "slack_channel", + operator: "any_of", + value: ["C1", 123], + } as unknown as TriggerCondition, + ], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it("rejects a text_match value that is not an object (no throw)", () => { + const errors = validateConditions( + [{ type: "text_match", operator: "contains", value: null } as unknown as TriggerCondition], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it("rejects a text_match pattern that is not a string", () => { + const errors = validateConditions( + [ + { + type: "text_match", + operator: "contains", + value: { pattern: 123 }, + } as unknown as TriggerCondition, + ], + "slack", + conditionRegistry + ); + expect(errors.length).toBeGreaterThan(0); + }); + it("reports a slack condition used on a github trigger", () => { const errors = validateConditions( [{ type: "slack_channel", operator: "any_of", value: ["C1"] }], diff --git a/packages/shared/src/triggers/slack/conditions.ts b/packages/shared/src/triggers/slack/conditions.ts index a0cb87d2b..a3c705e6f 100644 --- a/packages/shared/src/triggers/slack/conditions.ts +++ b/packages/shared/src/triggers/slack/conditions.ts @@ -26,6 +26,21 @@ function flagsAllowed(flags: string | undefined): boolean { return true; } +/** + * True when `value` is an array of one or more non-empty strings. The condition + * value arrives as untrusted JSON typed only by assertion, so the handlers below + * verify the runtime shape before it is persisted — a bare string would satisfy + * the TypeScript type's `.length` check and then be iterated character-by-character + * when the watched-channel index is built. + */ +function isNonEmptyStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((v) => typeof v === "string" && v !== "") + ); +} + /** * Slack condition handlers. `slack_actor` is a distinct slack-only handler — the * github/linear `actor` handler passes through for slack, so it cannot be reused. @@ -34,11 +49,18 @@ export const slackConditions = { text_match: { appliesTo: ["slack"] as const, validate(c: { operator: "contains" | "exact" | "regex"; value: TextMatchValue }) { - const { pattern, flags } = c.value; - if (!pattern) return "Text match pattern is required"; + const value = c.value as unknown; + if (typeof value !== "object" || value === null) { + return "text_match value must be an object with a pattern"; + } + const { pattern, flags } = value as { pattern?: unknown; flags?: unknown }; + if (typeof pattern !== "string" || pattern === "") return "Text match pattern is required"; if (pattern.length > REGEX_PATTERN_MAX_LENGTH) { return `Pattern exceeds the ${REGEX_PATTERN_MAX_LENGTH}-character limit`; } + if (flags !== undefined && typeof flags !== "string") { + return "text_match flags must be a string"; + } if (!flagsAllowed(flags)) return "Unsupported regex flag"; if (c.operator === "regex") { try { @@ -80,7 +102,9 @@ export const slackConditions = { slack_channel: { appliesTo: ["slack"] as const, validate(c: { value: string[] }) { - return c.value.length === 0 ? "At least one channel required" : null; + return isNonEmptyStringArray(c.value) + ? null + : "slack_channel requires at least one channel ID"; }, evaluate(c: { value: string[] }, event: AutomationEvent) { if (event.source !== "slack") return true; @@ -90,7 +114,7 @@ export const slackConditions = { slack_actor: { appliesTo: ["slack"] as const, validate(c: { value: string[] }) { - return c.value.length === 0 ? "At least one actor required" : null; + return isNonEmptyStringArray(c.value) ? null : "slack_actor requires at least one user ID"; }, evaluate(c: { operator: "include" | "exclude"; value: string[] }, event: AutomationEvent) { if (event.source !== "slack") return true; diff --git a/packages/web/src/components/automations/automation-form.tsx b/packages/web/src/components/automations/automation-form.tsx index f939d2173..4b2e35cdc 100644 --- a/packages/web/src/components/automations/automation-form.tsx +++ b/packages/web/src/components/automations/automation-form.tsx @@ -150,6 +150,12 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au !isSlack || (conditions.some((c) => c.type === "slack_channel") && conditions.some((c) => c.type === "text_match")); + // Mirror the server rule: blank uses the default, otherwise a positive integer. + // Use Number (not parseInt) so "1.5" is rejected rather than truncated to 1. + const trimmedMaxRuns = maxRunsPerHour.trim(); + const parsedMaxRuns = Number(trimmedMaxRuns); + const maxRunsValid = + !isSlack || trimmedMaxRuns === "" || (Number.isInteger(parsedMaxRuns) && parsedMaxRuns > 0); // The model we display and submit. The selector only lists enabled models, so // a disabled default (blank create), a disabled saved model (edit), or a @@ -202,6 +208,7 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au if (!name.trim() || !selectedRepo || !instructions.trim() || !isScheduleValid) return; if (triggerType === "sentry" && mode === "create" && !sentryClientSecret.trim()) return; if (!slackConditionsValid) return; + if (!maxRunsValid) return; if (showEventTypeSelector && !eventType) { setEventTypeError("Event type is required."); return; @@ -237,8 +244,8 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au values.sentryClientSecret = sentryClientSecret.trim(); } if (isSlack) { - const parsed = Number.parseInt(maxRunsPerHour, 10); - values.maxRunsPerHour = maxRunsPerHour.trim() && parsed > 0 ? parsed : null; + // Guarded by maxRunsValid above, so trimmedMaxRuns is "" or a positive integer. + values.maxRunsPerHour = trimmedMaxRuns ? parsedMaxRuns : null; } } @@ -572,6 +579,11 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au Caps how many runs this automation can start per hour. Extra matching messages are skipped. Leave blank to use the default ({DEFAULT_MAX_RUNS_PER_HOUR}). + {!maxRunsValid && ( +

+ Max runs per hour must be a positive whole number. +

+ )} )} @@ -631,6 +643,7 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au !instructions.trim() || !isScheduleValid || !slackConditionsValid || + !maxRunsValid || (showEventTypeSelector && !eventType) || (triggerType === "sentry" && mode === "create" && !sentryClientSecret.trim()) } From e839c83b4506741050580b768cfa3a7775a32dc2 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 24 Jun 2026 22:58:02 -0700 Subject: [PATCH 09/19] refactor(slack): drop automation-complete message and replyInThread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Automation completed/failed' thread reply was noise. The scheduler → bot completion callback now only clears the 👀 reaction on the triggering message; success/failure is observed in the web UI run history. With no message ever posted, the replyInThread setting controlled nothing, so it is removed end to end (TriggerConfig field, store accessor, route validation, scheduler payload, and the web form checkbox). SlackRunMetadata drops its now-dead threadTs. --- .../src/db/automation-store.test.ts | 20 +-- .../control-plane/src/db/automation-store.ts | 12 -- .../control-plane/src/routes/automations.ts | 16 +-- .../src/scheduler/durable-object.ts | 52 ++------ .../src/scheduler/slack-completion.test.ts | 79 +----------- .../src/scheduler/slack-completion.ts | 63 +++------- .../automations-slack-route.test.ts | 59 +-------- packages/shared/src/triggers/conditions.ts | 6 - packages/slack-bot/src/callbacks.test.ts | 56 +-------- packages/slack-bot/src/callbacks.ts | 115 +++--------------- .../automations/automation-form.test.tsx | 9 +- .../automations/automation-form.tsx | 21 +--- packages/web/src/lib/automation-templates.ts | 1 - 13 files changed, 67 insertions(+), 442 deletions(-) diff --git a/packages/control-plane/src/db/automation-store.test.ts b/packages/control-plane/src/db/automation-store.test.ts index 7a968bb13..affd95112 100644 --- a/packages/control-plane/src/db/automation-store.test.ts +++ b/packages/control-plane/src/db/automation-store.test.ts @@ -10,7 +10,6 @@ import { describe, it, expect, vi } from "vitest"; import { AutomationStore, isDuplicateKeyError, - getReplyInThread, toAutomation, toAutomationRun, type AutomationRow, @@ -145,32 +144,17 @@ describe("toAutomation", () => { expect(automation.enabled).toBe(false); }); - it("defaults the slack knobs (null cap, reply-in-thread on) when unset", () => { + it("defaults maxRunsPerHour to null when unset", () => { const automation = toAutomation(sampleRow); expect(automation.maxRunsPerHour).toBeNull(); - expect(getReplyInThread(automation.triggerConfig)).toBe(true); }); - it("maps an explicit cap and reads reply-in-thread from trigger_config", () => { + it("maps an explicit maxRunsPerHour cap", () => { const automation = toAutomation({ ...sampleRow, max_runs_per_hour: 5, - trigger_config: JSON.stringify({ conditions: [], replyInThread: false }), }); expect(automation.maxRunsPerHour).toBe(5); - expect(getReplyInThread(automation.triggerConfig)).toBe(false); - }); -}); - -describe("getReplyInThread", () => { - it("defaults to true for a null config or absent flag", () => { - expect(getReplyInThread(null)).toBe(true); - expect(getReplyInThread({ conditions: [] })).toBe(true); - }); - - it("respects an explicit flag", () => { - expect(getReplyInThread({ conditions: [], replyInThread: false })).toBe(false); - expect(getReplyInThread({ conditions: [], replyInThread: true })).toBe(true); }); }); diff --git a/packages/control-plane/src/db/automation-store.ts b/packages/control-plane/src/db/automation-store.ts index f5e461e07..79834d6d4 100644 --- a/packages/control-plane/src/db/automation-store.ts +++ b/packages/control-plane/src/db/automation-store.ts @@ -67,18 +67,6 @@ export interface EnrichedRunRow extends AutomationRunRow { // ─── Mappers ───────────────────────────────────────────────────────────────── -/** - * The effective reply-in-thread setting from a parsed trigger_config (slack_event - * only); defaults to true — matching the prior `reply_in_thread NOT NULL DEFAULT 1` - * column. The single read-path accessor: the setting lives only inside - * trigger_config, with no parallel top-level field. Any non-boolean stored value - * resolves to the default, guaranteeing the boolean the scheduler → bot - * completion payload requires regardless of how the config was written. - */ -export function getReplyInThread(config: TriggerConfig | null): boolean { - return typeof config?.replyInThread === "boolean" ? config.replyInThread : true; -} - export function toAutomation(row: AutomationRow): Automation { const triggerConfig: TriggerConfig | null = row.trigger_config ? JSON.parse(row.trigger_config) diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts index 2834bad76..bba642cac 100644 --- a/packages/control-plane/src/routes/automations.ts +++ b/packages/control-plane/src/routes/automations.ts @@ -85,10 +85,7 @@ function extractSlackChannels(triggerConfig: TriggerConfig | null | undefined): /** * Validate a slack_event trigger config before persistence. It must be scoped — * an explicit channel set + at least one `text_match` (net-new validation; the - * engine otherwise skips condition validation entirely when none are present) — - * and `replyInThread`, when present, must be a boolean: it rides verbatim into - * the scheduler → bot completion payload, which the bot rejects if it is not a - * boolean (dropping the run's thread reply and leaving the 👀 reaction stuck). + * engine otherwise skips condition validation entirely when none are present). * Returns an error message, or null when valid. */ function validateSlackTriggerConfig( @@ -108,10 +105,6 @@ function validateSlackTriggerConfig( if (!conditions.some((c) => c.type === "text_match")) { return "slack_event triggers require at least one text_match condition"; } - const replyInThread = triggerConfig?.replyInThread as unknown; - if (replyInThread !== undefined && typeof replyInThread !== "boolean") { - return "triggerConfig.replyInThread must be a boolean"; - } return null; } @@ -544,10 +537,9 @@ async function handleUpdateAutomation( updateFields.max_runs_per_hour = body.maxRunsPerHour; } - // trigger_config is a single source-interpreted JSON blob — slack_event's - // replyInThread rides inside it alongside conditions, so a PUT replaces it - // wholesale (null clears it). The caller owns the full blob; the web form - // always re-submits replyInThread within triggerConfig. + // trigger_config is a single source-interpreted JSON blob (the conditions), + // so a PUT replaces it wholesale (null clears it). The caller owns the full + // blob; the web form always re-submits the conditions within triggerConfig. if (body.triggerConfig === null) { updateFields.trigger_config = null; } else if (body.triggerConfig !== undefined) { diff --git a/packages/control-plane/src/scheduler/durable-object.ts b/packages/control-plane/src/scheduler/durable-object.ts index 2ca86c53b..105c14aac 100644 --- a/packages/control-plane/src/scheduler/durable-object.ts +++ b/packages/control-plane/src/scheduler/durable-object.ts @@ -24,7 +24,6 @@ import { AutomationStore, toAutomationRun, isDuplicateKeyError, - getReplyInThread, type AutomationRow, type AutomationRunRow, } from "../db/automation-store"; @@ -698,18 +697,13 @@ export class SchedulerDO extends DurableObject { }); } - // Slack-triggered runs deliver their result back into the originating - // thread. The scheduler owns this fan-out (not the session callback path) - // because the thread coordinates live on the run row. Best-effort. + // Slack-triggered runs clear the `eyes` reaction from their triggering + // message when they finish. The scheduler owns this fan-out (not the session + // callback path) because the message coordinates live on the run row. + // Best-effort; success/failure is surfaced in the web UI, not in Slack. const slackMeta = getSlackRunMetadata(run); if (slackMeta) { - await this.notifySlackCompletion( - store, - run, - slackMeta, - body.success, - body.success ? undefined : body.error - ); + await this.notifySlackCompletion(run, slackMeta); } return new Response(JSON.stringify({ ok: true }), { @@ -739,42 +733,21 @@ export class SchedulerDO extends DurableObject { } /** - * Post a Slack-triggered run's result into its originating thread by calling - * the slack-bot's `/callbacks/automation-complete` endpoint. Signs the body - * with `INTERNAL_CALLBACK_SECRET` (in-body HMAC, matching the bot's other - * callbacks). No-ops when the run has no thread anchor, when `SLACK_BOT` is - * unbound, or when the secret is unset — all best-effort. + * Tell the slack-bot to clear the `eyes` reaction from a slack-triggered run's + * triggering message by calling its `/callbacks/automation-complete` endpoint. + * Signs the body with `INTERNAL_CALLBACK_SECRET` (in-body HMAC, matching the + * bot's other callbacks). No-ops when the run has no triggering message, when + * `SLACK_BOT` is unbound, or when the secret is unset — all best-effort. */ private async notifySlackCompletion( - store: AutomationStore, run: AutomationRunRow, - meta: SlackRunMetadata, - success: boolean, - error?: string + meta: SlackRunMetadata ): Promise { const binding = this.env.SLACK_BOT; const secret = this.env.INTERNAL_CALLBACK_SECRET; if (!binding || !secret) return; - const automation = await store.getById(run.automation_id); - let triggerConfig: TriggerConfig | null = null; - if (automation?.trigger_config) { - try { - triggerConfig = JSON.parse(automation.trigger_config) as TriggerConfig; - } catch { - triggerConfig = null; - } - } - const body = buildSlackCompletionNotification({ - meta, - sessionId: run.session_id, - automationName: automation?.name ?? "Automation", - success, - error, - // Honor the stored reply-in-thread setting (in trigger_config; defaults - // true). The bot skips the thread post when false but still clears the 👀. - replyInThread: getReplyInThread(triggerConfig), - }); + const body = buildSlackCompletionNotification(meta); if (!body) return; try { @@ -962,7 +935,6 @@ function slackRunMetadata( ): Pick { const metadata: SlackRunMetadata = { channel: event.channelId, - threadTs: event.threadTs ?? undefined, messageTs: event.ts, }; return { trigger_run_metadata: JSON.stringify(metadata) }; diff --git a/packages/control-plane/src/scheduler/slack-completion.test.ts b/packages/control-plane/src/scheduler/slack-completion.test.ts index 5973ab71c..ef8f2f46b 100644 --- a/packages/control-plane/src/scheduler/slack-completion.test.ts +++ b/packages/control-plane/src/scheduler/slack-completion.test.ts @@ -15,87 +15,18 @@ function meta(overrides?: Partial): SlackRunMetadata { describe("buildSlackCompletionNotification", () => { it("returns null for a non-slack run (no metadata)", () => { - expect( - buildSlackCompletionNotification({ - meta: null, - sessionId: "sess-1", - automationName: "Triage", - success: true, - replyInThread: true, - }) - ).toBeNull(); + expect(buildSlackCompletionNotification(null)).toBeNull(); }); - it("returns null when the metadata has no usable thread anchor", () => { - expect( - buildSlackCompletionNotification({ - meta: meta({ messageTs: "" }), - sessionId: "sess-1", - automationName: "Triage", - success: true, - replyInThread: true, - }) - ).toBeNull(); + it("returns null when there is no triggering message to clear", () => { + expect(buildSlackCompletionNotification(meta({ messageTs: "" }))).toBeNull(); }); - it("anchors to the thread ts when present", () => { - const n = buildSlackCompletionNotification({ - meta: meta({ threadTs: "1699999999.000001" }), - sessionId: "sess-1", - automationName: "Triage", - success: true, - replyInThread: true, - }); - expect(n).toMatchObject({ + it("targets the triggering message's eyes reaction", () => { + expect(buildSlackCompletionNotification(meta())).toEqual({ channel: "C1", - threadTs: "1699999999.000001", reactionMessageTs: "1700000000.000100", - sessionId: "sess-1", - success: true, - automationName: "Triage", - replyInThread: true, - }); - expect(n?.summary).toBeUndefined(); - }); - - it("falls back to the message ts as the thread anchor", () => { - const n = buildSlackCompletionNotification({ - meta: meta(), - sessionId: "sess-1", - automationName: "Triage", - success: true, - replyInThread: true, - }); - expect(n?.threadTs).toBe("1700000000.000100"); - }); - - it("includes a truncated error summary on failure", () => { - const longError = "x".repeat(5000); - const n = buildSlackCompletionNotification({ - meta: meta(), - sessionId: "sess-1", - automationName: "Triage", - success: false, - error: longError, - replyInThread: true, - }); - expect(n?.success).toBe(false); - expect(n?.summary?.length).toBe(1500); - }); - - it("threads replyInThread=false through so the bot suppresses the thread post", () => { - const n = buildSlackCompletionNotification({ - meta: meta(), - sessionId: "sess-1", - automationName: "Triage", - success: true, - replyInThread: false, }); - // Still produced (the bot needs it to clear the eyes reaction), but flagged - // so the bot posts no message. - expect(n).not.toBeNull(); - expect(n?.replyInThread).toBe(false); - expect(n?.reactionMessageTs).toBe("1700000000.000100"); }); }); diff --git a/packages/control-plane/src/scheduler/slack-completion.ts b/packages/control-plane/src/scheduler/slack-completion.ts index 78de45c76..2a9749787 100644 --- a/packages/control-plane/src/scheduler/slack-completion.ts +++ b/packages/control-plane/src/scheduler/slack-completion.ts @@ -5,21 +5,19 @@ * body) and POSTs it via the optional `SLACK_BOT` Fetcher. * * Returning `null` from either builder is the explicit signal to skip the bot - * call — for a non-slack run, a slack run with no thread anchor, or a skip with - * no actor to address. + * call — for a non-slack run, a slack run with no triggering message, or a skip + * with no actor to address. */ import type { AutomationRunRow } from "../db/automation-store"; /** * Slack run coordinates captured at trigger time, serialized into the run's - * generic `trigger_run_metadata` column (slack-origin runs only). `threadTs` is - * the reply target (falls back to `messageTs`); `messageTs` is the triggering - * message, used to clear the `eyes` reaction on completion. + * generic `trigger_run_metadata` column (slack-origin runs only). `messageTs` is + * the triggering message, used to clear the `eyes` reaction on completion. */ export interface SlackRunMetadata { channel: string; - threadTs?: string; messageTs: string; } @@ -39,50 +37,23 @@ export function getSlackRunMetadata( } } -/** Max characters of an error surfaced inline; the full transcript is one click away. */ -const SUMMARY_MAX_LENGTH = 1500; - +/** + * The scheduler → bot payload for a completed slack-triggered run. Its only job + * is to clear the `eyes` reaction the bot added to the triggering message when + * the run started; the run's success/failure is surfaced in the web UI, not in + * Slack. + */ export interface SlackCompletionNotification { channel: string; - threadTs: string; - /** Message to clear the `eyes` reaction from (the triggering message). */ - reactionMessageTs?: string; - sessionId: string | null; - success: boolean; - /** Short failure summary; omitted on success (the bot renders a generic ✅). */ - summary?: string; - automationName: string; - /** - * The automation's reply-in-thread setting (stored in trigger_config). When - * false, the bot still clears the `eyes` reaction but posts no completion - * message into the thread. - */ - replyInThread: boolean; + /** The triggering message to clear the `eyes` reaction from. */ + reactionMessageTs: string; } -export function buildSlackCompletionNotification(params: { - meta: SlackRunMetadata | null; - sessionId: string | null; - automationName: string; - success: boolean; - error?: string; - replyInThread: boolean; -}): SlackCompletionNotification | null { - const { meta } = params; - if (!meta) return null; - const threadTs = meta.threadTs ?? meta.messageTs; - if (!threadTs) return null; - - return { - channel: meta.channel, - threadTs, - reactionMessageTs: meta.messageTs, - sessionId: params.sessionId, - success: params.success, - summary: params.error ? params.error.slice(0, SUMMARY_MAX_LENGTH) : undefined, - automationName: params.automationName, - replyInThread: params.replyInThread, - }; +export function buildSlackCompletionNotification( + meta: SlackRunMetadata | null +): SlackCompletionNotification | null { + if (!meta?.messageTs) return null; + return { channel: meta.channel, reactionMessageTs: meta.messageTs }; } export interface SlackSkipNotification { diff --git a/packages/control-plane/test/integration/automations-slack-route.test.ts b/packages/control-plane/test/integration/automations-slack-route.test.ts index 6ce4af0e9..60e7de0c9 100644 --- a/packages/control-plane/test/integration/automations-slack-route.test.ts +++ b/packages/control-plane/test/integration/automations-slack-route.test.ts @@ -1,11 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import { SELF, env } from "cloudflare:test"; -import { - AutomationStore, - getReplyInThread, - toAutomation, - type AutomationRow, -} from "../../src/db/automation-store"; +import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; import { SlackChannelStore } from "../../src/db/slack-channel-store"; import { generateInternalToken } from "../../src/auth/internal"; import { cleanD1Tables } from "./cleanup"; @@ -101,22 +96,6 @@ describe("POST /automations — slack_event validation (integration)", () => { expect(await res.text()).toContain("slack_channel"); }); - it("rejects a non-boolean replyInThread (400)", async () => { - const res = await postAutomation( - createBody({ - triggerConfig: { - conditions: [ - { type: "slack_channel", operator: "any_of", value: ["C1"] }, - { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, - ], - replyInThread: "yes", - }, - }) - ); - expect(res.status).toBe(400); - expect(await res.text()).toContain("replyInThread"); - }); - it("rejects an invalid regex text_match at save time (400)", async () => { const res = await postAutomation( createBody({ @@ -241,46 +220,19 @@ describe("PUT /automations/:id — slack_event validation (integration)", () => expect([...watched].sort()).toEqual(["C2", "C3"]); }); - it("persists replyInThread inside trigger_config on update", async () => { + it("replaces trigger_config wholesale on update", async () => { const store = new AutomationStore(env.DB); const auto = makeSlackAutomation({ trigger_config: JSON.stringify({ - conditions: [{ type: "slack_channel", operator: "any_of", value: ["C1"] }], - }), - }); - await store.create(auto); - - const res = await putAutomation(auto.id, { - triggerConfig: { conditions: [ { type: "slack_channel", operator: "any_of", value: ["C1"] }, - { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + { type: "text_match", operator: "contains", value: { pattern: "old" } }, ], - replyInThread: false, - }, - }); - expect(res.status).toBe(200); - - const row = await store.getById(auto.id); - const config = JSON.parse(row!.trigger_config!) as TriggerConfig; - expect(config.replyInThread).toBe(false); - expect(config.conditions.some((c) => c.type === "slack_channel")).toBe(true); - // The read-path accessor surfaces the stored flag (no top-level field). - expect(getReplyInThread(toAutomation(row!).triggerConfig)).toBe(false); - }); - - it("replaces trigger_config wholesale on update (replyInThread is not auto-merged)", async () => { - const store = new AutomationStore(env.DB); - const auto = makeSlackAutomation({ - trigger_config: JSON.stringify({ - conditions: [{ type: "slack_channel", operator: "any_of", value: ["C1"] }], - replyInThread: false, }), }); await store.create(auto); - // A PUT replaces the whole blob; omitting replyInThread drops it. The client - // owns the full trigger_config (the web form always re-submits the flag). + // A PUT replaces the whole blob. The client owns the full trigger_config. const res = await putAutomation(auto.id, { triggerConfig: { conditions: [ @@ -292,8 +244,7 @@ describe("PUT /automations/:id — slack_event validation (integration)", () => expect(res.status).toBe(200); const config = JSON.parse((await store.getById(auto.id))!.trigger_config!) as TriggerConfig; - expect(config.replyInThread).toBeUndefined(); // replaced, not merged - expect(getReplyInThread(config)).toBe(true); // resolves to the default on read + expect(config.conditions.find((c) => c.type === "slack_channel")?.value).toEqual(["C9"]); }); it("rejects clearing trigger_config to null on a slack_event automation (400)", async () => { diff --git a/packages/shared/src/triggers/conditions.ts b/packages/shared/src/triggers/conditions.ts index c597e0595..9c7067e1a 100644 --- a/packages/shared/src/triggers/conditions.ts +++ b/packages/shared/src/triggers/conditions.ts @@ -105,10 +105,4 @@ export function validateConditions( export interface TriggerConfig { conditions: TriggerCondition[]; - /** - * slack_event only: post the run result back into the originating thread. - * Like `conditions`, this blob is source-specific and interpreted via the - * automation's `trigger_type`; absent for other sources. Defaults to true. - */ - replyInThread?: boolean; } diff --git a/packages/slack-bot/src/callbacks.test.ts b/packages/slack-bot/src/callbacks.test.ts index 0e020d729..51c64a451 100644 --- a/packages/slack-bot/src/callbacks.test.ts +++ b/packages/slack-bot/src/callbacks.test.ts @@ -287,11 +287,7 @@ describe("POST /callbacks/automation-complete", () => { function completeData(overrides: Record = {}) { return { channel: "C123", - threadTs: "111.222", reactionMessageTs: "111.222", - sessionId: "sess-1", - success: true, - automationName: "Auto-triage", ...overrides, }; } @@ -311,7 +307,7 @@ describe("POST /callbacks/automation-complete", () => { expect(ctx.waitUntil).not.toHaveBeenCalled(); }); - it("posts a success result to the thread and clears the eyes reaction", async () => { + it("clears the eyes reaction and posts no thread message", async () => { const fetchMock = okFetchMock(); const payload = await signPayload(completeData()); const { response, ctx } = await postCallback("/callbacks/automation-complete", payload); @@ -319,61 +315,13 @@ describe("POST /callbacks/automation-complete", () => { expect(response.status).toBe(200); await flushWaitUntil(ctx); - const post = slackCall(fetchMock, "chat.postMessage"); - expect(post).toBeDefined(); - expect(post!.body).toMatchObject({ channel: "C123", thread_ts: "111.222" }); - expect(JSON.stringify(post!.body.blocks)).toContain("Auto-triage"); - expect(JSON.stringify(post!.body.blocks)).toContain("app.test/session/sess-1"); - - const reaction = slackCall(fetchMock, "reactions.remove"); - expect(reaction).toBeDefined(); - expect(reaction!.body).toMatchObject({ channel: "C123", timestamp: "111.222", name: "eyes" }); - }); - - it("renders a failure summary when the run failed", async () => { - const fetchMock = okFetchMock(); - const payload = await signPayload( - completeData({ success: false, summary: "boom: the build broke" }) - ); - await postCallback("/callbacks/automation-complete", payload).then(({ ctx }) => - flushWaitUntil(ctx) - ); - - const post = slackCall(fetchMock, "chat.postMessage"); - expect(JSON.stringify(post!.body.blocks)).toContain("boom: the build broke"); - }); - - it("suppresses the thread post but still clears the reaction when replyInThread is false", async () => { - const fetchMock = okFetchMock(); - const payload = await signPayload(completeData({ replyInThread: false })); - await postCallback("/callbacks/automation-complete", payload).then(({ ctx }) => - flushWaitUntil(ctx) - ); - expect(slackCall(fetchMock, "chat.postMessage")).toBeUndefined(); const reaction = slackCall(fetchMock, "reactions.remove"); expect(reaction).toBeDefined(); expect(reaction!.body).toMatchObject({ channel: "C123", timestamp: "111.222", name: "eyes" }); }); - it("does not clear the reaction when Slack rejects the post (ok:false)", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ ok: false, error: "channel_not_found" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }) - ); - const payload = await signPayload(completeData()); - const { response, ctx } = await postCallback("/callbacks/automation-complete", payload); - - expect(response.status).toBe(200); - await flushWaitUntil(ctx); - - expect(slackCall(fetchMock, "chat.postMessage")).toBeDefined(); - expect(slackCall(fetchMock, "reactions.remove")).toBeUndefined(); - }); - - it("does not crash when the Slack post throws", async () => { + it("does not crash when clearing the reaction throws", async () => { vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")); const payload = await signPayload(completeData()); const { response, ctx } = await postCallback("/callbacks/automation-complete", payload); diff --git a/packages/slack-bot/src/callbacks.ts b/packages/slack-bot/src/callbacks.ts index be1f9de62..f1838ac81 100644 --- a/packages/slack-bot/src/callbacks.ts +++ b/packages/slack-bot/src/callbacks.ts @@ -104,23 +104,14 @@ function isValidToolCallPayload(payload: unknown): payload is ToolCallCallback { } /** - * Payload for a scheduler-owned automation completion (Slack-triggered run). - * Posted by the SchedulerDO into the originating thread. + * Payload for a scheduler-owned automation completion (Slack-triggered run). The + * SchedulerDO posts this when the run finishes; the bot's only action is to clear + * the `eyes` reaction it added to the triggering message when the run started. */ interface AutomationCompletePayload { channel: string; - threadTs: string; - reactionMessageTs?: string; - sessionId: string | null; - success: boolean; - summary?: string; - automationName: string; - /** - * The automation's reply_in_thread setting. When false, clear the eyes - * reaction but post no thread reply. Optional/defaulting-to-true so a payload - * from an older scheduler still posts (preserving prior behavior). - */ - replyInThread?: boolean; + /** The triggering message to clear the `eyes` reaction from. */ + reactionMessageTs: string; signature: string; } @@ -129,14 +120,8 @@ function isValidAutomationCompletePayload(payload: unknown): payload is Automati const p = payload; return ( typeof p.channel === "string" && - typeof p.threadTs === "string" && - typeof p.success === "boolean" && - typeof p.automationName === "string" && - typeof p.signature === "string" && - (p.sessionId === null || typeof p.sessionId === "string") && - (p.summary === undefined || typeof p.summary === "string") && - (p.reactionMessageTs === undefined || typeof p.reactionMessageTs === "string") && - (p.replyInThread === undefined || typeof p.replyInThread === "boolean") + typeof p.reactionMessageTs === "string" && + typeof p.signature === "string" ); } @@ -159,48 +144,6 @@ function isValidAutomationSkipPayload(payload: unknown): payload is AutomationSk ); } -/** - * Build the blocks for an automation completion message: a ✅/❌ headline, an - * optional failure summary, and a "View Session" deep link when a session - * exists. Mirrors the compact style of the completion error path. - */ -function buildAutomationCompletionBlocks( - payload: AutomationCompletePayload, - webAppUrl: string -): unknown[] { - const emoji = payload.success ? ":white_check_mark:" : ":x:"; - const verb = payload.success ? "completed" : "failed"; - const blocks: unknown[] = [ - { - type: "section", - text: { type: "mrkdwn", text: `${emoji} *Automation ${verb}:* ${payload.automationName}` }, - }, - ]; - - if (payload.summary) { - blocks.push({ - type: "section", - text: { type: "mrkdwn", text: truncateError(payload.summary, 2000) }, - }); - } - - if (payload.sessionId) { - blocks.push({ - type: "actions", - elements: [ - { - type: "button", - text: { type: "plain_text", text: "View Session" }, - url: `${webAppUrl}/session/${payload.sessionId}`, - action_id: "view_session", - }, - ], - }); - } - - return blocks; -} - /** * Shared rejection guard for signed callback routes: validate the payload shape, * require the signing secret, then verify the in-body HMAC signature. Returns a @@ -346,10 +289,9 @@ callbacksRouter.post("/tool_call", async (c) => { }); /** - * Callback endpoint for Slack-triggered automation completion. Posts the run - * result into the originating thread and clears the `eyes` reaction. The - * SchedulerDO owns this fan-out (it holds the thread coordinates); this route - * just renders and delivers. + * Callback endpoint for Slack-triggered automation completion. Clears the `eyes` + * reaction from the triggering message. The SchedulerDO owns this fan-out (it + * holds the message coordinates); this route just delivers the reaction clear. */ callbacksRouter.post("/automation-complete", async (c) => { const startTime = Date.now(); @@ -534,8 +476,8 @@ async function handleCompletionCallback( } /** - * Post an automation completion result into the originating Slack thread and - * clear the `eyes` reaction from the triggering message. Fire-and-forget. + * Clear the `eyes` reaction from a Slack-triggered run's triggering message when + * the run completes. Fire-and-forget. */ async function handleAutomationComplete( payload: AutomationCompletePayload, @@ -546,44 +488,15 @@ async function handleAutomationComplete( const base = { trace_id: traceId, channel: payload.channel, - thread_ts: payload.threadTs, - session_id: payload.sessionId, - automation_name: payload.automationName, + message_ts: payload.reactionMessageTs, }; try { - // Honor reply_in_thread: when false, skip the thread post but still clear - // the eyes reaction below so the triggering message isn't left marked 👀. - if (payload.replyInThread !== false) { - const blocks = buildAutomationCompletionBlocks(payload, env.WEB_APP_URL); - const fallback = `${payload.success ? "Automation completed" : "Automation failed"}: ${payload.automationName}`; - - const postResult = await postMessage(env.SLACK_BOT_TOKEN, payload.channel, fallback, { - thread_ts: payload.threadTs, - blocks, - }); - - // postMessage can return ok:false without throwing; don't log success then. - if (!postResult.ok) { - log.warn("callback.automation_complete", { - ...base, - outcome: "error", - slack_error: postResult.error, - duration_ms: Date.now() - startTime, - }); - return; - } - } - - if (payload.reactionMessageTs) { - await clearThinkingReaction(env, payload.channel, payload.reactionMessageTs, traceId); - } + await clearThinkingReaction(env, payload.channel, payload.reactionMessageTs, traceId); log.info("callback.automation_complete", { ...base, outcome: "success", - agent_success: payload.success, - replied: payload.replyInThread !== false, duration_ms: Date.now() - startTime, }); } catch (error) { diff --git a/packages/web/src/components/automations/automation-form.test.tsx b/packages/web/src/components/automations/automation-form.test.tsx index e694b812c..609520072 100644 --- a/packages/web/src/components/automations/automation-form.test.tsx +++ b/packages/web/src/components/automations/automation-form.test.tsx @@ -192,7 +192,6 @@ describe("slack_event automation", () => { initialValues={{ ...slackBase, triggerConfig: validConditions }} /> ); - expect(screen.getByText("Reply in thread")).toBeInTheDocument(); expect(screen.getByText("Max runs per hour")).toBeInTheDocument(); }); @@ -210,7 +209,6 @@ describe("slack_event automation", () => { }} /> ); - expect(screen.queryByText("Reply in thread")).not.toBeInTheDocument(); expect(screen.queryByText("Max runs per hour")).not.toBeInTheDocument(); }); @@ -233,7 +231,7 @@ describe("slack_event automation", () => { ).toBeInTheDocument(); }); - it("submits replyInThread and maxRunsPerHour for a valid slack_event", () => { + it("submits maxRunsPerHour for a valid slack_event", () => { const onSubmit = vi.fn(); const { container } = render( { ); fireEvent.change(screen.getByPlaceholderText(/Default \(/), { target: { value: "5" } }); - fireEvent.click(screen.getByLabelText("Reply in thread")); // default true -> false fireEvent.submit(container.querySelector("form")!); @@ -253,7 +250,7 @@ describe("slack_event automation", () => { expect(onSubmit.mock.calls[0][0]).toMatchObject({ triggerType: "slack_event", maxRunsPerHour: 5, - triggerConfig: { ...validConditions, replyInThread: false }, + triggerConfig: validConditions, }); }); @@ -272,7 +269,7 @@ describe("slack_event automation", () => { expect(onSubmit.mock.calls[0][0]).toMatchObject({ maxRunsPerHour: null, - triggerConfig: { ...validConditions, replyInThread: true }, + triggerConfig: validConditions, }); }); }); diff --git a/packages/web/src/components/automations/automation-form.tsx b/packages/web/src/components/automations/automation-form.tsx index 4b2e35cdc..836e78034 100644 --- a/packages/web/src/components/automations/automation-form.tsx +++ b/packages/web/src/components/automations/automation-form.tsx @@ -135,9 +135,6 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au initialValues?.triggerConfig?.conditions ?? [] ); const [sentryClientSecret, setSentryClientSecret] = useState(""); - const [replyInThread, setReplyInThread] = useState( - initialValues?.triggerConfig?.replyInThread ?? true - ); const [maxRunsPerHour, setMaxRunsPerHour] = useState( initialValues?.maxRunsPerHour != null ? String(initialValues.maxRunsPerHour) : "" ); @@ -237,9 +234,8 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au if (eventType) values.eventType = eventType; // Always send triggerConfig so clearing all conditions persists (PUT skips - // trigger_config when triggerConfig is omitted). For slack_event the - // reply-in-thread flag rides inside it (source-interpreted, like conditions). - values.triggerConfig = isSlack ? { conditions, replyInThread } : { conditions }; + // trigger_config when triggerConfig is omitted). + values.triggerConfig = { conditions }; if (triggerType === "sentry" && mode === "create" && sentryClientSecret.trim()) { values.sentryClientSecret = sentryClientSecret.trim(); } @@ -548,20 +544,9 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au )} - {/* Slack delivery + rate limit (slack_event only) */} + {/* Slack rate limit (slack_event only) */} {isSlack && (
- - - Post the run result back into the originating Slack thread when it completes. -
)} - {/* Slack rate limit (slack_event only) */} - {isSlack && ( -
-
- - setMaxRunsPerHour(e.target.value)} - placeholder={`Default (${DEFAULT_MAX_RUNS_PER_HOUR})`} - className="w-40" - /> - - Caps how many runs this automation can start per hour. Extra matching messages are - skipped. Leave blank to use the default ({DEFAULT_MAX_RUNS_PER_HOUR}). - - {!maxRunsValid && ( -

- Max runs per hour must be a positive whole number. -

- )} -
-
- )} - {/* Instructions */}
@@ -628,7 +583,6 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au !instructions.trim() || !isScheduleValid || !slackConditionsValid || - !maxRunsValid || (showEventTypeSelector && !eventType) || (triggerType === "sentry" && mode === "create" && !sentryClientSecret.trim()) } diff --git a/terraform/d1/migrations/0027_drop_max_runs_per_hour.sql b/terraform/d1/migrations/0027_drop_max_runs_per_hour.sql new file mode 100644 index 000000000..fc6f69737 --- /dev/null +++ b/terraform/d1/migrations/0027_drop_max_runs_per_hour.sql @@ -0,0 +1,8 @@ +-- Drop the per-automation hourly run cap. The max-runs-per-hour feature was +-- removed: the scheduler no longer rate-limits slack_event triggers, leaving the +-- per-thread concurrency guard as the backstop. The column is unindexed, so the +-- drop is clean. +-- +-- 0026 (already applied) added this column; never edit it — the drop must be its +-- own migration. +ALTER TABLE automations DROP COLUMN max_runs_per_hour; From 40e7cf96cc51d4f08ca0c6f4d969fd13252e0675 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 24 Jun 2026 23:09:24 -0700 Subject: [PATCH 11/19] feat(slack): allow slack_event automations without a text filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slack_event automation no longer requires a text_match condition — only a slack_channel. Without a text filter it responds to every message in the watched channel, which is the desired 'respond to any reply' behavior. Channel scoping stays required so a trigger is always bounded to specific channels. --- .../control-plane/src/routes/automations.ts | 12 ++++----- .../automations-slack-route.test.ts | 7 ++--- .../automations/automation-form.test.tsx | 26 ++++++++++++++++--- .../automations/automation-form.tsx | 10 +++---- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts index a9f453647..dde69b08a 100644 --- a/packages/control-plane/src/routes/automations.ts +++ b/packages/control-plane/src/routes/automations.ts @@ -83,9 +83,10 @@ function extractSlackChannels(triggerConfig: TriggerConfig | null | undefined): } /** - * Validate a slack_event trigger config before persistence. It must be scoped — - * an explicit channel set + at least one `text_match` (net-new validation; the - * engine otherwise skips condition validation entirely when none are present). + * Validate a slack_event trigger config before persistence. It must be scoped to + * an explicit channel set (net-new validation; the engine otherwise skips + * condition validation entirely when none are present). A text_match is optional + * — without one the automation fires on every message in the watched channel. * Returns an error message, or null when valid. */ function validateSlackTriggerConfig( @@ -102,9 +103,6 @@ function validateSlackTriggerConfig( if (!conditions.some((c) => c.type === "slack_channel")) { return "slack_event triggers require a slack_channel condition"; } - if (!conditions.some((c) => c.type === "text_match")) { - return "slack_event triggers require at least one text_match condition"; - } return null; } @@ -217,7 +215,7 @@ async function handleCreateAutomation( } } - // Slack triggers require explicit scoping (a channel set + at least one text_match). + // Slack triggers require explicit scoping (at least one watched channel). if (triggerType === "slack_event") { const slackError = validateSlackTriggerConfig(body.triggerConfig); if (slackError) return error(slackError, 400); diff --git a/packages/control-plane/test/integration/automations-slack-route.test.ts b/packages/control-plane/test/integration/automations-slack-route.test.ts index 3e7328660..88001d3cc 100644 --- a/packages/control-plane/test/integration/automations-slack-route.test.ts +++ b/packages/control-plane/test/integration/automations-slack-route.test.ts @@ -69,7 +69,7 @@ describe("POST /automations — slack_event validation (integration)", () => { expect(await res.text()).toContain("slack_channel"); }); - it("rejects a slack_event without a text_match condition (400)", async () => { + it("accepts a slack_event with only a slack_channel condition (no text_match)", async () => { const res = await postAutomation( createBody({ triggerConfig: { @@ -77,8 +77,9 @@ describe("POST /automations — slack_event validation (integration)", () => { }, }) ); - expect(res.status).toBe(400); - expect(await res.text()).toContain("text_match"); + // text_match is optional; the channel-only config passes validation and only + // later fails at repo resolution in the test env. + expect(res.status).not.toBe(400); }); it("rejects a slack_channel value that is not an array of strings (400)", async () => { diff --git a/packages/web/src/components/automations/automation-form.test.tsx b/packages/web/src/components/automations/automation-form.test.tsx index 688dc9cc9..64d82b6b2 100644 --- a/packages/web/src/components/automations/automation-form.test.tsx +++ b/packages/web/src/components/automations/automation-form.test.tsx @@ -183,7 +183,7 @@ describe("slack_event automation", () => { ], }; - it("blocks submit until a slack_channel and text_match condition exist", () => { + it("blocks submit until a slack_channel condition exists", () => { const onSubmit = vi.fn(); const { container } = render( { expect(screen.getByRole("button", { name: "Save Changes" })).toBeDisabled(); fireEvent.submit(container.querySelector("form")!); expect(onSubmit).not.toHaveBeenCalled(); - expect( - screen.getByText(/require at least one Slack Channel and one Message Text/) - ).toBeInTheDocument(); + expect(screen.getByText(/require at least one Slack Channel/)).toBeInTheDocument(); }); it("submits a valid slack_event", () => { @@ -221,6 +219,26 @@ describe("slack_event automation", () => { triggerConfig: validConditions, }); }); + + it("submits a slack_event with only a slack_channel condition (no text_match)", () => { + const onSubmit = vi.fn(); + const channelOnly = { + conditions: [{ type: "slack_channel" as const, operator: "any_of" as const, value: ["C1"] }], + }; + const { container } = render( + + ); + + fireEvent.submit(container.querySelector("form")!); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ triggerConfig: channelOnly }); + }); }); describe("instructions character counter", () => { diff --git a/packages/web/src/components/automations/automation-form.tsx b/packages/web/src/components/automations/automation-form.tsx index b9c3e16e4..c49188248 100644 --- a/packages/web/src/components/automations/automation-form.tsx +++ b/packages/web/src/components/automations/automation-form.tsx @@ -137,11 +137,9 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au const isSchedule = triggerType === "schedule"; const isSlack = triggerType === "slack_event"; const isScheduleValid = !isSchedule || isValidCron(scheduleCron); - // Mirror the server rule: a slack_event needs a slack_channel + a text_match. - const slackConditionsValid = - !isSlack || - (conditions.some((c) => c.type === "slack_channel") && - conditions.some((c) => c.type === "text_match")); + // Mirror the server rule: a slack_event needs a slack_channel. A text_match is + // optional — without one it fires on every message in the watched channel. + const slackConditionsValid = !isSlack || conditions.some((c) => c.type === "slack_channel"); // The model we display and submit. The selector only lists enabled models, so // a disabled default (blank create), a disabled saved model (edit), or a @@ -522,7 +520,7 @@ export function AutomationForm({ mode, initialValues, onSubmit, submitting }: Au {isSlack && !slackConditionsValid && (

- Slack triggers require at least one Slack Channel and one Message Text condition. + Slack triggers require at least one Slack Channel condition.

)}
From 32b10df657d78213f635822d5bd2514135226b23 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 24 Jun 2026 23:21:48 -0700 Subject: [PATCH 12/19] feat(slack): select channels by name in automation setup Pasting raw channel IDs into the slack_channel condition was impractical. Add a channel picker: a new conversations.list-backed shared client method (listChannels), an internal control-plane route GET /integration-settings/slack/channels, a web proxy + useSlackChannels hook, and a SlackChannelPicker that resolves IDs to #names and stores IDs. It degrades to manual channel-ID entry when listing is unavailable (no bot token, missing channels:read/groups:read scopes, or a Slack error), and flags channels the bot hasn't been invited to. --- docs/GETTING_STARTED.md | 4 +- .../control-plane/src/routes/automations.ts | 35 ++++++ .../automations-slack-route.test.ts | 28 +++++ packages/shared/src/slack/client.test.ts | 75 ++++++++++++ packages/shared/src/slack/client.ts | 58 +++++++++ packages/shared/src/slack/index.ts | 2 + .../api/integrations/slack/channels/route.ts | 36 ++++++ .../automations/automation-form.test.tsx | 7 ++ .../automations/condition-builder.test.tsx | 45 ++++++- .../automations/condition-builder.tsx | 110 +++++++++++++++++- packages/web/src/hooks/use-slack-channels.ts | 28 +++++ 11 files changed, 422 insertions(+), 6 deletions(-) create mode 100644 packages/web/src/app/api/integrations/slack/channels/route.ts create mode 100644 packages/web/src/hooks/use-slack-channels.ts diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index bc961d1fd..05370b720 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -942,7 +942,9 @@ If the bot doesn't see the original message when tagged in a thread reply: private channels). These are required by the `conversations.replies` API to fetch thread messages. 2. Verify the bot has `channels:read` and `groups:read` scopes. These are required by - `conversations.info` to fetch channel name and description for context. + `conversations.info` to fetch channel name and description for context, and by + `conversations.list` to populate the automation channel picker. If the picker shows no channels, + check these scopes and that the bot is invited to the target channel. 3. If you added missing scopes, **reinstall the app** to your workspace for the new permissions to take effect. diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts index dde69b08a..5d7f1e11a 100644 --- a/packages/control-plane/src/routes/automations.ts +++ b/packages/control-plane/src/routes/automations.ts @@ -11,6 +11,7 @@ import { getValidModelOrDefault, validateConditions, conditionRegistry, + listChannels, TRIGGER_TYPE_TO_SOURCE, type CreateAutomationRequest, type UpdateAutomationRequest, @@ -840,6 +841,35 @@ async function handleGetWatchedSlackChannels( return json({ channels }); } +/** + * GET /integration-settings/slack/channels + * + * Lists the workspace's channels (public + private the bot can see) so the + * automation form can offer a channel picker instead of a raw channel ID. Sourced + * live from Slack via `conversations.list` using the bot token. + * + * Returns `{ channels }` on success, or `{ channels: [], error }` when the token + * is unset or Slack rejects the call (e.g. missing `channels:read`/`groups:read` + * scope) — the form then degrades to manual channel-ID entry. Internal-auth gated + * by the router (non-public route). + */ +async function handleGetSlackChannels( + _request: Request, + env: Env, + _match: RegExpMatchArray, + _ctx: RequestContext +): Promise { + if (!env.SLACK_BOT_TOKEN) { + return json({ channels: [], error: "not_configured" }); + } + const result = await listChannels(env.SLACK_BOT_TOKEN); + if (!result.ok) { + logger.warn("slack.channels.list_failed", { slack_error: result.error }); + return json({ channels: [], error: result.error }); + } + return json({ channels: result.channels }); +} + // ─── Route exports ─────────────────────────────────────────────────────────── export const automationRoutes: Route[] = [ @@ -848,6 +878,11 @@ export const automationRoutes: Route[] = [ pattern: parsePattern("/integration-settings/slack/watched-channels"), handler: handleGetWatchedSlackChannels, }, + { + method: "GET", + pattern: parsePattern("/integration-settings/slack/channels"), + handler: handleGetSlackChannels, + }, { method: "GET", pattern: parsePattern("/automations"), diff --git a/packages/control-plane/test/integration/automations-slack-route.test.ts b/packages/control-plane/test/integration/automations-slack-route.test.ts index 88001d3cc..e89abc0d3 100644 --- a/packages/control-plane/test/integration/automations-slack-route.test.ts +++ b/packages/control-plane/test/integration/automations-slack-route.test.ts @@ -285,3 +285,31 @@ describe("GET /integration-settings/slack/watched-channels (integration)", () => expect(body.channels).toEqual([]); }); }); + +describe("GET /integration-settings/slack/channels (integration)", () => { + beforeEach(cleanD1Tables); + + async function getSlackChannels(auth = true): Promise { + return SELF.fetch("https://test.local/integration-settings/slack/channels", { + method: "GET", + headers: auth ? await authHeaders() : undefined, + }); + } + + it("returns 401 without an internal token", async () => { + const res = await getSlackChannels(false); + expect(res.status).toBe(401); + }); + + it("degrades to an empty channel list (never a 500) when listing is unavailable", async () => { + // The integration env has no usable bot token, so the route returns an empty + // list with an error — `not_configured` when unset, or a Slack error such as + // `invalid_auth` when a placeholder token is present — rather than throwing. + const res = await getSlackChannels(); + expect(res.status).toBe(200); + const body = await res.json<{ channels: string[]; error?: string }>(); + expect(body.channels).toEqual([]); + expect(typeof body.error).toBe("string"); + expect(body.error).toBeTruthy(); + }); +}); diff --git a/packages/shared/src/slack/client.test.ts b/packages/shared/src/slack/client.test.ts index bb60dd427..d7a32ad88 100644 --- a/packages/shared/src/slack/client.test.ts +++ b/packages/shared/src/slack/client.test.ts @@ -6,6 +6,7 @@ import { getPermalink, getThreadMessages, getUserInfo, + listChannels, openView, postMessage, publishView, @@ -478,3 +479,77 @@ describe("openView", () => { } }); }); + +describe("listChannels", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("normalizes a single page and requests public + private, non-archived", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + jsonResponse({ + ok: true, + channels: [ + { id: "C1", name: "general", is_private: false, is_member: true }, + { id: "C2", name: "secret", is_private: true, is_member: false }, + ], + response_metadata: { next_cursor: "" }, + }) + ); + + const result = await listChannels("xoxb-token"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.channels).toEqual([ + { id: "C1", name: "general", isPrivate: false, isMember: true }, + { id: "C2", name: "secret", isPrivate: true, isMember: false }, + ]); + } + const url = String(fetchSpy.mock.calls[0]![0]); + expect(url).toContain("conversations.list"); + expect(url).toContain("types=public_channel%2Cprivate_channel"); + expect(url).toContain("exclude_archived=true"); + expect(url).toContain("limit=1000"); + }); + + it("follows next_cursor pagination and concatenates pages", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + jsonResponse({ + ok: true, + channels: [{ id: "C1", name: "a" }], + response_metadata: { next_cursor: "cur-2" }, + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + ok: true, + channels: [{ id: "C2", name: "b" }], + response_metadata: { next_cursor: "" }, + }) + ); + + const result = await listChannels("xoxb-token"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.channels.map((c) => c.id)).toEqual(["C1", "C2"]); + } + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(String(fetchSpy.mock.calls[1]![0])).toContain("cursor=cur-2"); + }); + + it("returns the Slack failure envelope when a page errors", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + jsonResponse({ ok: false, error: "missing_scope" }) + ); + + const result = await listChannels("xoxb-token"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("missing_scope"); + } + }); +}); diff --git a/packages/shared/src/slack/client.ts b/packages/shared/src/slack/client.ts index 42cb0a339..ff0e0de18 100644 --- a/packages/shared/src/slack/client.ts +++ b/packages/shared/src/slack/client.ts @@ -225,6 +225,64 @@ export function getChannelInfo( return slackGet(token, "conversations.info", { channel: channelId }); } +/** Raw `conversations.list` channel shape (subset the picker consumes). */ +interface SlackConversation { + id: string; + name: string; + is_private?: boolean; + is_member?: boolean; +} + +/** Normalized channel for the automation channel picker. */ +export interface SlackChannelListing { + id: string; + name: string; + isPrivate: boolean; + /** Whether the bot is a member — only member channels deliver messages. */ + isMember: boolean; +} + +/** + * List the workspace's public + private channels via `conversations.list`, + * following `response_metadata.next_cursor` pagination and excluding archived + * channels. Requires the bot token's `channels:read` (public) and `groups:read` + * (private) scopes. Returns the SlackEnvelope failure arm on any page's error. + */ +export async function listChannels( + token: string +): Promise> { + const channels: SlackChannelListing[] = []; + let cursor: string | undefined; + // Bound the loop defensively: 1000/page × 20 pages caps at 20k channels. + for (let page = 0; page < 20; page++) { + const query: Record = { + types: "public_channel,private_channel", + exclude_archived: "true", + limit: "1000", + }; + if (cursor) query.cursor = cursor; + + const res = await slackGet<{ + channels: SlackConversation[]; + response_metadata?: { next_cursor?: string }; + }>(token, "conversations.list", query); + if (!res.ok) return res; + + for (const c of res.channels) { + channels.push({ + id: c.id, + name: c.name, + isPrivate: Boolean(c.is_private), + isMember: Boolean(c.is_member), + }); + } + + cursor = res.response_metadata?.next_cursor || undefined; + if (!cursor) break; + } + return { ok: true, channels }; +} + export interface SlackThreadMessage { ts: string; text: string; diff --git a/packages/shared/src/slack/index.ts b/packages/shared/src/slack/index.ts index b93aee0e5..7384d5860 100644 --- a/packages/shared/src/slack/index.ts +++ b/packages/shared/src/slack/index.ts @@ -5,6 +5,7 @@ export { getPermalink, getThreadMessages, getUserInfo, + listChannels, openView, postEphemeral, postMessage, @@ -16,6 +17,7 @@ export { export type { SlackAuthTestResult, SlackChannelInfo, + SlackChannelListing, SlackEnvelope, SlackThreadMessage, SlackUser, diff --git a/packages/web/src/app/api/integrations/slack/channels/route.ts b/packages/web/src/app/api/integrations/slack/channels/route.ts new file mode 100644 index 000000000..ece24a5a3 --- /dev/null +++ b/packages/web/src/app/api/integrations/slack/channels/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { controlPlaneFetch } from "@/lib/control-plane"; +import type { SlackChannelListing } from "@open-inspect/shared"; + +interface ControlPlaneChannelsResponse { + channels: SlackChannelListing[]; + error?: string; +} + +/** + * List Slack channels for the automation channel picker. Proxies to the control + * plane (which holds the bot token) and always responds 200 with a `channels` + * array so the picker degrades to manual channel-ID entry on any failure. + */ +export async function GET() { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const response = await controlPlaneFetch("/integration-settings/slack/channels"); + if (!response.ok) { + const error = await response.text(); + console.error("Control plane slack channels error:", error); + return NextResponse.json({ channels: [], error: "fetch_failed" }); + } + const data: ControlPlaneChannelsResponse = await response.json(); + return NextResponse.json({ channels: data.channels ?? [], error: data.error }); + } catch (error) { + console.error("Error fetching slack channels:", error); + return NextResponse.json({ channels: [], error: "fetch_failed" }); + } +} diff --git a/packages/web/src/components/automations/automation-form.test.tsx b/packages/web/src/components/automations/automation-form.test.tsx index 64d82b6b2..ba9272b62 100644 --- a/packages/web/src/components/automations/automation-form.test.tsx +++ b/packages/web/src/components/automations/automation-form.test.tsx @@ -58,6 +58,13 @@ vi.mock("@/hooks/use-enabled-models", () => ({ }), })); +// The SlackChannelPicker (rendered for slack_channel conditions) lists channels via +// useSession-backed SWR. The form tests don't exercise channel listing, so stub it out +// to avoid needing a SessionProvider. +vi.mock("@/hooks/use-slack-channels", () => ({ + useSlackChannels: () => ({ channels: [], loading: false }), +})); + vi.mock("@/components/ui/combobox", () => ({ Combobox: ({ children }: { children: ReactNode }) =>
{children}
, })); diff --git a/packages/web/src/components/automations/condition-builder.test.tsx b/packages/web/src/components/automations/condition-builder.test.tsx index f7e7d6c6e..b90074ff3 100644 --- a/packages/web/src/components/automations/condition-builder.test.tsx +++ b/packages/web/src/components/automations/condition-builder.test.tsx @@ -1,14 +1,26 @@ // @vitest-environment jsdom /// -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; import type { TriggerCondition } from "@open-inspect/shared"; import { ConditionBuilder } from "./condition-builder"; +type ChannelListing = { id: string; name: string; isPrivate: boolean; isMember: boolean }; +// Mutable per-test channel listing; the hoisted use-slack-channels mock closes over it. +let slackChannelsMock: { channels: ChannelListing[]; loading: boolean; error?: string }; +vi.mock("@/hooks/use-slack-channels", () => ({ + useSlackChannels: () => slackChannelsMock, +})); + expect.extend(matchers); afterEach(cleanup); +beforeEach(() => { + slackChannelsMock = { channels: [], loading: false }; + // jsdom doesn't implement scrollIntoView, which the Combobox calls when opened. + Element.prototype.scrollIntoView = vi.fn(); +}); function renderBuilder(conditions: TriggerCondition[]) { const onChange = vi.fn(); @@ -35,7 +47,8 @@ describe("ConditionBuilder — slack editors", () => { ]); }); - it("renders a slack_channel tag input and adds a channel ID", () => { + it("falls back to manual channel-ID entry when channels can't be listed", () => { + slackChannelsMock = { channels: [], loading: false, error: "not_configured" }; const onChange = renderBuilder([{ type: "slack_channel", operator: "any_of", value: [] }]); const input = screen.getByPlaceholderText(/Add channel ID/); @@ -47,6 +60,34 @@ describe("ConditionBuilder — slack editors", () => { ]); }); + it("picks a channel by name and stores its ID", () => { + slackChannelsMock = { + channels: [ + { id: "C0123ABCD", name: "general", isPrivate: false, isMember: true }, + { id: "C9999", name: "random", isPrivate: false, isMember: true }, + ], + loading: false, + }; + const onChange = renderBuilder([{ type: "slack_channel", operator: "any_of", value: [] }]); + + fireEvent.click(screen.getByText("Add channel...")); + fireEvent.click(screen.getByText("#general")); + + expect(onChange).toHaveBeenLastCalledWith([ + { type: "slack_channel", operator: "any_of", value: ["C0123ABCD"] }, + ]); + }); + + it("resolves selected channel IDs to #name chips", () => { + slackChannelsMock = { + channels: [{ id: "C0123ABCD", name: "general", isPrivate: false, isMember: true }], + loading: false, + }; + renderBuilder([{ type: "slack_channel", operator: "any_of", value: ["C0123ABCD"] }]); + + expect(screen.getByText("#general")).toBeInTheDocument(); + }); + it("renders the slack_actor include/exclude control and user input", () => { renderBuilder([{ type: "slack_actor", operator: "include", value: [] }]); expect(screen.getByText("Slack User")).toBeInTheDocument(); diff --git a/packages/web/src/components/automations/condition-builder.tsx b/packages/web/src/components/automations/condition-builder.tsx index 35ac78f1e..8b619d267 100644 --- a/packages/web/src/components/automations/condition-builder.tsx +++ b/packages/web/src/components/automations/condition-builder.tsx @@ -1,10 +1,12 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import type { TriggerCondition, AutomationEventSource, JsonPathFilter } from "@open-inspect/shared"; import { conditionRegistry } from "@open-inspect/shared"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { ChevronDownIcon } from "@/components/ui/icons"; import { Select, SelectContent, @@ -12,6 +14,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { useSlackChannels } from "@/hooks/use-slack-channels"; interface ConditionBuilderProps { conditions: TriggerCondition[]; @@ -315,10 +318,9 @@ function ConditionEditor({ } case "slack_channel": return ( - onChange({ ...condition, value })} - placeholder="Add channel ID (e.g. C0123ABCD)..." /> ); case "slack_actor": @@ -348,6 +350,108 @@ function ConditionEditor({ } } +/** + * Channel selector for the `slack_channel` condition. Resolves channel names + * from the workspace listing and stores channel IDs. Falls back to manual ID + * entry when the listing is unavailable (no bot token, missing scopes, or a + * Slack API failure), so the condition is always editable. + */ +function SlackChannelPicker({ + values, + onChange, +}: { + values: string[]; + onChange: (values: string[]) => void; +}) { + const { channels, loading } = useSlackChannels(); + const byId = useMemo(() => new Map(channels.map((c) => [c.id, c])), [channels]); + + const add = (id: string) => { + const trimmed = id.trim(); + if (trimmed && !values.includes(trimmed)) onChange([...values, trimmed]); + }; + const remove = (id: string) => onChange(values.filter((v) => v !== id)); + + // Degraded mode: no channel list (no bot token, missing scopes, or empty + // workspace). Fall back to manual channel-ID entry so the trigger still works. + if (!loading && channels.length === 0) { + return ( +
+ +

+ Couldn't list Slack channels — add channel IDs manually. Check that the Slack app has + the channels:read / groups:read scopes and that the bot is + invited to the channel. +

+
+ ); + } + + // Unselected channels, bot-member first, then alphabetical. + const options: ComboboxOption[] = channels + .filter((c) => !values.includes(c.id)) + .sort((a, b) => Number(b.isMember) - Number(a.isMember) || a.name.localeCompare(b.name)) + .map((c) => ({ + value: c.id, + label: `#${c.name}`, + description: !c.isMember ? "bot not in channel" : c.isPrivate ? "private" : undefined, + })); + const someNotMember = values.some((id) => byId.get(id)?.isMember === false); + + return ( +
+ {values.length > 0 && ( +
+ {values.map((id) => { + const ch = byId.get(id); + return ( + + {ch ? `#${ch.name}` : id} + + + ); + })} +
+ )} + + + {loading ? "Loading channels..." : "Add channel..."} + + + + {someNotMember && ( +

+ The bot isn't a member of some selected channels and won't receive their + messages until it's invited. +

+ )} +
+ ); +} + function TagInput({ values, onChange, diff --git a/packages/web/src/hooks/use-slack-channels.ts b/packages/web/src/hooks/use-slack-channels.ts new file mode 100644 index 000000000..8eebfbfdc --- /dev/null +++ b/packages/web/src/hooks/use-slack-channels.ts @@ -0,0 +1,28 @@ +import useSWR from "swr"; +import { useSession } from "next-auth/react"; +import type { SlackChannelListing } from "@open-inspect/shared"; + +interface SlackChannelsResponse { + channels: SlackChannelListing[]; + error?: string; +} + +/** + * Fetch the workspace's Slack channels for the automation channel picker. + * `error` is set (and `channels` empty) when listing is unavailable — no bot + * token, missing scopes, or a Slack API failure — so callers can fall back to + * manual channel-ID entry. + */ +export function useSlackChannels() { + const { data: session } = useSession(); + + const { data, isLoading } = useSWR( + session ? "/api/integrations/slack/channels" : null + ); + + return { + channels: data?.channels ?? [], + error: data?.error, + loading: isLoading, + }; +} From 15f0bfb4ca73c10a9a9d87e1b894efdf256aa841 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 24 Jun 2026 23:53:07 -0700 Subject: [PATCH 13/19] fix(slack): show channel names and readable text_match on automation detail --- .../src/app/(app)/automations/[id]/page.tsx | 16 +--- .../automations/condition-summary.test.tsx | 77 +++++++++++++++++++ .../automations/condition-summary.tsx | 61 +++++++++++++++ packages/web/src/hooks/use-slack-channels.ts | 7 +- 4 files changed, 145 insertions(+), 16 deletions(-) create mode 100644 packages/web/src/components/automations/condition-summary.test.tsx create mode 100644 packages/web/src/components/automations/condition-summary.tsx diff --git a/packages/web/src/app/(app)/automations/[id]/page.tsx b/packages/web/src/app/(app)/automations/[id]/page.tsx index 2c00e2ecb..31569e12a 100644 --- a/packages/web/src/app/(app)/automations/[id]/page.tsx +++ b/packages/web/src/app/(app)/automations/[id]/page.tsx @@ -8,6 +8,7 @@ import { useSidebarContext } from "@/components/sidebar-layout"; import { useAutomation, useAutomationRuns } from "@/hooks/use-automations"; import { RunHistory } from "@/components/automations/run-history"; import { AutomationStatusBadge } from "@/components/automations/automation-status-badge"; +import { ConditionSummary } from "@/components/automations/condition-summary"; import { Button } from "@/components/ui/button"; import { ErrorBanner } from "@/components/ui/error-banner"; import { SidebarIcon, BackIcon, PencilIcon } from "@/components/ui/icons"; @@ -247,20 +248,7 @@ export default function AutomationDetailPage({ params }: { params: Promise<{ id: )} {automation.triggerConfig?.conditions && automation.triggerConfig.conditions.length > 0 && ( -
-
Conditions
-
- {automation.triggerConfig.conditions.map((c, i) => ( - - {c.type}: {c.operator}{" "} - {Array.isArray(c.value) ? c.value.join(", ") : String(c.value)} - - ))} -
-
+ )}
Model
diff --git a/packages/web/src/components/automations/condition-summary.test.tsx b/packages/web/src/components/automations/condition-summary.test.tsx new file mode 100644 index 000000000..e8337dd78 --- /dev/null +++ b/packages/web/src/components/automations/condition-summary.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +/// + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import type { TriggerCondition } from "@open-inspect/shared"; +import { ConditionSummary } from "./condition-summary"; + +type ChannelListing = { id: string; name: string; isPrivate: boolean; isMember: boolean }; +// Mutable per-test channel listing; the hoisted use-slack-channels mock closes over it. +let slackChannelsMock: { channels: ChannelListing[]; loading: boolean; error?: string }; +vi.mock("@/hooks/use-slack-channels", () => ({ + useSlackChannels: () => slackChannelsMock, +})); + +expect.extend(matchers); +afterEach(cleanup); +beforeEach(() => { + slackChannelsMock = { channels: [], loading: false }; +}); + +function renderSummary(conditions: TriggerCondition[]) { + render(); +} + +describe("ConditionSummary", () => { + it("resolves slack_channel IDs to #names and falls back to the ID when unknown", () => { + slackChannelsMock = { + channels: [{ id: "C0AV0V949D0", name: "general", isPrivate: false, isMember: true }], + loading: false, + }; + renderSummary([ + { type: "slack_channel", operator: "any_of", value: ["C0AV0V949D0", "C_UNKNOWN"] }, + ]); + + expect(screen.getByText(/#general, C_UNKNOWN/)).toBeInTheDocument(); + }); + + it("renders a text_match pattern instead of [object Object]", () => { + renderSummary([{ type: "text_match", operator: "contains", value: { pattern: "deploy" } }]); + + expect(screen.getByText(/deploy/)).toBeInTheDocument(); + expect(screen.queryByText(/object Object/)).not.toBeInTheDocument(); + }); + + it("appends regex flags to a text_match pattern", () => { + renderSummary([ + { type: "text_match", operator: "regex", value: { pattern: "rollback", flags: "i" } }, + ]); + + expect(screen.getByText(/rollback \(i\)/)).toBeInTheDocument(); + }); + + it("renders jsonpath filters readably instead of [object Object]", () => { + renderSummary([ + { + type: "jsonpath", + operator: "all_match", + value: [ + { path: "$.level", comparison: "eq", value: "error" }, + { path: "$.tags", comparison: "exists" }, + ], + }, + ]); + + expect(screen.getByText(/\$\.level eq error/)).toBeInTheDocument(); + expect(screen.getByText(/\$\.tags exists/)).toBeInTheDocument(); + expect(screen.queryByText(/object Object/)).not.toBeInTheDocument(); + }); + + it("joins plain string-array values", () => { + renderSummary([{ type: "label", operator: "any_of", value: ["bug", "urgent"] }]); + + expect(screen.getByText(/bug, urgent/)).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/automations/condition-summary.tsx b/packages/web/src/components/automations/condition-summary.tsx new file mode 100644 index 000000000..70e8b1b89 --- /dev/null +++ b/packages/web/src/components/automations/condition-summary.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useMemo } from "react"; +import type { TriggerCondition } from "@open-inspect/shared"; +import { useSlackChannels } from "@/hooks/use-slack-channels"; + +/** + * Render a human-readable value for one trigger condition. + * + * Slack channel IDs resolve to `#name` (falling back to the raw ID when the + * channel can't be listed), and structured values (`text_match`, `jsonpath`) + * are formatted explicitly instead of stringifying to "[object Object]". + */ +function formatConditionValue( + condition: TriggerCondition, + channelNameById: Map +): string { + switch (condition.type) { + case "slack_channel": + return condition.value + .map((id) => { + const name = channelNameById.get(id); + return name ? `#${name}` : id; + }) + .join(", "); + case "text_match": { + const { pattern, flags } = condition.value; + return flags ? `${pattern} (${flags})` : pattern; + } + case "jsonpath": + return condition.value + .map((f) => `${f.path} ${f.comparison}${f.value === undefined ? "" : ` ${f.value}`}`) + .join(", "); + default: + return Array.isArray(condition.value) ? condition.value.join(", ") : String(condition.value); + } +} + +/** + * Read-only summary of an automation's trigger conditions, shown on the detail + * page. Resolves Slack channel names lazily — only when a `slack_channel` + * condition is present. + */ +export function ConditionSummary({ conditions }: { conditions: TriggerCondition[] }) { + const hasSlackChannel = conditions.some((c) => c.type === "slack_channel"); + const { channels } = useSlackChannels(hasSlackChannel); + const channelNameById = useMemo(() => new Map(channels.map((c) => [c.id, c.name])), [channels]); + + return ( +
+
Conditions
+
+ {conditions.map((c, i) => ( + + {c.type}: {c.operator} {formatConditionValue(c, channelNameById)} + + ))} +
+
+ ); +} diff --git a/packages/web/src/hooks/use-slack-channels.ts b/packages/web/src/hooks/use-slack-channels.ts index 8eebfbfdc..e9f17434b 100644 --- a/packages/web/src/hooks/use-slack-channels.ts +++ b/packages/web/src/hooks/use-slack-channels.ts @@ -12,12 +12,15 @@ interface SlackChannelsResponse { * `error` is set (and `channels` empty) when listing is unavailable — no bot * token, missing scopes, or a Slack API failure — so callers can fall back to * manual channel-ID entry. + * + * Pass `enabled: false` to skip the request entirely — e.g. when there is no + * Slack channel to resolve — without violating the rules of hooks. */ -export function useSlackChannels() { +export function useSlackChannels(enabled = true) { const { data: session } = useSession(); const { data, isLoading } = useSWR( - session ? "/api/integrations/slack/channels" : null + enabled && session ? "/api/integrations/slack/channels" : null ); return { From d2f21831cad058e2b76e8cc4f498836d7ae816db Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Thu, 25 Jun 2026 09:21:58 -0700 Subject: [PATCH 14/19] docs(slack): align automation docs with slack-trigger changes --- docs/AUTOMATIONS.md | 43 +++++++++++++++++++------------------- docs/integrations/SLACK.md | 22 +++++++++---------- 2 files changed, 31 insertions(+), 34 deletions(-) diff --git a/docs/AUTOMATIONS.md b/docs/AUTOMATIONS.md index 7313a3cbb..46ab63acd 100644 --- a/docs/AUTOMATIONS.md +++ b/docs/AUTOMATIONS.md @@ -46,12 +46,12 @@ Start by choosing a **Trigger Type**. The rest of the form adjusts based on that ### Trigger-Specific Fields -| Trigger Type | Additional Fields | -| ------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| **Schedule** | **Schedule** and **Timezone** | -| **Inbound Webhook** | No extra required fields | -| **Sentry Alert** | **Event Type** and **Sentry Client Secret** | -| **Slack Message** | **Conditions** (a Slack Channel and a Message Text condition are required), **Reply in thread**, **Max runs per hour** | +| Trigger Type | Additional Fields | +| ------------------- | -------------------------------------------------------------------------------------------- | +| **Schedule** | **Schedule** and **Timezone** | +| **Inbound Webhook** | No extra required fields | +| **Sentry Alert** | **Event Type** and **Sentry Client Secret** | +| **Slack Message** | **Conditions** (a Slack Channel condition is required; a Message Text condition is optional) | For non-schedule automations, schedule fields are not used. @@ -206,36 +206,35 @@ operator to set the `SLACK_TRIGGERS_ENABLED` flag and configure the Slack app threat model. The web form and these conditions are always available to author; messages are only ingested once the flag is on. -### Required conditions +### Conditions -A Slack automation must define both: +A Slack automation must define at least a **Slack Channel** condition; the rest are optional +filters. -- **Slack Channel** — one or more channel IDs (for example `C0123ABCD`) to watch. Only messages in - these channels are considered; the bot must be a member of each. -- **Message Text** — how the message text is matched. Pick a mode: +- **Slack Channel** (required) — the channels to watch. Pick channels by name in the web form; + channel IDs (for example `C0123ABCD`) also work as a fallback when channel listing is unavailable. + Only messages in these channels are considered, and the bot must be a member of each. +- **Message Text** (optional) — filter on the message text. Without it, every message in the watched + channels triggers the automation. Pick a mode: - **contains** — the message contains the substring (optionally case-insensitive). - **exact** — the message equals the text. - **regex** — the message matches a regular expression. Patterns are capped in length and limited to the `i` and `m` flags; an invalid pattern is rejected when you save. - -Optionally add a **Slack User** condition to include or exclude specific Slack user IDs (an -allowlist is the recommended way to limit who can trigger a run). +- **Slack User** (optional) — include or exclude specific Slack user IDs (an allowlist is the + recommended way to limit who can trigger a run). A message runs the automation only when **every** condition passes. The bot-mention token is stripped before matching, and messages that `@mention` the bot are handled by the interactive `@mention` flow instead — they never double-fire as triggers. -### Delivery and rate behavior +### Run feedback -| Field | Behavior | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Reply in thread** | When on (default), the run result is posted back into the originating message's thread. | -| **Max runs per hour** | Caps how many runs this automation starts per hour. Extra matching messages are recorded as a skipped run with reason `rate_limited` and start no session. Leave blank to use the default. | +A triggering message is marked with the 👀 reaction while its run is in flight; the reaction is +cleared when the run finishes. The automation does not post a result back to the channel — follow +the run in the web app. While a run is active for a thread, another matching message in that same thread is skipped (reason -`concurrent_run_active`) and the author receives an ephemeral "a run is already active" notice. A -triggering message is marked with the 👀 reaction while its run is in flight; the reaction is -cleared when the result is posted. +`concurrent_run_active`) and the author receives an ephemeral "a run is already active" notice. --- diff --git a/docs/integrations/SLACK.md b/docs/integrations/SLACK.md index 27f9d42a5..814dd4a2e 100644 --- a/docs/integrations/SLACK.md +++ b/docs/integrations/SLACK.md @@ -185,9 +185,9 @@ Slack thread continue the existing session. ## Optional Agent Notifications -Slack-started sessions always get their normal thread replies and completion messages. Agent -notifications are separate: they let an agent post an extra message to a Slack channel when you -explicitly ask for it: +Interactive Slack sessions (DMs and `@mentions`) always get their normal thread replies and +completion messages. Agent notifications are separate: they let an agent post an extra message to a +Slack channel when you explicitly ask for it: ```text When you finish, post a short summary to #eng-updates. @@ -246,18 +246,16 @@ ordinary channel messages: - Invite the bot to every channel you intend to watch. The bot only sees messages in channels it is a member of. -Then, in the web app, create a **Slack Message** automation, add a **Slack Channel** condition with -the channel ID(s), and a **Message Text** condition. See +Then, in the web app, create a **Slack Message** automation and add a **Slack Channel** condition +(pick channels by name; channel IDs also work as a fallback). Optionally add a **Message Text** +condition to filter by content. See [Slack Message Triggers](../AUTOMATIONS.md#slack-message-triggers) for the full field reference. -### Reply and rate behavior +### Run feedback -- The run result is posted back into the originating thread when **Reply in thread** is on - (default). -- A triggering message gets a 👀 reaction while its run is in flight; it is cleared when the result - posts. -- **Max runs per hour** caps how often an automation fires; messages over the cap are skipped - (recorded as `rate_limited`) and start no session. +- A triggering message gets a 👀 reaction while its run is in flight; it is cleared when the run + finishes. The automation does not post the result back to the channel — follow the run in the web + app. - A new matching message in a thread that already has an active run is skipped and the author gets an ephemeral "a run is already active" notice. From 272a6d41b013fccf6fa2a65acc2764c37e377a3f Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Thu, 25 Jun 2026 09:52:49 -0700 Subject: [PATCH 15/19] fix(slack): stop forwarding automation tool-calls to the scheduler --- .../callback-notification-service.test.ts | 25 +++++++++++++++++++ .../session/callback-notification-service.ts | 15 +++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/control-plane/src/session/callback-notification-service.test.ts b/packages/control-plane/src/session/callback-notification-service.test.ts index a8b12157b..105df93ac 100644 --- a/packages/control-plane/src/session/callback-notification-service.test.ts +++ b/packages/control-plane/src/session/callback-notification-service.test.ts @@ -271,6 +271,31 @@ describe("CallbackNotificationService", () => { expect(body.signature).toEqual(expect.any(String)); }); + it("skips automation source — the SchedulerDO has no tool-call consumer", async () => { + vi.mocked(harness.repository.getMessageCallbackContext).mockReturnValue({ + callback_context: JSON.stringify({ automationId: "a1", runId: "r1" }), + source: "automation", + }); + + await harness.service.notifyToolCall("msg-1", { + type: "tool_call", + tool: "glob", + callId: "call-1", + }); + + // No forward at all — previously this 404'd against the SchedulerDO. + const slackFetch = (harness.slackBot as unknown as { fetch: ReturnType }).fetch; + expect(slackFetch).not.toHaveBeenCalled(); + expect(harness.log.debug).toHaveBeenCalledWith( + "callback.tool_call", + expect.objectContaining({ + source: "automation", + outcome: "skipped", + skip_reason: "automation_no_consumer", + }) + ); + }); + it("skips when no callback context", async () => { vi.mocked(harness.repository.getMessageCallbackContext).mockReturnValue(null); diff --git a/packages/control-plane/src/session/callback-notification-service.ts b/packages/control-plane/src/session/callback-notification-service.ts index 8608eb36c..4c7619d32 100644 --- a/packages/control-plane/src/session/callback-notification-service.ts +++ b/packages/control-plane/src/session/callback-notification-service.ts @@ -308,6 +308,21 @@ export class CallbackNotificationService { } const source = message.source ?? null; + + // Automation runs have no tool-call progress consumer: getBinding routes them + // to the SchedulerDO, which only implements /internal/run-complete — every + // /callbacks/tool_call forward 404s. Skip rather than spam best-effort calls. + if (source === "automation") { + this.log.debug("callback.tool_call", { + message_id: messageId, + source, + tool, + outcome: "skipped", + skip_reason: "automation_no_consumer", + }); + return; + } + const binding = this.getBinding(source); if (!binding) { this.log.debug("callback.tool_call", { From e9052386207cf81567322c320242205d0966a53c Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Thu, 25 Jun 2026 10:00:48 -0700 Subject: [PATCH 16/19] feat(slack): post the agent result to Slack on automation completion --- .../src/scheduler/durable-object.ts | 38 ++++++++---- .../src/scheduler/slack-completion.test.ts | 32 ++++++++-- .../src/scheduler/slack-completion.ts | 32 +++++++--- .../session/callback-notification-service.ts | 7 ++- packages/slack-bot/src/callbacks.test.ts | 48 ++++++++++++++- packages/slack-bot/src/callbacks.ts | 61 ++++++++++++++++--- 6 files changed, 180 insertions(+), 38 deletions(-) diff --git a/packages/control-plane/src/scheduler/durable-object.ts b/packages/control-plane/src/scheduler/durable-object.ts index b089d741a..dd991b9f4 100644 --- a/packages/control-plane/src/scheduler/durable-object.ts +++ b/packages/control-plane/src/scheduler/durable-object.ts @@ -32,6 +32,7 @@ import { buildSlackSkipNotification, getSlackRunMetadata, type SlackRunMetadata, + type SlackCompletionContext, } from "./slack-completion"; import { UserStore } from "../db/user-store"; import { createRequestMetrics } from "../db/instrumented-d1"; @@ -627,6 +628,8 @@ export class SchedulerDO extends DurableObject { automationId: string; runId: string; sessionId: string; + /** Optional for resilience to version skew; the bot falls back to a reaction clear. */ + messageId?: string; success: boolean; error?: string; }; @@ -678,13 +681,22 @@ export class SchedulerDO extends DurableObject { }); } - // Slack-triggered runs clear the `eyes` reaction from their triggering - // message when they finish. The scheduler owns this fan-out (not the session - // callback path) because the message coordinates live on the run row. - // Best-effort; success/failure is surfaced in the web UI, not in Slack. + // Slack-triggered runs post the agent's result into the triggering message's + // thread and clear the `eyes` reaction when they finish. The scheduler owns + // this fan-out (not the session callback path) because the message + // coordinates live on the run row. Best-effort. const slackMeta = getSlackRunMetadata(run); if (slackMeta) { - await this.notifySlackCompletion(run, slackMeta); + const automation = await store.getById(body.automationId); + await this.notifySlackCompletion(run, slackMeta, { + sessionId: body.sessionId, + messageId: body.messageId ?? "", + success: body.success, + error: body.error, + repoFullName: automation ? `${automation.repo_owner}/${automation.repo_name}` : "", + model: automation?.model ?? "", + reasoningEffort: automation?.reasoning_effort ?? undefined, + }); } return new Response(JSON.stringify({ ok: true }), { @@ -714,21 +726,23 @@ export class SchedulerDO extends DurableObject { } /** - * Tell the slack-bot to clear the `eyes` reaction from a slack-triggered run's - * triggering message by calling its `/callbacks/automation-complete` endpoint. - * Signs the body with `INTERNAL_CALLBACK_SECRET` (in-body HMAC, matching the - * bot's other callbacks). No-ops when the run has no triggering message, when - * `SLACK_BOT` is unbound, or when the secret is unset — all best-effort. + * Tell the slack-bot to post a slack-triggered run's result into the triggering + * message's thread and clear the `eyes` reaction, via its + * `/callbacks/automation-complete` endpoint. Signs the body with + * `INTERNAL_CALLBACK_SECRET` (in-body HMAC, matching the bot's other callbacks). + * No-ops when the run has no triggering message, when `SLACK_BOT` is unbound, or + * when the secret is unset — all best-effort. */ private async notifySlackCompletion( run: AutomationRunRow, - meta: SlackRunMetadata + meta: SlackRunMetadata, + ctx: SlackCompletionContext ): Promise { const binding = this.env.SLACK_BOT; const secret = this.env.INTERNAL_CALLBACK_SECRET; if (!binding || !secret) return; - const body = buildSlackCompletionNotification(meta); + const body = buildSlackCompletionNotification(meta, ctx); if (!body) return; try { diff --git a/packages/control-plane/src/scheduler/slack-completion.test.ts b/packages/control-plane/src/scheduler/slack-completion.test.ts index ef8f2f46b..0978ed9fe 100644 --- a/packages/control-plane/src/scheduler/slack-completion.test.ts +++ b/packages/control-plane/src/scheduler/slack-completion.test.ts @@ -3,6 +3,7 @@ import { buildSlackCompletionNotification, buildSlackSkipNotification, type SlackRunMetadata, + type SlackCompletionContext, } from "./slack-completion"; function meta(overrides?: Partial): SlackRunMetadata { @@ -13,21 +14,42 @@ function meta(overrides?: Partial): SlackRunMetadata { }; } +function ctx(overrides?: Partial): SlackCompletionContext { + return { + sessionId: "sess-1", + messageId: "msg-1", + success: true, + repoFullName: "acme/web", + model: "anthropic/claude-sonnet-4-6", + ...overrides, + }; +} + describe("buildSlackCompletionNotification", () => { it("returns null for a non-slack run (no metadata)", () => { - expect(buildSlackCompletionNotification(null)).toBeNull(); + expect(buildSlackCompletionNotification(null, ctx())).toBeNull(); }); - it("returns null when there is no triggering message to clear", () => { - expect(buildSlackCompletionNotification(meta({ messageTs: "" }))).toBeNull(); + it("returns null when there is no triggering message to anchor to", () => { + expect(buildSlackCompletionNotification(meta({ messageTs: "" }), ctx())).toBeNull(); }); - it("targets the triggering message's eyes reaction", () => { - expect(buildSlackCompletionNotification(meta())).toEqual({ + it("carries the run result plus the triggering message coordinates", () => { + expect(buildSlackCompletionNotification(meta(), ctx({ error: undefined }))).toEqual({ channel: "C1", reactionMessageTs: "1700000000.000100", + sessionId: "sess-1", + messageId: "msg-1", + success: true, + repoFullName: "acme/web", + model: "anthropic/claude-sonnet-4-6", }); }); + + it("includes the failure detail when the run errored", () => { + const result = buildSlackCompletionNotification(meta(), ctx({ success: false, error: "boom" })); + expect(result).toMatchObject({ success: false, error: "boom" }); + }); }); describe("buildSlackSkipNotification", () => { diff --git a/packages/control-plane/src/scheduler/slack-completion.ts b/packages/control-plane/src/scheduler/slack-completion.ts index 2a9749787..087ed9e2f 100644 --- a/packages/control-plane/src/scheduler/slack-completion.ts +++ b/packages/control-plane/src/scheduler/slack-completion.ts @@ -38,22 +38,38 @@ export function getSlackRunMetadata( } /** - * The scheduler → bot payload for a completed slack-triggered run. Its only job - * is to clear the `eyes` reaction the bot added to the triggering message when - * the run started; the run's success/failure is surfaced in the web UI, not in - * Slack. + * Run result fields the bot needs to post the agent's final response into the + * triggering message's thread. The SchedulerDO sources `sessionId`/`messageId` + * (and success/error) from the run-complete callback and repo/model from the + * automation row. */ -export interface SlackCompletionNotification { +export interface SlackCompletionContext { + sessionId: string; + messageId: string; + success: boolean; + error?: string; + repoFullName: string; + model: string; + reasoningEffort?: string; +} + +/** + * The scheduler → bot payload for a completed slack-triggered run. Carries the + * triggering message (the thread anchor and the `eyes` reaction target) plus the + * run result the bot uses to fetch and post the agent's response in-thread. + */ +export interface SlackCompletionNotification extends SlackCompletionContext { channel: string; - /** The triggering message to clear the `eyes` reaction from. */ + /** The triggering message: the thread anchor and the `eyes` reaction target. */ reactionMessageTs: string; } export function buildSlackCompletionNotification( - meta: SlackRunMetadata | null + meta: SlackRunMetadata | null, + ctx: SlackCompletionContext ): SlackCompletionNotification | null { if (!meta?.messageTs) return null; - return { channel: meta.channel, reactionMessageTs: meta.messageTs }; + return { channel: meta.channel, reactionMessageTs: meta.messageTs, ...ctx }; } export interface SlackSkipNotification { diff --git a/packages/control-plane/src/session/callback-notification-service.ts b/packages/control-plane/src/session/callback-notification-service.ts index 4c7619d32..17f8f01d2 100644 --- a/packages/control-plane/src/session/callback-notification-service.ts +++ b/packages/control-plane/src/session/callback-notification-service.ts @@ -115,7 +115,7 @@ export class CallbackNotificationService { // Route automation callbacks to SchedulerDO (different URL + payload) if (context.source === "automation") { - return this.notifyAutomationComplete(context, success, error); + return this.notifyAutomationComplete(context, success, error, messageId); } if (!this.env.INTERNAL_CALLBACK_SECRET) { @@ -201,7 +201,8 @@ export class CallbackNotificationService { private async notifyAutomationComplete( context: { automationId: string; runId: string; automationName: string }, success: boolean, - error?: string + error: string | undefined, + messageId: string ): Promise { const binding = this.env.SCHEDULER_CALLBACK; if (!binding) { @@ -213,6 +214,8 @@ export class CallbackNotificationService { automationId: context.automationId, runId: context.runId, sessionId: this.getSessionId(), + // The message whose agent response the bot fetches to post the run result. + messageId, success, error, automationName: context.automationName, diff --git a/packages/slack-bot/src/callbacks.test.ts b/packages/slack-bot/src/callbacks.test.ts index 51c64a451..26e3e3f38 100644 --- a/packages/slack-bot/src/callbacks.test.ts +++ b/packages/slack-bot/src/callbacks.test.ts @@ -2,8 +2,19 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { Hono } from "hono"; import { computeHmacHex } from "@open-inspect/shared"; import { callbacksRouter } from "./callbacks"; +import { extractAgentResponse } from "./completion/extractor"; +import type * as ExtractorModule from "./completion/extractor"; import type { Env } from "./types"; +// The automation-complete post path reuses the interactive completion path, which +// fetches the agent's response from the control-plane. Stub that extraction so the +// handler test asserts the in-thread post + reaction clear without reconstructing +// the event-fetch protocol. +vi.mock("./completion/extractor", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, extractAgentResponse: vi.fn() }; +}); + function makeEnv(overrides: Partial = {}): Env { return { SLACK_KV: {} as KVNamespace, @@ -307,7 +318,8 @@ describe("POST /callbacks/automation-complete", () => { expect(ctx.waitUntil).not.toHaveBeenCalled(); }); - it("clears the eyes reaction and posts no thread message", async () => { + it("clears the reaction without posting when the run carries no session coordinates", async () => { + // No sessionId/messageId (e.g. version skew) — falls back to a reaction clear. const fetchMock = okFetchMock(); const payload = await signPayload(completeData()); const { response, ctx } = await postCallback("/callbacks/automation-complete", payload); @@ -321,6 +333,40 @@ describe("POST /callbacks/automation-complete", () => { expect(reaction!.body).toMatchObject({ channel: "C123", timestamp: "111.222", name: "eyes" }); }); + it("posts the agent result in-thread and clears the reaction", async () => { + vi.mocked(extractAgentResponse).mockResolvedValue({ + textContent: "Fixed the bug and opened a PR.", + toolCalls: [], + artifacts: [], + success: true, + }); + const fetchMock = okFetchMock(); + const payload = await signPayload( + completeData({ + sessionId: "session-9", + messageId: "msg-9", + success: true, + repoFullName: "acme/app", + model: "anthropic/claude-haiku-4-5", + }) + ); + const { response, ctx } = await postCallback("/callbacks/automation-complete", payload); + + expect(response.status).toBe(200); + await flushWaitUntil(ctx); + + // The agent's response is posted into the triggering message's thread. + const post = slackCall(fetchMock, "chat.postMessage"); + expect(post).toBeDefined(); + expect(post!.body).toMatchObject({ channel: "C123", thread_ts: "111.222" }); + expect(JSON.stringify(post!.body)).toContain("Fixed the bug"); + + // ...and the eyes reaction is still cleared. + const reaction = slackCall(fetchMock, "reactions.remove"); + expect(reaction).toBeDefined(); + expect(reaction!.body).toMatchObject({ channel: "C123", timestamp: "111.222", name: "eyes" }); + }); + it("does not crash when clearing the reaction throws", async () => { vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")); const payload = await signPayload(completeData()); diff --git a/packages/slack-bot/src/callbacks.ts b/packages/slack-bot/src/callbacks.ts index f1838ac81..efc62c699 100644 --- a/packages/slack-bot/src/callbacks.ts +++ b/packages/slack-bot/src/callbacks.ts @@ -105,13 +105,23 @@ function isValidToolCallPayload(payload: unknown): payload is ToolCallCallback { /** * Payload for a scheduler-owned automation completion (Slack-triggered run). The - * SchedulerDO posts this when the run finishes; the bot's only action is to clear - * the `eyes` reaction it added to the triggering message when the run started. + * SchedulerDO posts this when the run finishes. The bot posts the agent's final + * response into the triggering message's thread and clears the `eyes` reaction. + * + * The run-result fields (`sessionId`/`messageId` and the presentation fields) are + * optional so a control-plane/bot version skew degrades to a reaction clear only. */ interface AutomationCompletePayload { channel: string; - /** The triggering message to clear the `eyes` reaction from. */ + /** The triggering message: thread anchor for the result and the `eyes` reaction target. */ reactionMessageTs: string; + sessionId?: string; + messageId?: string; + success?: boolean; + error?: string; + repoFullName?: string; + model?: string; + reasoningEffort?: string; signature: string; } @@ -121,7 +131,9 @@ function isValidAutomationCompletePayload(payload: unknown): payload is Automati return ( typeof p.channel === "string" && typeof p.reactionMessageTs === "string" && - typeof p.signature === "string" + typeof p.signature === "string" && + (p.sessionId === undefined || typeof p.sessionId === "string") && + (p.messageId === undefined || typeof p.messageId === "string") ); } @@ -289,9 +301,9 @@ callbacksRouter.post("/tool_call", async (c) => { }); /** - * Callback endpoint for Slack-triggered automation completion. Clears the `eyes` - * reaction from the triggering message. The SchedulerDO owns this fan-out (it - * holds the message coordinates); this route just delivers the reaction clear. + * Callback endpoint for Slack-triggered automation completion. Posts the agent's + * final response into the triggering message's thread and clears the `eyes` + * reaction. The SchedulerDO owns this fan-out (it holds the message coordinates). */ callbacksRouter.post("/automation-complete", async (c) => { const startTime = Date.now(); @@ -476,21 +488,50 @@ async function handleCompletionCallback( } /** - * Clear the `eyes` reaction from a Slack-triggered run's triggering message when - * the run completes. Fire-and-forget. + * Post a Slack-triggered run's result into the triggering message's thread and + * clear the `eyes` reaction. Reuses the interactive completion path + * (`handleCompletionCallback`) — which fetches the agent's response, posts it + * in-thread, and clears the reaction. Falls back to a reaction clear only when + * the run carries no session coordinates (control-plane/bot version skew). + * Fire-and-forget. */ async function handleAutomationComplete( payload: AutomationCompletePayload, env: Env, traceId?: string ): Promise { + if (payload.sessionId && payload.messageId) { + await handleCompletionCallback( + { + sessionId: payload.sessionId, + messageId: payload.messageId, + success: payload.success ?? true, + error: payload.error, + timestamp: Date.now(), + signature: payload.signature, + context: { + source: "slack", + channel: payload.channel, + threadTs: payload.reactionMessageTs, + reactionMessageTs: payload.reactionMessageTs, + repoFullName: payload.repoFullName ?? "", + model: payload.model ?? "", + reasoningEffort: payload.reasoningEffort, + }, + }, + env, + traceId + ); + return; + } + + // No session coordinates — clear the reaction only. const startTime = Date.now(); const base = { trace_id: traceId, channel: payload.channel, message_ts: payload.reactionMessageTs, }; - try { await clearThinkingReaction(env, payload.channel, payload.reactionMessageTs, traceId); From 7dd1522618ab3c77d3e98bc403232cc06aadf2ec Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Thu, 25 Jun 2026 10:01:50 -0700 Subject: [PATCH 17/19] docs(slack): document the automation result posted on completion --- docs/AUTOMATIONS.md | 7 ++++--- docs/integrations/SLACK.md | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/AUTOMATIONS.md b/docs/AUTOMATIONS.md index 46ab63acd..52a279189 100644 --- a/docs/AUTOMATIONS.md +++ b/docs/AUTOMATIONS.md @@ -229,9 +229,10 @@ stripped before matching, and messages that `@mention` the bot are handled by th ### Run feedback -A triggering message is marked with the 👀 reaction while its run is in flight; the reaction is -cleared when the run finishes. The automation does not post a result back to the channel — follow -the run in the web app. +A triggering message is marked with the 👀 reaction while its run is in flight. When the run +finishes, the agent's final response is posted as a reply in that message's thread — with links to +any pull requests it opened and to the full web session — and the reaction is cleared. A failed run +posts a short failure notice in the thread instead. While a run is active for a thread, another matching message in that same thread is skipped (reason `concurrent_run_active`) and the author receives an ephemeral "a run is already active" notice. diff --git a/docs/integrations/SLACK.md b/docs/integrations/SLACK.md index 814dd4a2e..f7812e811 100644 --- a/docs/integrations/SLACK.md +++ b/docs/integrations/SLACK.md @@ -253,9 +253,10 @@ condition to filter by content. See ### Run feedback -- A triggering message gets a 👀 reaction while its run is in flight; it is cleared when the run - finishes. The automation does not post the result back to the channel — follow the run in the web - app. +- A triggering message gets a 👀 reaction while its run is in flight. +- When the run finishes, the agent's final response is posted into the triggering message's thread + (with links to any pull requests and the full session), and the reaction is cleared. A failed run + posts a short failure notice instead. - A new matching message in a thread that already has an active run is skipped and the author gets an ephemeral "a run is already active" notice. From 9a7b7a0529323be7bcb997b7548308cb877f5a9c Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Thu, 25 Jun 2026 16:00:38 -0700 Subject: [PATCH 18/19] feat(slack): steer thread follow-ups into the active run's session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reply in a thread whose automation run is still in flight was dropped with an ephemeral "a run is already active" notice, so users could never steer a running channel-trigger agent. Route such follow-ups to the active run's session via /internal/prompt (the same queue the interactive @mention/DM path uses): the message lands as the next turn on the persistent OpenCode session, and its reply posts back in-thread via a slack completion callback. The active-run lookup now precedes condition matching, since a natural reply ("also do X") won't contain the trigger keyword — conditions gate new runs, not replies. Falls back to the "already active" notice only when the run has no session yet (still starting) or the enqueue fails. Non-slack sources keep condition-then-concurrency ordering unchanged. --- docs/AUTOMATIONS.md | 12 +- docs/integrations/SLACK.md | 7 +- .../src/scheduler/durable-object.test.ts | 192 ++++++++++++++++++ .../src/scheduler/durable-object.ts | 129 ++++++++++-- .../src/webhooks/automation-event.ts | 4 +- .../scheduler-slack-events.test.ts | 52 +++-- .../slack-bot/src/channel-trigger.test.ts | 39 +++- packages/slack-bot/src/channel-trigger.ts | 17 +- 8 files changed, 394 insertions(+), 58 deletions(-) diff --git a/docs/AUTOMATIONS.md b/docs/AUTOMATIONS.md index 52a279189..6d398dae7 100644 --- a/docs/AUTOMATIONS.md +++ b/docs/AUTOMATIONS.md @@ -234,8 +234,12 @@ finishes, the agent's final response is posted as a reply in that message's thre any pull requests it opened and to the full web session — and the reaction is cleared. A failed run posts a short failure notice in the thread instead. -While a run is active for a thread, another matching message in that same thread is skipped (reason -`concurrent_run_active`) and the author receives an ephemeral "a run is already active" notice. +While a run is active for a thread, a reply in that thread **steers** the running agent: the message +is enqueued as a follow-up turn on the same session, and the agent posts its response in-thread when +that turn finishes. A follow-up does not need to match the trigger condition — conditions gate new +runs, not replies to one already in progress. If the run is still starting and has no session yet, +the reply falls back to an ephemeral "a run is already active" notice (reason +`concurrent_run_active`). A reply that arrives after the run has finished starts a fresh run. --- @@ -351,6 +355,10 @@ Event-driven automations use concurrency keys instead. For inbound webhooks, ret `idempotencyKey` are treated as the same event, but separate deliveries without a shared `idempotencyKey` can overlap. +Slack Message triggers key concurrency by thread. While a thread's run is in flight, replies in that +thread are not skipped — they are routed to the running agent as follow-up prompts (see the **Run +feedback** note under [Slack Message Triggers](#slack-message-triggers)). + This prevents overlapping sessions from interfering with each other on the same repository. --- diff --git a/docs/integrations/SLACK.md b/docs/integrations/SLACK.md index f7812e811..2ab788f14 100644 --- a/docs/integrations/SLACK.md +++ b/docs/integrations/SLACK.md @@ -257,8 +257,11 @@ condition to filter by content. See - When the run finishes, the agent's final response is posted into the triggering message's thread (with links to any pull requests and the full session), and the reaction is cleared. A failed run posts a short failure notice instead. -- A new matching message in a thread that already has an active run is skipped and the author gets - an ephemeral "a run is already active" notice. +- While a run is still in flight, a reply in its thread is routed to the running agent as a + follow-up prompt: it steers that session (queued behind the current turn) instead of being + dropped. The reply gets its own 👀 reaction and in-thread response, and it does **not** need to + match the trigger's text condition — conditions gate new runs, not replies to one in progress. A + reply that arrives after the run has finished starts a fresh run instead. ### Threat model diff --git a/packages/control-plane/src/scheduler/durable-object.test.ts b/packages/control-plane/src/scheduler/durable-object.test.ts index 611856d0d..9c8272f57 100644 --- a/packages/control-plane/src/scheduler/durable-object.test.ts +++ b/packages/control-plane/src/scheduler/durable-object.test.ts @@ -31,6 +31,8 @@ function createMockStore() { return { getOverdueAutomations: vi.fn().mockResolvedValue([]), getActiveRunForAutomation: vi.fn().mockResolvedValue(null), + getActiveRunForKey: vi.fn().mockResolvedValue(null), + recordSkippedRun: vi.fn().mockResolvedValue(undefined), createRunAndAdvanceSchedule: vi.fn().mockResolvedValue(undefined), insertRun: vi.fn().mockResolvedValue(undefined), updateRun: vi.fn().mockResolvedValue(undefined), @@ -77,6 +79,15 @@ vi.mock("../db/user-store", () => ({ }), })); +const mockGetSlackAutomationsForChannel = vi.fn().mockResolvedValue([]); +vi.mock("../db/slack-channel-store", () => ({ + SlackChannelStore: vi.fn().mockImplementation(function () { + return { + getSlackAutomationsForChannel: mockGetSlackAutomationsForChannel, + }; + }), +})); + vi.mock("../auth/crypto", () => ({ generateId: vi.fn(() => `id-${Math.random().toString(36).slice(2, 8)}`), })); @@ -154,6 +165,33 @@ async function getInitBody(fetchMock: ReturnType): Promise; } +async function getPromptBody( + fetchMock: ReturnType +): Promise> { + const promptCall = fetchMock.mock.calls.find((call) => { + const input = call[0]; + const url = + typeof input === "string" ? input : input instanceof Request ? input.url : String(input); + return new URL(url).pathname === "/internal/prompt"; + }); + + expect(promptCall).toBeDefined(); + const [input, init] = promptCall!; + if (input instanceof Request) { + return (await input.json()) as Record; + } + return JSON.parse(String(init?.body)) as Record; +} + +function promptCallCount(fetchMock: ReturnType): number { + return fetchMock.mock.calls.filter((call) => { + const input = call[0]; + const url = + typeof input === "string" ? input : input instanceof Request ? input.url : String(input); + return new URL(url).pathname === "/internal/prompt"; + }).length; +} + function createEnv(overrides?: Partial): Env { const sessionStub = createMockSessionStub(); return { @@ -200,12 +238,55 @@ const sampleAutomation = { deleted_at: null, }; +const sampleSlackAutomation = { + ...sampleAutomation, + id: "auto-slack", + name: "Slack triage", + trigger_type: "slack_event", + schedule_cron: null, + next_run_at: null, + event_type: "message.posted", + trigger_config: JSON.stringify({ + conditions: [ + { type: "slack_channel", operator: "any_of", value: ["C1"] }, + { type: "text_match", operator: "contains", value: { pattern: "deploy" } }, + ], + }), +}; + +function makeSlackEvent(overrides?: Record) { + const ts = "1700000000.000200"; + return { + source: "slack", + eventType: "message.posted", + triggerKey: `slack:msg:C1:${ts}`, + concurrencyKey: "slack:C1:thread-root", + contextBlock: "A message was posted in #ops.", + meta: {}, + channelId: "C1", + threadTs: "1700000000.000100", + ts, + actorUserId: "U1", + text: "please deploy the api", + ...overrides, + }; +} + +function slackEventRequest(overrides?: Record): Request { + return new Request("http://internal/internal/event", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(makeSlackEvent(overrides)), + }); +} + // ─── Tests ─────────────────────────────────────────────────────────────────── describe("SchedulerDO", () => { beforeEach(() => { vi.clearAllMocks(); mockStore = createMockStore(); + mockGetSlackAutomationsForChannel.mockResolvedValue([]); }); describe("/internal/health", () => { @@ -1042,6 +1123,117 @@ describe("SchedulerDO", () => { }); }); + describe("/internal/event — slack steering", () => { + it("steers the active run's session even when the follow-up fails trigger conditions", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + mockStore.getActiveRunForKey.mockResolvedValue({ + id: "active-run", + status: "running", + session_id: "sess-running", + }); + + const env = createEnv(); + const stub = env.SESSION.get(env.SESSION.idFromName("any")); + const fetchMock = vi.mocked(stub.fetch); + + const scheduler = createSchedulerDO(env); + // A natural follow-up reply won't repeat the "deploy" trigger keyword, yet + // it must still steer the running session — conditions gate new runs only. + const res = await scheduler.fetch( + slackEventRequest({ text: "thanks — also update the changelog" }) + ); + + expect(res.status).toBe(200); + const body = await res.json<{ triggered: number; skipped: number; steered: number }>(); + expect(body).toEqual({ triggered: 0, skipped: 0, steered: 1 }); + + // The follow-up was enqueued onto the existing session as a slack-sourced + // turn, so its reply posts back in-thread via /callbacks/complete. + const promptBody = await getPromptBody(fetchMock); + expect(promptBody.source).toBe("slack"); + expect(promptBody.content).toBe("thanks — also update the changelog"); + expect(promptBody.authorId).toBe("slack:U1"); + expect(promptBody.callbackContext).toMatchObject({ + source: "slack", + channel: "C1", + threadTs: "1700000000.000100", + reactionMessageTs: "1700000000.000200", + repoFullName: "acme/web-app", + }); + + // No skip row, no "already active" ephemeral. + expect(mockStore.recordSkippedRun).not.toHaveBeenCalled(); + }); + + it("anchors the thread to the message ts for a top-level (non-reply) trigger", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + mockStore.getActiveRunForKey.mockResolvedValue({ + id: "active-run", + status: "running", + session_id: "sess-running", + }); + + const env = createEnv(); + const stub = env.SESSION.get(env.SESSION.idFromName("any")); + const fetchMock = vi.mocked(stub.fetch); + + const scheduler = createSchedulerDO(env); + // No threadTs → the follow-up should anchor to its own ts. + await scheduler.fetch(slackEventRequest({ threadTs: undefined })); + + const promptBody = await getPromptBody(fetchMock); + expect(promptBody.callbackContext).toMatchObject({ + threadTs: "1700000000.000200", + reactionMessageTs: "1700000000.000200", + }); + }); + + it("falls back to a concurrency skip when the active run has no session yet", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + mockStore.getActiveRunForKey.mockResolvedValue({ + id: "starting-run", + status: "starting", + session_id: null, + }); + + const env = createEnv(); + const stub = env.SESSION.get(env.SESSION.idFromName("any")); + const fetchMock = vi.mocked(stub.fetch); + + const scheduler = createSchedulerDO(env); + const res = await scheduler.fetch(slackEventRequest()); + + const body = await res.json<{ triggered: number; skipped: number; steered: number }>(); + expect(body).toEqual({ triggered: 0, skipped: 1, steered: 0 }); + expect(mockStore.recordSkippedRun).toHaveBeenCalled(); + // No prompt reached any session. + expect(promptCallCount(fetchMock)).toBe(0); + }); + + it("records a skip when steering the session fails", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + mockStore.getActiveRunForKey.mockResolvedValue({ + id: "active-run", + status: "running", + session_id: "sess-running", + }); + + // Session DO rejects the prompt enqueue → steering fails → fall back to skip. + const failingStub = { + fetch: vi.fn().mockResolvedValue(new Response("boom", { status: 500 })), + } as never; + const env = createEnv(); + vi.mocked(env.SESSION.get).mockReturnValue(failingStub); + + const scheduler = createSchedulerDO(env); + const res = await scheduler.fetch(slackEventRequest()); + + const body = await res.json<{ triggered: number; skipped: number; steered: number }>(); + expect(body).toEqual({ triggered: 0, skipped: 1, steered: 0 }); + expect(mockStore.recordSkippedRun).toHaveBeenCalled(); + }); + }); + it("returns 404 for unknown routes", async () => { const scheduler = createSchedulerDO(); const res = await scheduler.fetch(new Request("http://internal/unknown", { method: "GET" })); diff --git a/packages/control-plane/src/scheduler/durable-object.ts b/packages/control-plane/src/scheduler/durable-object.ts index dd991b9f4..678392c1f 100644 --- a/packages/control-plane/src/scheduler/durable-object.ts +++ b/packages/control-plane/src/scheduler/durable-object.ts @@ -17,6 +17,7 @@ import { type AutomationCallbackContext, type AutomationEvent, type SlackAutomationEvent, + type SlackCallbackContext, type TriggerConfig, } from "@open-inspect/shared"; import { @@ -436,6 +437,8 @@ export class SchedulerDO extends DurableObject { let triggered = 0; let skipped = 0; + // Follow-ups routed into an already-active thread's session (slack steering). + let steered = 0; // Surface at most one concurrency-skip ephemeral per event, even when // several automations watch the same thread and all skip. let concurrencySkipped = false; @@ -443,7 +446,34 @@ export class SchedulerDO extends DurableObject { for (const automation of candidates) { const now = Date.now(); - // 2. Evaluate conditions + // Active run for this automation in this thread/key. Looked up before + // condition matching because a slack follow-up reply steers the running + // session regardless of the trigger conditions — a natural reply ("also + // do X") won't contain the trigger keyword that started the run. + const activeRun = await store.getActiveRunForKey(automation.id, event.concurrencyKey); + + // A slack message in a thread that already has an active run is a + // follow-up: enqueue it onto that run's session as the next turn (queued + // behind the in-flight one) so the user can steer the running agent. Its + // reply posts back in-thread via the slack completion callback, exactly + // like an interactive follow-up. Falls back to the "already active" notice + // when there is no session to steer yet (run still starting) or the + // enqueue fails. + if (event.source === "slack" && activeRun) { + if ( + activeRun.session_id && + (await this.steerActiveRun(activeRun.session_id, automation, event)) + ) { + steered++; + } else { + await this.recordSlackSkip(store, automation.id, event, "concurrent_run_active"); + concurrencySkipped = true; + skipped++; + } + continue; + } + + // Evaluate trigger conditions (these gate starting a NEW run). const config: TriggerConfig = automation.trigger_config ? JSON.parse(automation.trigger_config) : { conditions: [] }; @@ -451,20 +481,14 @@ export class SchedulerDO extends DurableObject { continue; } - // 3. Concurrency check (per-event-instance) - const activeRun = await store.getActiveRunForKey(automation.id, event.concurrencyKey); + // Concurrency guard for non-slack sources (slack's active-run case is + // handled above): never start a second concurrent run for the same key. if (activeRun) { - // For slack, persist the skip so the bot can surface "a run is already - // active for this thread" and so the drop is auditable. - if (event.source === "slack") { - await this.recordSlackSkip(store, automation.id, event, "concurrent_run_active"); - concurrencySkipped = true; - } skipped++; continue; } - // 4. Create run (dedup via unique index on trigger_key) + // Create run (dedup via unique index on trigger_key) const runId = generateId(); try { await store.insertRun({ @@ -490,7 +514,7 @@ export class SchedulerDO extends DurableObject { throw e; } - // 5. Create session + send prompt (with event context prepended) + // Create session + send prompt (with event context prepended) try { const instructions = `${event.contextBlock}\n---\n\n${automation.instructions}`; const { sessionId } = await this.createSessionForAutomation(automation, runId); @@ -520,10 +544,11 @@ export class SchedulerDO extends DurableObject { trigger_key: event.triggerKey, triggered, skipped, + steered, candidates: candidates.length, }); - return new Response(JSON.stringify({ triggered, skipped }), { + return new Response(JSON.stringify({ triggered, skipped, steered }), { headers: { "Content-Type": "application/json" }, }); } @@ -897,9 +922,6 @@ export class SchedulerDO extends DurableObject { runId: string, instructionsOverride?: string ): Promise { - const doId = this.env.SESSION.idFromName(sessionId); - const stub = this.env.SESSION.get(doId); - const callbackContext: AutomationCallbackContext = { source: "automation", automationId: automation.id, @@ -907,15 +929,80 @@ export class SchedulerDO extends DurableObject { automationName: automation.name, }; + await this.enqueueSessionPrompt(sessionId, { + content: instructionsOverride ?? automation.instructions, + authorId: automation.created_by, + source: "automation", + callbackContext, + }); + } + + /** + * Route a follow-up slack message in an active thread to the run's existing + * session as the next turn, letting the user steer the running agent instead + * of having the message dropped by the per-thread concurrency guard. The turn + * queues behind the in-flight one; its reply posts back in-thread via the + * slack completion callback (source "slack"), exactly like an interactive + * follow-up. Returns false when the enqueue fails, so the caller can fall back + * to the "already active" notice. + */ + private async steerActiveRun( + sessionId: string, + automation: AutomationRow, + event: SlackAutomationEvent + ): Promise { + const callbackContext: SlackCallbackContext = { + source: "slack", + channel: event.channelId, + // Post in the existing thread; for a reply, threadTs is the thread root. + threadTs: event.threadTs ?? event.ts, + // React on (and later clear) the follow-up message itself. + reactionMessageTs: event.ts, + repoFullName: `${automation.repo_owner}/${automation.repo_name}`, + model: automation.model, + reasoningEffort: automation.reasoning_effort ?? undefined, + }; + + try { + await this.enqueueSessionPrompt(sessionId, { + content: event.text, + authorId: `slack:${event.actorUserId}`, + source: "slack", + callbackContext, + }); + this.log.info("Steered active run with slack follow-up", { + event: "scheduler.slack_steer", + automation_id: automation.id, + session_id: sessionId, + channel: event.channelId, + }); + return true; + } catch (e) { + this.log.warn("Failed to steer active run; falling back to skip notice", { + event: "scheduler.slack_steer_failed", + automation_id: automation.id, + session_id: sessionId, + error: e instanceof Error ? e : new Error(String(e)), + }); + return false; + } + } + + /** Enqueue a prompt onto a session's queue via its DO `/internal/prompt` route. */ + private async enqueueSessionPrompt( + sessionId: string, + body: { + content: string; + authorId: string; + source: string; + callbackContext: AutomationCallbackContext | SlackCallbackContext; + } + ): Promise { + const stub = this.env.SESSION.get(this.env.SESSION.idFromName(sessionId)); const promptResponse = await stub.fetch("http://internal/internal/prompt", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - content: instructionsOverride ?? automation.instructions, - authorId: automation.created_by, - source: "automation", - callbackContext, - }), + body: JSON.stringify(body), }); if (!promptResponse.ok) { diff --git a/packages/control-plane/src/webhooks/automation-event.ts b/packages/control-plane/src/webhooks/automation-event.ts index e06a6300e..47216052a 100644 --- a/packages/control-plane/src/webhooks/automation-event.ts +++ b/packages/control-plane/src/webhooks/automation-event.ts @@ -81,9 +81,9 @@ export function createAutomationEventRoute(opts: { return json({ ok: false, error: "Failed to reach scheduler" }, 502); } - let result: { triggered: number; skipped: number }; + let result: { triggered: number; skipped: number; steered?: number }; try { - result = await response.json<{ triggered: number; skipped: number }>(); + result = await response.json<{ triggered: number; skipped: number; steered?: number }>(); } catch { return json({ ok: false, error: "Invalid response from scheduler" }, 502); } diff --git a/packages/control-plane/test/integration/scheduler-slack-events.test.ts b/packages/control-plane/test/integration/scheduler-slack-events.test.ts index df0ae5dd0..e0bcbdfb8 100644 --- a/packages/control-plane/test/integration/scheduler-slack-events.test.ts +++ b/packages/control-plane/test/integration/scheduler-slack-events.test.ts @@ -166,17 +166,21 @@ describe("SchedulerDO /internal/event — slack (integration)", () => { expect(runs.total).toBe(0); }); - it("records a concurrency skip for slack and creates no new materialized run", async () => { + it("falls back to a concurrency skip when the active run has no session to steer", async () => { const store = new AutomationStore(env.DB); const id = await seedSlackAutomation(store); + // A run still in "starting" has not created its session yet, so a follow-up + // has nothing to steer and is dropped with the "already active" notice. + // (The steering path — where the active run has a session_id — is covered in + // the SchedulerDO unit tests with a mocked session, so it doesn't attempt a + // real sandbox spawn here.) const concurrencyKey = "slack:C1:thread-1"; await store.insertRun( makeRun(id, { id: "active-1", - status: "running", - session_id: "sess-x", - started_at: Date.now(), + status: "starting", + session_id: null, concurrency_key: concurrencyKey, trigger_key: "slack:msg:C1:first", }) @@ -185,9 +189,10 @@ describe("SchedulerDO /internal/event — slack (integration)", () => { const res = await sendEvent( makeSlackEvent({ text: "deploy", concurrencyKey, triggerKey: "slack:msg:C1:second" }) ); - const body = await res.json<{ triggered: number; skipped: number }>(); + const body = await res.json<{ triggered: number; skipped: number; steered: number }>(); expect(body.skipped).toBe(1); expect(body.triggered).toBe(0); + expect(body.steered).toBe(0); const runs = await store.listRunsForAutomation(id, { limit: 20, offset: 0 }); const skip = runs.runs.find((r) => r.skip_reason === "concurrent_run_active"); @@ -195,21 +200,36 @@ describe("SchedulerDO /internal/event — slack (integration)", () => { expect(JSON.parse(skip!.trigger_run_metadata!).channel).toBe("C1"); }); - it("dedups a duplicate slack message with the same trigger_key", async () => { + it("steers the running session on a follow-up reply instead of dropping it", async () => { const store = new AutomationStore(env.DB); const id = await seedSlackAutomation(store); - const event = makeSlackEvent({ - text: "deploy", - triggerKey: "slack:msg:C1:dup", - concurrencyKey: "slack:C1:dup", - }); - await sendEvent(event); - const res2 = await sendEvent(event); - const body2 = await res2.json<{ triggered: number; skipped: number }>(); - expect(body2.skipped).toBe(1); + const concurrencyKey = "slack:C1:thread-steer"; + // Root message triggers the run and creates its session. + const rootRes = await sendEvent( + makeSlackEvent({ text: "deploy the api", concurrencyKey, triggerKey: "slack:msg:C1:root" }) + ); + const rootBody = await rootRes.json<{ triggered: number }>(); + expect(rootBody.triggered).toBe(1); + // A follow-up reply in the same thread (same concurrency key, new message) + // is routed to the running session as a steering turn — not skipped. + const followRes = await sendEvent( + makeSlackEvent({ + text: "also update the changelog", + concurrencyKey, + triggerKey: "slack:msg:C1:reply", + }) + ); + const followBody = await followRes.json<{ + triggered: number; + skipped: number; + steered: number; + }>(); + expect(followBody).toEqual({ triggered: 0, skipped: 0, steered: 1 }); + + // No concurrency-skip row recorded — the follow-up was steered, not dropped. const runs = await store.listRunsForAutomation(id, { limit: 20, offset: 0 }); - expect(runs.runs.filter((r) => r.trigger_key === "slack:msg:C1:dup")).toHaveLength(1); + expect(runs.runs.find((r) => r.skip_reason === "concurrent_run_active")).toBeUndefined(); }); }); diff --git a/packages/slack-bot/src/channel-trigger.test.ts b/packages/slack-bot/src/channel-trigger.test.ts index 53dd41333..5a17120bb 100644 --- a/packages/slack-bot/src/channel-trigger.test.ts +++ b/packages/slack-bot/src/channel-trigger.test.ts @@ -52,7 +52,7 @@ function createMockKV() { } /** Control-plane fetch mock: serves the watched-channel set and records forwards. */ -function makeControlPlaneFetch(watched: string[], triggered: number) { +function makeControlPlaneFetch(watched: string[], triggered: number, steered: number) { return vi.fn(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : String(input); if (url.includes("/integration-settings/slack/watched-channels")) { @@ -62,25 +62,27 @@ function makeControlPlaneFetch(watched: string[], triggered: number) { }); } if (url.includes("/internal/slack-event")) { - return new Response( - JSON.stringify({ ok: true, triggered, skipped: triggered === 0 ? 1 : 0 }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); + const skipped = triggered === 0 && steered === 0 ? 1 : 0; + return new Response(JSON.stringify({ ok: true, triggered, skipped, steered }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); } return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }); }); } function makeEnv( - opts: { triggersEnabled?: boolean; watched?: string[]; triggered?: number } = {} + opts: { triggersEnabled?: boolean; watched?: string[]; triggered?: number; steered?: number } = {} ): Env { return { SLACK_KV: createMockKV() as unknown as KVNamespace, CONTROL_PLANE: { - fetch: makeControlPlaneFetch(opts.watched ?? ["C123"], opts.triggered ?? 1), + fetch: makeControlPlaneFetch( + opts.watched ?? ["C123"], + opts.triggered ?? 1, + opts.steered ?? 0 + ), } as unknown as Fetcher, DEPLOYMENT_NAME: "test", CONTROL_PLANE_URL: "https://control-plane.test", @@ -188,6 +190,23 @@ describe("channel-message automation triggers (POST /events)", () => { expect(mockAddReaction).not.toHaveBeenCalled(); }); + it("reacts when a follow-up steers an active run (triggered: 0, steered: 1)", async () => { + const env = makeEnv({ triggersEnabled: true, watched: ["C123"], triggered: 0, steered: 1 }); + const ctx = makeCtx(); + + // A reply in an active thread is forwarded and steers the running session; + // the bot still marks the follow-up message with 👀. + const res = await app.fetch( + channelMessageRequest({ ts: "1700000000.000200", thread_ts: "1700000000.000100" }), + env, + ctx + ); + expect(res.status).toBe(200); + await flushWaitUntil(ctx); + + expect(mockAddReaction).toHaveBeenCalledWith("xoxb-test", "C123", "1700000000.000200", "eyes"); + }); + it("does not forward when the kill switch is off (default)", async () => { const env = makeEnv({ triggersEnabled: false, watched: ["C123"] }); const ctx = makeCtx(); diff --git a/packages/slack-bot/src/channel-trigger.ts b/packages/slack-bot/src/channel-trigger.ts index 0b1fe78bb..a552f94ab 100644 --- a/packages/slack-bot/src/channel-trigger.ts +++ b/packages/slack-bot/src/channel-trigger.ts @@ -138,20 +138,27 @@ async function forwardSlackEvent( return; } - const result = (await response.json()) as { triggered?: number; skipped?: number }; + const result = (await response.json()) as { + triggered?: number; + skipped?: number; + steered?: number; + }; log.info("slack_trigger.forward", { trace_id: traceId, outcome: "success", channel_id: event.channelId, triggered: result.triggered ?? 0, skipped: result.skipped ?? 0, + steered: result.steered ?? 0, duration_ms: Date.now() - startTime, }); - // React 👀 on the triggering message only when a run actually materialized, - // so unmatched channel chatter doesn't get marked. The scheduler clears it - // via /callbacks/automation-complete when the run finishes. - if ((result.triggered ?? 0) >= 1) { + // React 👀 on the triggering message when a new run materializes or a + // follow-up steers an already-active run's session, so unmatched channel + // chatter stays unmarked. The reaction clears when the work finishes — via + // /callbacks/automation-complete for a new run, or /callbacks/complete for a + // steered follow-up turn. + if ((result.triggered ?? 0) >= 1 || (result.steered ?? 0) >= 1) { const reaction = await addReaction(env.SLACK_BOT_TOKEN, event.channelId, event.ts, "eyes"); if (!reaction.ok && reaction.error !== "already_reacted") { log.warn("slack_trigger.react", { From cbbac17a54d5f578faba265bb6708bd0fbe6fd68 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Thu, 25 Jun 2026 18:53:55 -0700 Subject: [PATCH 19/19] feat(slack): continue thread session on replies after run completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broaden the channel-automation thread lookup from the active run to the latest run with a session within a 24h window, so every reply continues the same session — during the run and after it completes or fails — matching the interactive @mention path. Steering precedes condition matching: a reply is a steer, not a new trigger. - add AutomationStore.getLatestSteerableRunForThread + the backing index idx_runs_thread_continuity (migration 0028) - SLACK_THREAD_CONTINUITY_WINDOW_MS (24h, measured from the root trigger, matching the interactive thread->session KV TTL) - steer failure falls through to the trigger path (stale-session recovery) - non-slack dispatch is unchanged; the active-run lookup just moves after condition matching Extends 9a7b7a05's in-run steering to cover replies after completion. --- docs/AUTOMATIONS.md | 21 ++-- docs/integrations/SLACK.md | 11 +- .../control-plane/src/db/automation-store.ts | 24 ++++ .../src/scheduler/durable-object.test.ts | 103 +++++++++++++++--- .../src/scheduler/durable-object.ts | 83 ++++++++------ .../test/integration/automation-store.test.ts | 94 ++++++++++++++++ .../scheduler-slack-events.test.ts | 49 +++++++++ .../0028_slack_thread_continuity_index.sql | 7 ++ 8 files changed, 328 insertions(+), 64 deletions(-) create mode 100644 terraform/d1/migrations/0028_slack_thread_continuity_index.sql diff --git a/docs/AUTOMATIONS.md b/docs/AUTOMATIONS.md index 6d398dae7..b7ef15e73 100644 --- a/docs/AUTOMATIONS.md +++ b/docs/AUTOMATIONS.md @@ -234,12 +234,14 @@ finishes, the agent's final response is posted as a reply in that message's thre any pull requests it opened and to the full web session — and the reaction is cleared. A failed run posts a short failure notice in the thread instead. -While a run is active for a thread, a reply in that thread **steers** the running agent: the message -is enqueued as a follow-up turn on the same session, and the agent posts its response in-thread when -that turn finishes. A follow-up does not need to match the trigger condition — conditions gate new -runs, not replies to one already in progress. If the run is still starting and has no session yet, -the reply falls back to an ephemeral "a run is already active" notice (reason -`concurrent_run_active`). A reply that arrives after the run has finished starts a fresh run. +Every reply in a thread **continues the same session** — during the run and after it finishes — for +up to 24 hours after the thread's first trigger, exactly like replying in an `@mention` thread. The +reply is enqueued as a follow-up turn on that session (re-spawning it from a snapshot if it had gone +idle), and the agent posts its response in-thread when the turn finishes. A follow-up does not need +to match the trigger condition — conditions gate new runs, not replies that continue an existing +thread. If a reply races the very first trigger before its session exists, it falls back to an +ephemeral "a run is already active" notice (reason `concurrent_run_active`); a reply more than 24 +hours after the first trigger starts a fresh run. --- @@ -355,9 +357,10 @@ Event-driven automations use concurrency keys instead. For inbound webhooks, ret `idempotencyKey` are treated as the same event, but separate deliveries without a shared `idempotencyKey` can overlap. -Slack Message triggers key concurrency by thread. While a thread's run is in flight, replies in that -thread are not skipped — they are routed to the running agent as follow-up prompts (see the **Run -feedback** note under [Slack Message Triggers](#slack-message-triggers)). +Slack Message triggers key concurrency by thread. Replies in a thread are not skipped — for 24 hours +after the thread's first trigger they continue the same session (during the run and after it +finishes), routed to that session as follow-up prompts (see the **Run feedback** note under +[Slack Message Triggers](#slack-message-triggers)). This prevents overlapping sessions from interfering with each other on the same repository. diff --git a/docs/integrations/SLACK.md b/docs/integrations/SLACK.md index 2ab788f14..9c00e6bec 100644 --- a/docs/integrations/SLACK.md +++ b/docs/integrations/SLACK.md @@ -257,11 +257,12 @@ condition to filter by content. See - When the run finishes, the agent's final response is posted into the triggering message's thread (with links to any pull requests and the full session), and the reaction is cleared. A failed run posts a short failure notice instead. -- While a run is still in flight, a reply in its thread is routed to the running agent as a - follow-up prompt: it steers that session (queued behind the current turn) instead of being - dropped. The reply gets its own 👀 reaction and in-thread response, and it does **not** need to - match the trigger's text condition — conditions gate new runs, not replies to one in progress. A - reply that arrives after the run has finished starts a fresh run instead. +- Every reply in a thread continues the same session — during the run and after it finishes — for up + to 24 hours after the thread's first trigger, like replying in an `@mention` thread. The reply is + routed to that session as a follow-up prompt (re-spawned from a snapshot if it had gone idle), + gets its own 👀 reaction and in-thread response, and does **not** need to match the trigger's text + condition — conditions gate new runs, not replies that continue a thread. A reply more than 24 + hours after the first trigger starts a fresh run. ### Threat model diff --git a/packages/control-plane/src/db/automation-store.ts b/packages/control-plane/src/db/automation-store.ts index cc8e18ce5..87e8a7a96 100644 --- a/packages/control-plane/src/db/automation-store.ts +++ b/packages/control-plane/src/db/automation-store.ts @@ -563,6 +563,30 @@ export class AutomationStore { .first(); } + /** + * The most recent materialized run (any status, with a session) for a thread's + * concurrency key, created at/after `sinceMs`. Powers Slack thread-session + * continuity: a reply continues this run's session regardless of run status. + * Excludes skipped rows and not-yet-started runs (session_id NULL). Backed by + * idx_runs_thread_continuity (migration 0028). + */ + async getLatestSteerableRunForThread( + automationId: string, + concurrencyKey: string | null, + sinceMs: number + ): Promise { + if (concurrencyKey === null) return null; + return this.db + .prepare( + `SELECT * FROM automation_runs + WHERE automation_id = ? AND concurrency_key = ? + AND session_id IS NOT NULL AND created_at >= ? + ORDER BY created_at DESC LIMIT 1` + ) + .bind(automationId, concurrencyKey, sinceMs) + .first(); + } + // --- Recovery sweep queries --- // Backed by partial indexes (migration 0024); `status` must stay a literal, not // a bound param, or the planner skips the index and full-scans automation_runs. diff --git a/packages/control-plane/src/scheduler/durable-object.test.ts b/packages/control-plane/src/scheduler/durable-object.test.ts index 9c8272f57..95a8d66a7 100644 --- a/packages/control-plane/src/scheduler/durable-object.test.ts +++ b/packages/control-plane/src/scheduler/durable-object.test.ts @@ -32,6 +32,7 @@ function createMockStore() { getOverdueAutomations: vi.fn().mockResolvedValue([]), getActiveRunForAutomation: vi.fn().mockResolvedValue(null), getActiveRunForKey: vi.fn().mockResolvedValue(null), + getLatestSteerableRunForThread: vi.fn().mockResolvedValue(null), recordSkippedRun: vi.fn().mockResolvedValue(undefined), createRunAndAdvanceSchedule: vi.fn().mockResolvedValue(undefined), insertRun: vi.fn().mockResolvedValue(undefined), @@ -1123,10 +1124,10 @@ describe("SchedulerDO", () => { }); }); - describe("/internal/event — slack steering", () => { - it("steers the active run's session even when the follow-up fails trigger conditions", async () => { + describe("/internal/event — slack thread continuity", () => { + it("steers the thread session even when the follow-up fails trigger conditions", async () => { mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); - mockStore.getActiveRunForKey.mockResolvedValue({ + mockStore.getLatestSteerableRunForThread.mockResolvedValue({ id: "active-run", status: "running", session_id: "sess-running", @@ -1138,7 +1139,7 @@ describe("SchedulerDO", () => { const scheduler = createSchedulerDO(env); // A natural follow-up reply won't repeat the "deploy" trigger keyword, yet - // it must still steer the running session — conditions gate new runs only. + // it must still steer the thread's session — conditions gate new runs only. const res = await scheduler.fetch( slackEventRequest({ text: "thanks — also update the changelog" }) ); @@ -1147,6 +1148,14 @@ describe("SchedulerDO", () => { const body = await res.json<{ triggered: number; skipped: number; steered: number }>(); expect(body).toEqual({ triggered: 0, skipped: 0, steered: 1 }); + // The continuity lookup is scoped to the thread's concurrency key and a time + // window measured from now (24h back). + expect(mockStore.getLatestSteerableRunForThread).toHaveBeenCalledWith( + "auto-slack", + "slack:C1:thread-root", + expect.any(Number) + ); + // The follow-up was enqueued onto the existing session as a slack-sourced // turn, so its reply posts back in-thread via /callbacks/complete. const promptBody = await getPromptBody(fetchMock); @@ -1161,13 +1170,45 @@ describe("SchedulerDO", () => { repoFullName: "acme/web-app", }); - // No skip row, no "already active" ephemeral. + // A steer is not a new trigger and not a skip. + expect(mockStore.insertRun).not.toHaveBeenCalled(); expect(mockStore.recordSkippedRun).not.toHaveBeenCalled(); }); - it("anchors the thread to the message ts for a top-level (non-reply) trigger", async () => { + it("continues the same session on a reply after the run has completed", async () => { mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); - mockStore.getActiveRunForKey.mockResolvedValue({ + // The thread's run finished, but its session is still steerable within the + // window — like replying after an @mention turn ends. + mockStore.getLatestSteerableRunForThread.mockResolvedValue({ + id: "done-run", + status: "completed", + session_id: "sess-done", + }); + + const env = createEnv(); + const stub = env.SESSION.get(env.SESSION.idFromName("any")); + const fetchMock = vi.mocked(stub.fetch); + + const scheduler = createSchedulerDO(env); + const res = await scheduler.fetch( + slackEventRequest({ text: "actually, can you also bump the version?" }) + ); + + const body = await res.json<{ triggered: number; skipped: number; steered: number }>(); + expect(body).toEqual({ triggered: 0, skipped: 0, steered: 1 }); + + const promptBody = await getPromptBody(fetchMock); + expect(promptBody.source).toBe("slack"); + expect(promptBody.content).toBe("actually, can you also bump the version?"); + // Routed to the completed run's session — no new run, and the concurrency + // guard is never consulted (the steer short-circuits the loop). + expect(mockStore.insertRun).not.toHaveBeenCalled(); + expect(mockStore.getActiveRunForKey).not.toHaveBeenCalled(); + }); + + it("anchors the thread to the message ts for a top-level (non-reply) follow-up", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + mockStore.getLatestSteerableRunForThread.mockResolvedValue({ id: "active-run", status: "running", session_id: "sess-running", @@ -1188,8 +1229,27 @@ describe("SchedulerDO", () => { }); }); - it("falls back to a concurrency skip when the active run has no session yet", async () => { + it("starts a fresh run when no steerable session exists (outside the window)", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + // Outside the continuity window → no steerable run, and no active run. + mockStore.getLatestSteerableRunForThread.mockResolvedValue(null); + mockStore.getActiveRunForKey.mockResolvedValue(null); + + const scheduler = createSchedulerDO(); + // Matching text so the trigger conditions pass. + const res = await scheduler.fetch(slackEventRequest()); + + const body = await res.json<{ triggered: number; skipped: number; steered: number }>(); + expect(body).toEqual({ triggered: 1, skipped: 0, steered: 0 }); + expect(mockStore.insertRun).toHaveBeenCalledWith( + expect.objectContaining({ automation_id: "auto-slack", status: "starting" }) + ); + }); + + it("posts the already-active notice for a reply racing the initial trigger (no session yet)", async () => { mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + // Run is still starting → not yet steerable, but it blocks a second run. + mockStore.getLatestSteerableRunForThread.mockResolvedValue(null); mockStore.getActiveRunForKey.mockResolvedValue({ id: "starting-run", status: "starting", @@ -1210,15 +1270,20 @@ describe("SchedulerDO", () => { expect(promptCallCount(fetchMock)).toBe(0); }); - it("records a skip when steering the session fails", async () => { + it("falls through to a new trigger when steering the session fails", async () => { mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); - mockStore.getActiveRunForKey.mockResolvedValue({ - id: "active-run", - status: "running", - session_id: "sess-running", + // A completed run is steerable, but the enqueue will fail; with the run no + // longer active, the reply is re-evaluated as a new trigger (it matches), + // mirroring the @mention path's stale-session → new-session recovery. + mockStore.getLatestSteerableRunForThread.mockResolvedValue({ + id: "done-run", + status: "completed", + session_id: "sess-done", }); + mockStore.getActiveRunForKey.mockResolvedValue(null); - // Session DO rejects the prompt enqueue → steering fails → fall back to skip. + // Session DO rejects every fetch → steerSession fails AND the fresh run's + // session init fails, so the run is created then marked failed. const failingStub = { fetch: vi.fn().mockResolvedValue(new Response("boom", { status: 500 })), } as never; @@ -1229,8 +1294,14 @@ describe("SchedulerDO", () => { const res = await scheduler.fetch(slackEventRequest()); const body = await res.json<{ triggered: number; skipped: number; steered: number }>(); - expect(body).toEqual({ triggered: 0, skipped: 1, steered: 0 }); - expect(mockStore.recordSkippedRun).toHaveBeenCalled(); + // Steer failed → fell through → matched conditions → fresh run created + // (insertRun) but its session init failed, so triggered stays 0. + expect(body).toEqual({ triggered: 0, skipped: 0, steered: 0 }); + expect(mockStore.insertRun).toHaveBeenCalledWith( + expect.objectContaining({ automation_id: "auto-slack", status: "starting" }) + ); + // Not treated as a concurrency skip. + expect(mockStore.recordSkippedRun).not.toHaveBeenCalled(); }); }); diff --git a/packages/control-plane/src/scheduler/durable-object.ts b/packages/control-plane/src/scheduler/durable-object.ts index 678392c1f..5e4d5b7ad 100644 --- a/packages/control-plane/src/scheduler/durable-object.ts +++ b/packages/control-plane/src/scheduler/durable-object.ts @@ -62,6 +62,14 @@ const AUTO_PAUSE_THRESHOLD = 3; /** Max runs to recover per sweep type per tick (backpressure). */ const RECOVERY_SWEEP_LIMIT = 50; +/** + * How long after a slack run's first trigger that thread replies keep continuing + * the same session (matches the interactive thread→session KV TTL of 24h). Steering + * does not create new runs, so this is measured from the root run's `created_at` and + * does not slide — a reply after the window forks a fresh run. + */ +const SLACK_THREAD_CONTINUITY_WINDOW_MS = 24 * 60 * 60 * 1000; + export class SchedulerDO extends DurableObject { private readonly log: Logger; @@ -446,34 +454,33 @@ export class SchedulerDO extends DurableObject { for (const automation of candidates) { const now = Date.now(); - // Active run for this automation in this thread/key. Looked up before - // condition matching because a slack follow-up reply steers the running - // session regardless of the trigger conditions — a natural reply ("also - // do X") won't contain the trigger keyword that started the run. - const activeRun = await store.getActiveRunForKey(automation.id, event.concurrencyKey); - - // A slack message in a thread that already has an active run is a - // follow-up: enqueue it onto that run's session as the next turn (queued - // behind the in-flight one) so the user can steer the running agent. Its - // reply posts back in-thread via the slack completion callback, exactly - // like an interactive follow-up. Falls back to the "already active" notice - // when there is no session to steer yet (run still starting) or the - // enqueue fails. - if (event.source === "slack" && activeRun) { + // Slack thread continuity — mirrors the interactive @mention path: any reply + // in a thread that already has a session (any run status, within the + // continuity window) continues that session, regardless of trigger + // conditions. A reply is a steer, not a new trigger: a natural reply ("also + // do X") won't contain the keyword that started the run, and a reply after + // the run finished should still land in the same session. Its reply posts + // back in-thread via the slack completion callback, exactly like an + // interactive follow-up. + if (event.source === "slack") { + const steerable = await store.getLatestSteerableRunForThread( + automation.id, + event.concurrencyKey, + now - SLACK_THREAD_CONTINUITY_WINDOW_MS + ); if ( - activeRun.session_id && - (await this.steerActiveRun(activeRun.session_id, automation, event)) + steerable?.session_id && + (await this.steerSession(steerable.session_id, automation, event)) ) { steered++; - } else { - await this.recordSlackSkip(store, automation.id, event, "concurrent_run_active"); - concurrencySkipped = true; - skipped++; + continue; } - continue; + // No steerable session (outside the window, no session yet, or a rare + // enqueue error) → fall through. Like the @mention path's stale-session + // recovery, the reply is re-evaluated as a potential new trigger below. } - // Evaluate trigger conditions (these gate starting a NEW run). + // Trigger conditions gate starting a NEW run. const config: TriggerConfig = automation.trigger_config ? JSON.parse(automation.trigger_config) : { conditions: [] }; @@ -481,9 +488,16 @@ export class SchedulerDO extends DurableObject { continue; } - // Concurrency guard for non-slack sources (slack's active-run case is - // handled above): never start a second concurrent run for the same key. + // Concurrency guard: never start a second run while one is active for this + // key. For slack this also covers the brief window before a run has created + // its session (no steerable row yet), so a reply racing the initial trigger + // gets the "already active" notice instead of a second session. + const activeRun = await store.getActiveRunForKey(automation.id, event.concurrencyKey); if (activeRun) { + if (event.source === "slack") { + await this.recordSlackSkip(store, automation.id, event, "concurrent_run_active"); + concurrencySkipped = true; + } skipped++; continue; } @@ -938,15 +952,16 @@ export class SchedulerDO extends DurableObject { } /** - * Route a follow-up slack message in an active thread to the run's existing - * session as the next turn, letting the user steer the running agent instead - * of having the message dropped by the per-thread concurrency guard. The turn - * queues behind the in-flight one; its reply posts back in-thread via the - * slack completion callback (source "slack"), exactly like an interactive - * follow-up. Returns false when the enqueue fails, so the caller can fall back - * to the "already active" notice. + * Route a follow-up slack message in a thread to its run's existing session as + * the next turn — whether that run is still in flight, completed, or failed — + * so every reply in the thread continues the same session, like the interactive + * @mention path. If the session has gone idle the prompt re-spawns/restores it + * in the background; its reply posts back in-thread via the slack completion + * callback (source "slack"), exactly like an interactive follow-up. Returns + * false when the enqueue fails, so the caller can fall through to the trigger + * path (stale-session recovery). */ - private async steerActiveRun( + private async steerSession( sessionId: string, automation: AutomationRow, event: SlackAutomationEvent @@ -970,7 +985,7 @@ export class SchedulerDO extends DurableObject { source: "slack", callbackContext, }); - this.log.info("Steered active run with slack follow-up", { + this.log.info("Steered thread session with slack follow-up", { event: "scheduler.slack_steer", automation_id: automation.id, session_id: sessionId, @@ -978,7 +993,7 @@ export class SchedulerDO extends DurableObject { }); return true; } catch (e) { - this.log.warn("Failed to steer active run; falling back to skip notice", { + this.log.warn("Failed to steer thread session; falling through to trigger path", { event: "scheduler.slack_steer_failed", automation_id: automation.id, session_id: sessionId, diff --git a/packages/control-plane/test/integration/automation-store.test.ts b/packages/control-plane/test/integration/automation-store.test.ts index 57842a2a1..cdd6f82be 100644 --- a/packages/control-plane/test/integration/automation-store.test.ts +++ b/packages/control-plane/test/integration/automation-store.test.ts @@ -767,6 +767,100 @@ describe("AutomationStore (D1 integration)", () => { expect(active!.id).toBe("run-ck3"); }); + it("getLatestSteerableRunForThread finds a completed run with a session (any status)", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeAutomation({ id: "auto-steer1" })); + + await store.insertRun( + makeRun("auto-steer1", { + id: "run-steer1", + status: "completed", + session_id: "sess-1", + concurrency_key: "slack:C1:t1", + completed_at: Date.now(), + }) + ); + + const run = await store.getLatestSteerableRunForThread("auto-steer1", "slack:C1:t1", 0); + expect(run).not.toBeNull(); + expect(run!.id).toBe("run-steer1"); + }); + + it("getLatestSteerableRunForThread excludes runs without a session", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeAutomation({ id: "auto-steer2" })); + + // A skip/starting row (session_id null) must not shadow the thread session. + await store.insertRun( + makeRun("auto-steer2", { + id: "run-steer2-skip", + status: "skipped", + session_id: null, + concurrency_key: "slack:C1:t2", + }) + ); + + const run = await store.getLatestSteerableRunForThread("auto-steer2", "slack:C1:t2", 0); + expect(run).toBeNull(); + }); + + it("getLatestSteerableRunForThread excludes runs created before the window", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeAutomation({ id: "auto-steer3" })); + + await store.insertRun( + makeRun("auto-steer3", { + id: "run-steer3-old", + status: "completed", + session_id: "sess-old", + concurrency_key: "slack:C1:t3", + created_at: Date.now() - 48 * 60 * 60 * 1000, + }) + ); + + const sinceMs = Date.now() - 24 * 60 * 60 * 1000; + const run = await store.getLatestSteerableRunForThread("auto-steer3", "slack:C1:t3", sinceMs); + expect(run).toBeNull(); + }); + + it("getLatestSteerableRunForThread returns the most recent session run for the key", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeAutomation({ id: "auto-steer4" })); + + // Distinct scheduled_at: automation_runs has a UNIQUE(automation_id, + // scheduled_at), and these two share an automation. + await store.insertRun( + makeRun("auto-steer4", { + id: "run-steer4-older", + status: "completed", + session_id: "sess-older", + concurrency_key: "slack:C1:t4", + scheduled_at: Date.now() - 60000, + created_at: Date.now() - 60000, + }) + ); + await store.insertRun( + makeRun("auto-steer4", { + id: "run-steer4-newer", + status: "running", + session_id: "sess-newer", + concurrency_key: "slack:C1:t4", + scheduled_at: Date.now(), + created_at: Date.now(), + }) + ); + + const run = await store.getLatestSteerableRunForThread("auto-steer4", "slack:C1:t4", 0); + expect(run!.id).toBe("run-steer4-newer"); + }); + + it("getLatestSteerableRunForThread returns null for a null concurrency key", async () => { + const store = new AutomationStore(env.DB); + await store.create(makeAutomation({ id: "auto-steer5" })); + const run = await store.getLatestSteerableRunForThread("auto-steer5", null, 0); + expect(run).toBeNull(); + }); + it("trigger_key unique index prevents duplicate runs", async () => { const store = new AutomationStore(env.DB); await store.create(makeAutomation({ id: "auto-dedup1" })); diff --git a/packages/control-plane/test/integration/scheduler-slack-events.test.ts b/packages/control-plane/test/integration/scheduler-slack-events.test.ts index e0bcbdfb8..dbd818dfb 100644 --- a/packages/control-plane/test/integration/scheduler-slack-events.test.ts +++ b/packages/control-plane/test/integration/scheduler-slack-events.test.ts @@ -232,4 +232,53 @@ describe("SchedulerDO /internal/event — slack (integration)", () => { const runs = await store.listRunsForAutomation(id, { limit: 20, offset: 0 }); expect(runs.runs.find((r) => r.skip_reason === "concurrent_run_active")).toBeUndefined(); }); + + it("continues the same session on a reply after the run has completed", async () => { + const store = new AutomationStore(env.DB); + const id = await seedSlackAutomation(store); + + const concurrencyKey = "slack:C1:thread-done"; + // Root message triggers the run and creates its session. + const rootRes = await sendEvent( + makeSlackEvent({ + text: "deploy the api", + concurrencyKey, + triggerKey: "slack:msg:C1:root-done", + }) + ); + expect((await rootRes.json<{ triggered: number }>()).triggered).toBe(1); + + // Simulate the run finishing. Its session stays steerable within the window, + // just like an @mention thread after a turn completes. + const afterRoot = await store.listRunsForAutomation(id, { limit: 20, offset: 0 }); + const rootRun = afterRoot.runs.find((r) => r.trigger_key === "slack:msg:C1:root-done")!; + expect(rootRun.session_id).toBeTruthy(); + await store.updateRun(rootRun.id, { status: "completed", completed_at: Date.now() }); + + // A reply after completion — with text that does NOT match the trigger + // conditions — still continues the same session, proving the steer bypasses + // both condition matching and the run-status filter. + const followRes = await sendEvent( + makeSlackEvent({ + text: "thanks! can you also bump the version?", + concurrencyKey, + triggerKey: "slack:msg:C1:reply-done", + }) + ); + const followBody = await followRes.json<{ + triggered: number; + skipped: number; + steered: number; + }>(); + expect(followBody).toEqual({ triggered: 0, skipped: 0, steered: 1 }); + + // The reply created no new run and recorded no skip — it reused the completed + // run's session. Exactly one materialized run remains (the completed root). + const afterReply = await store.listRunsForAutomation(id, { limit: 20, offset: 0 }); + expect( + afterReply.runs.find((r) => r.trigger_key === "slack:msg:C1:reply-done") + ).toBeUndefined(); + expect(afterReply.runs.find((r) => r.skip_reason === "concurrent_run_active")).toBeUndefined(); + expect(afterReply.runs.filter((r) => r.trigger_key !== null)).toHaveLength(1); + }); }); diff --git a/terraform/d1/migrations/0028_slack_thread_continuity_index.sql b/terraform/d1/migrations/0028_slack_thread_continuity_index.sql new file mode 100644 index 000000000..b5c98c491 --- /dev/null +++ b/terraform/d1/migrations/0028_slack_thread_continuity_index.sql @@ -0,0 +1,7 @@ +-- Backs getLatestSteerableRunForThread: the most recent run with a session for a +-- thread's concurrency key, any status, within a time window. Powers Slack +-- thread-session continuity (a reply continues the same session after the run +-- completes). Partial on session_id so skipped rows (session_id NULL) are excluded. +CREATE INDEX IF NOT EXISTS idx_runs_thread_continuity + ON automation_runs (automation_id, concurrency_key, created_at DESC) + WHERE concurrency_key IS NOT NULL AND session_id IS NOT NULL;