From 204f250c3064815431057b9c7f9b59c5d1fbccdc Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:47:13 -0700 Subject: [PATCH 01/21] spike(agent): resume a parked run from an outside event Proves the load-bearing assumption behind the customer-onboarding flow: an event that arrives from outside the CRM can wake a run that is already parked, rather than starting a second one. The mechanism already existed and was not being used this way. dispatchAgentRun sends with `continuationToken: runToken(run.id)`, and per docs/agent.md eve hands a continuation token back only when the session is parked and will accept another turn. So resuming is the same send with the same token. resumeAgentRun is that send, with the guards a webhook needs, because a Slack event arrives whenever Slack feels like it: - a finished run is never restarted by a late event - a run with no session yet is left alone - an agent that is no longer LIVE is refused - an unknown run is ignored, not thrown - a refused send is an outcome, not an exception - the run's own status is never touched; the runner owns that Nine integration tests cover each. It does not decide which run an event belongs to: AgentRun has no slackChannelId, and adding one is a schema decision rather than spike material. Not wired to anything yet. The Slack Events endpoint, the channel-to-run lookup and the new action types are the next steps, and they are ordinary work now that this holds. --- apps/agent/agent/lib/run-resume.ts | 91 +++++++ .../agent/test/run-resume.integration.spec.ts | 244 ++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 apps/agent/agent/lib/run-resume.ts create mode 100644 apps/agent/test/run-resume.integration.spec.ts diff --git a/apps/agent/agent/lib/run-resume.ts b/apps/agent/agent/lib/run-resume.ts new file mode 100644 index 00000000..9fcd9dc4 --- /dev/null +++ b/apps/agent/agent/lib/run-resume.ts @@ -0,0 +1,91 @@ +import { db } from "@crm/db"; +import type { SendFn } from "eve/channels"; +import { APP_AUTH } from "./app-auth"; +import { runToken } from "./custom-agent-dispatch"; + +const LIVE_STATUSES = ["QUEUED", "RUNNING", "WAITING_FOR_APPROVAL"] as const; + +export type ResumeOutcome = + | { kind: "resumed"; runId: string; sessionId: string } + | { kind: "ignored"; runId: string; reason: string }; + +export type ResumeInput = { + runId: string; + message: string; + source: string; + attributes?: Readonly>; +}; + +export async function resumeAgentRun( + input: ResumeInput, + send: SendFn, +): Promise { + const { runId, message, source } = input; + + const run = await db.agentRun.findUnique({ + where: { id: runId }, + select: { + id: true, + status: true, + sessionId: true, + agentId: true, + versionId: true, + agent: { select: { status: true, name: true } }, + }, + }); + + if (!run) return { kind: "ignored", runId, reason: "no such run" }; + + if (!(LIVE_STATUSES as readonly string[]).includes(run.status)) { + return { + kind: "ignored", + runId, + reason: `the run is ${run.status.toLowerCase()}`, + }; + } + + if (run.agent.status !== "LIVE") { + return { + kind: "ignored", + runId, + reason: `the agent is ${run.agent.status.toLowerCase()}`, + }; + } + + if (!run.sessionId) { + return { + kind: "ignored", + runId, + reason: "the run has not started a session yet", + }; + } + + try { + const session = await send(message, { + auth: { + authenticator: APP_AUTH.authenticator, + principalType: APP_AUTH.principalType, + principalId: APP_AUTH.principalId, + attributes: { + purpose: "team-agent", + runId: run.id, + agentId: run.agentId, + versionId: run.versionId, + resumeSource: source, + ...input.attributes, + }, + }, + continuationToken: runToken(run.id), + title: `${run.agent.name} run`, + mode: "task", + }); + + return { kind: "resumed", runId, sessionId: session.id }; + } catch (error) { + return { + kind: "ignored", + runId, + reason: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/apps/agent/test/run-resume.integration.spec.ts b/apps/agent/test/run-resume.integration.spec.ts new file mode 100644 index 00000000..8e57a6fa --- /dev/null +++ b/apps/agent/test/run-resume.integration.spec.ts @@ -0,0 +1,244 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import type { SendFn } from "eve/channels"; +import { runToken } from "../agent/lib/custom-agent-dispatch"; +import { resumeAgentRun } from "../agent/lib/run-resume"; + +const suffix = crypto.randomUUID(); +const userId = `resume-user-${suffix}`; + +let agentId = ""; +let versionId = ""; + +type Delivery = { + message: string; + continuationToken?: string; + mode?: string; + attributes?: Record; +}; + +const deliveries: Delivery[] = []; + +const send = (async (message: string, options: Record) => { + const auth = options?.auth as { attributes?: Record }; + deliveries.push({ + message, + continuationToken: options?.continuationToken as string, + mode: options?.mode as string, + attributes: auth?.attributes, + }); + return { id: `ses_${deliveries.length}` }; +}) as unknown as SendFn; + +const refusing = (async () => { + throw new Error("eve refused: session is not active"); +}) as unknown as SendFn; + +async function makeRun(overrides: { + status: + | "QUEUED" + | "RUNNING" + | "WAITING_FOR_APPROVAL" + | "SUCCEEDED" + | "FAILED" + | "CANCELLED"; + sessionId?: string | null; +}) { + const unique = crypto.randomUUID(); + const run = await db.agentRun.create({ + data: { + agentId, + versionId, + status: overrides.status, + triggerType: "MANUAL", + idempotencyKey: `resume-${unique}`, + correlationId: `resume-${unique}`, + sessionId: overrides.sessionId === null ? null : `ses_seed_${unique}`, + }, + select: { id: true }, + }); + return run.id; +} + +beforeAll(async () => { + await db.user.create({ + data: { + id: userId, + name: "Resume Spike", + email: `${userId}@example.test`, + }, + }); + + const agent = await db.agentDefinition.create({ + data: { + name: `Resume spike ${suffix}`, + status: "LIVE", + createdById: userId, + }, + select: { id: true }, + }); + agentId = agent.id; + + const version = await db.agentVersion.create({ + data: { + agentId, + number: 1, + status: "DEPLOYED", + createdById: userId, + instructions: "Resume spike.", + manifest: {}, + modelId: "zai/glm-5.2-fast", + sandboxPolicy: {}, + }, + select: { id: true }, + }); + versionId = version.id; + + await db.agentDefinition.update({ + where: { id: agentId }, + data: { currentVersionId: versionId }, + }); +}); + +afterAll(async () => { + await db.agentRun.deleteMany({ where: { agentId } }); + await db.agentDefinition.updateMany({ + where: { id: agentId }, + data: { currentVersionId: null }, + }); + await db.agentVersion.deleteMany({ where: { agentId } }); + await db.agentDefinition.deleteMany({ where: { id: agentId } }); + await db.user.deleteMany({ where: { id: userId } }); +}); + +describe("resuming a parked run from an outside event", () => { + it("delivers to the run's own continuation token, not a new session", async () => { + deliveries.length = 0; + const runId = await makeRun({ status: "WAITING_FOR_APPROVAL" }); + + const outcome = await resumeAgentRun( + { runId, message: "The customer joined the channel.", source: "slack" }, + send, + ); + + expect(outcome.kind).toBe("resumed"); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]?.continuationToken).toBe(runToken(runId)); + expect(deliveries[0]?.message).toBe("The customer joined the channel."); + }); + + it("resumes in task mode, so it cannot pause for a per-action approval", async () => { + deliveries.length = 0; + const runId = await makeRun({ status: "RUNNING" }); + + await resumeAgentRun({ runId, message: "org id", source: "slack" }, send); + + expect(deliveries[0]?.mode).toBe("task"); + }); + + it("carries the run identity and the source, so the runner can revalidate", async () => { + deliveries.length = 0; + const runId = await makeRun({ status: "WAITING_FOR_APPROVAL" }); + + await resumeAgentRun( + { + runId, + message: "joined", + source: "slack.member_joined_channel", + attributes: { channelId: "C123" }, + }, + send, + ); + + expect(deliveries[0]?.attributes).toMatchObject({ + purpose: "team-agent", + runId, + agentId, + versionId, + resumeSource: "slack.member_joined_channel", + channelId: "C123", + }); + }); + + it("refuses a finished run, so a late event cannot restart it", async () => { + for (const status of ["SUCCEEDED", "FAILED", "CANCELLED"] as const) { + deliveries.length = 0; + const runId = await makeRun({ status }); + + const outcome = await resumeAgentRun( + { runId, message: "late", source: "slack" }, + send, + ); + + expect(outcome.kind).toBe("ignored"); + expect(deliveries).toHaveLength(0); + } + }); + + it("refuses a run that never started a session", async () => { + deliveries.length = 0; + const runId = await makeRun({ status: "QUEUED", sessionId: null }); + + const outcome = await resumeAgentRun( + { runId, message: "early", source: "slack" }, + send, + ); + + expect(outcome.kind).toBe("ignored"); + expect(deliveries).toHaveLength(0); + }); + + it("refuses a run whose agent is no longer live", async () => { + const runId = await makeRun({ status: "WAITING_FOR_APPROVAL" }); + await db.agentDefinition.update({ + where: { id: agentId }, + data: { status: "PAUSED" }, + }); + + const outcome = await resumeAgentRun( + { runId, message: "paused", source: "slack" }, + send, + ); + + await db.agentDefinition.update({ + where: { id: agentId }, + data: { status: "LIVE" }, + }); + + expect(outcome.kind).toBe("ignored"); + }); + + it("ignores an unknown run rather than throwing", async () => { + const outcome = await resumeAgentRun( + { runId: `missing-${suffix}`, message: "x", source: "slack" }, + send, + ); + + expect(outcome).toMatchObject({ kind: "ignored", reason: "no such run" }); + }); + + it("never throws when eve refuses the send", async () => { + const runId = await makeRun({ status: "WAITING_FOR_APPROVAL" }); + + const outcome = await resumeAgentRun( + { runId, message: "refused", source: "slack" }, + refusing, + ); + + expect(outcome.kind).toBe("ignored"); + if (outcome.kind !== "ignored") throw new Error("expected ignored"); + expect(outcome.reason).toContain("not active"); + }); + + it("leaves the run's status alone; the runner owns its own state", async () => { + const runId = await makeRun({ status: "WAITING_FOR_APPROVAL" }); + + await resumeAgentRun({ runId, message: "hello", source: "slack" }, send); + + const after = await db.agentRun.findUnique({ + where: { id: runId }, + select: { status: true }, + }); + expect(after?.status).toBe("WAITING_FOR_APPROVAL"); + }); +}); From 15abf2f87097c55c40f5cc309bf1a1deb2913dbc Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:04:26 -0700 Subject: [PATCH 02/21] feat(agent): route a Slack event to the run that owns the channel Closes the gap the spike left open. An inbound event knows a channel id; it needs a run id. - AgentRun gains slackChannelId, indexed with status, so the lookup is one query rather than a scan. - runOnSlackChannel returns only a live run, so a finished run's channel stops routing and a late event lands nowhere. - claimSlackChannel writes the channel once. A run cannot be reassigned, so two channels cannot both point at the same run. - The newest live run wins when a channel is genuinely reused. Adds verifySlackSignature in @crm/auth. Slack's events endpoint is a public POST, so the signature is the only thing standing between a stranger and resuming somebody's run. It fails closed with no secret, refuses a body changed by one byte, refuses another secret's signature, and refuses a replay outside the five-minute window in either direction. timingSafeEqual, not ===. Migration written by hand and verified against a throwaway Postgres: every migration applied, then `migrate diff` reports no difference. The local database could not author it because it carries the HubSpot migration from another branch. 25 tests. Still not wired to an HTTP route. --- apps/agent/agent/lib/run-resume.ts | 28 +++++ .../agent/test/run-resume.integration.spec.ts | 56 +++++++++- packages/auth/src/index.ts | 6 ++ packages/auth/src/slack-signature.ts | 63 +++++++++++ packages/auth/test/slack-signature.spec.ts | 102 ++++++++++++++++++ .../migration.sql | 5 + packages/db/prisma/schema.prisma | 2 + 7 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 packages/auth/src/slack-signature.ts create mode 100644 packages/auth/test/slack-signature.spec.ts create mode 100644 packages/db/prisma/migrations/20260826000000_agent_run_slack_channel/migration.sql diff --git a/apps/agent/agent/lib/run-resume.ts b/apps/agent/agent/lib/run-resume.ts index 9fcd9dc4..2da2ec6d 100644 --- a/apps/agent/agent/lib/run-resume.ts +++ b/apps/agent/agent/lib/run-resume.ts @@ -89,3 +89,31 @@ export async function resumeAgentRun( }; } } + +export async function runOnSlackChannel( + channelId: string, +): Promise { + const trimmed = channelId.trim(); + if (!trimmed) return null; + + const run = await db.agentRun.findFirst({ + where: { + slackChannelId: trimmed, + status: { in: [...LIVE_STATUSES] }, + }, + orderBy: { createdAt: "desc" }, + select: { id: true }, + }); + + return run?.id ?? null; +} + +export async function claimSlackChannel( + runId: string, + channelId: string, +): Promise { + await db.agentRun.updateMany({ + where: { id: runId, slackChannelId: null }, + data: { slackChannelId: channelId.trim() }, + }); +} diff --git a/apps/agent/test/run-resume.integration.spec.ts b/apps/agent/test/run-resume.integration.spec.ts index 8e57a6fa..264dddd8 100644 --- a/apps/agent/test/run-resume.integration.spec.ts +++ b/apps/agent/test/run-resume.integration.spec.ts @@ -2,7 +2,11 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { db } from "@crm/db"; import type { SendFn } from "eve/channels"; import { runToken } from "../agent/lib/custom-agent-dispatch"; -import { resumeAgentRun } from "../agent/lib/run-resume"; +import { + claimSlackChannel, + resumeAgentRun, + runOnSlackChannel, +} from "../agent/lib/run-resume"; const suffix = crypto.randomUUID(); const userId = `resume-user-${suffix}`; @@ -242,3 +246,53 @@ describe("resuming a parked run from an outside event", () => { expect(after?.status).toBe("WAITING_FOR_APPROVAL"); }); }); + +describe("finding the run an event belongs to", () => { + it("finds the live run that owns a channel", async () => { + const runId = await makeRun({ status: "WAITING_FOR_APPROVAL" }); + const channelId = `C-${crypto.randomUUID()}`; + + await claimSlackChannel(runId, channelId); + + expect(await runOnSlackChannel(channelId)).toBe(runId); + }); + + it("ignores a finished run, so its channel stops routing", async () => { + const runId = await makeRun({ status: "SUCCEEDED" }); + const channelId = `C-${crypto.randomUUID()}`; + + await claimSlackChannel(runId, channelId); + + expect(await runOnSlackChannel(channelId)).toBeNull(); + }); + + it("prefers the newest live run when a channel is reused", async () => { + const channelId = `C-${crypto.randomUUID()}`; + const older = await makeRun({ status: "WAITING_FOR_APPROVAL" }); + await claimSlackChannel(older, channelId); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const newer = await makeRun({ status: "WAITING_FOR_APPROVAL" }); + await claimSlackChannel(newer, channelId); + + expect(await runOnSlackChannel(channelId)).toBe(newer); + }); + + it("does not reassign a channel a run already claimed", async () => { + const runId = await makeRun({ status: "RUNNING" }); + const first = `C-${crypto.randomUUID()}`; + const second = `C-${crypto.randomUUID()}`; + + await claimSlackChannel(runId, first); + await claimSlackChannel(runId, second); + + expect(await runOnSlackChannel(first)).toBe(runId); + expect(await runOnSlackChannel(second)).toBeNull(); + }); + + it("reads an unknown or blank channel as nobody", async () => { + expect(await runOnSlackChannel(`C-${crypto.randomUUID()}`)).toBeNull(); + expect(await runOnSlackChannel(" ")).toBeNull(); + }); +}); diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 31fbcbaa..ac0e9225 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -66,6 +66,12 @@ export { slackScopeDrift, summariseSlackScopes, } from "./slack-scopes"; +export { + type SignatureVerdict, + SLACK_SIGNATURE, + signBody, + verifySlackSignature, +} from "./slack-signature"; export { canConfigureSso, ssoCallbackBase, diff --git a/packages/auth/src/slack-signature.ts b/packages/auth/src/slack-signature.ts new file mode 100644 index 00000000..a8b30c15 --- /dev/null +++ b/packages/auth/src/slack-signature.ts @@ -0,0 +1,63 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +export const SLACK_SIGNATURE = { + version: "v0", + timestampHeader: "x-slack-request-timestamp", + signatureHeader: "x-slack-signature", + toleranceSeconds: 300, +} as const; + +export type SignatureVerdict = { ok: true } | { ok: false; reason: string }; + +export type SignatureInput = { + body: string; + timestamp: string | undefined; + signature: string | undefined; + secret: string | undefined; + now?: number; +}; + +export function verifySlackSignature(input: SignatureInput): SignatureVerdict { + const { body, timestamp, signature, secret } = input; + + if (!secret) return { ok: false, reason: "no signing secret is configured" }; + if (!timestamp) + return { ok: false, reason: "the timestamp header is missing" }; + if (!signature) + return { ok: false, reason: "the signature header is missing" }; + + const sent = Number(timestamp); + if (!Number.isFinite(sent)) { + return { ok: false, reason: "the timestamp is not a number" }; + } + + const now = Math.floor((input.now ?? Date.now()) / 1000); + if (Math.abs(now - sent) > SLACK_SIGNATURE.toleranceSeconds) { + return { ok: false, reason: "the timestamp is outside the replay window" }; + } + + const expected = signBody(body, timestamp, secret); + return sameSignature(expected, signature) + ? { ok: true } + : { ok: false, reason: "the signature does not match" }; +} + +export function signBody( + body: string, + timestamp: string, + secret: string, +): string { + const digest = createHmac("sha256", secret) + .update(`${SLACK_SIGNATURE.version}:${timestamp}:${body}`) + .digest("hex"); + + return `${SLACK_SIGNATURE.version}=${digest}`; +} + +function sameSignature(expected: string, received: string): boolean { + const a = Buffer.from(expected, "utf8"); + const b = Buffer.from(received, "utf8"); + + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} diff --git a/packages/auth/test/slack-signature.spec.ts b/packages/auth/test/slack-signature.spec.ts new file mode 100644 index 00000000..92df363d --- /dev/null +++ b/packages/auth/test/slack-signature.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "bun:test"; +import { + SLACK_SIGNATURE, + signBody, + verifySlackSignature, +} from "../src/slack-signature"; + +const secret = "8f742231b10e8888abcd99yyyzzz85a5"; +const body = + '{"type":"event_callback","event":{"type":"member_joined_channel"}}'; +const now = 1_700_000_000_000; +const timestamp = String(Math.floor(now / 1000)); +const signature = signBody(body, timestamp, secret); + +const verify = ( + overrides: Partial[0]>, +) => + verifySlackSignature({ + body, + timestamp, + signature, + secret, + now, + ...overrides, + }); + +describe("verifySlackSignature", () => { + it("accepts a body Slack actually signed", () => { + expect(verify({})).toEqual({ ok: true }); + }); + + it("refuses a body that changed by one byte", () => { + const tampered = `${body.slice(0, -1)} `; + expect(verify({ body: tampered }).ok).toBe(false); + }); + + it("refuses a signature from a different secret", () => { + const other = signBody(body, timestamp, "a-different-signing-secret"); + expect(verify({ signature: other }).ok).toBe(false); + }); + + it("refuses a replay outside the window", () => { + const old = String( + Math.floor(now / 1000) - SLACK_SIGNATURE.toleranceSeconds - 1, + ); + const verdict = verify({ + timestamp: old, + signature: signBody(body, old, secret), + }); + + expect(verdict).toEqual({ + ok: false, + reason: "the timestamp is outside the replay window", + }); + }); + + it("accepts a request at the edge of the window", () => { + const edge = String( + Math.floor(now / 1000) - SLACK_SIGNATURE.toleranceSeconds, + ); + expect( + verify({ timestamp: edge, signature: signBody(body, edge, secret) }).ok, + ).toBe(true); + }); + + it("refuses a future timestamp outside the window", () => { + const ahead = String( + Math.floor(now / 1000) + SLACK_SIGNATURE.toleranceSeconds + 1, + ); + expect( + verify({ timestamp: ahead, signature: signBody(body, ahead, secret) }).ok, + ).toBe(false); + }); + + it("fails closed when no signing secret is configured", () => { + expect(verify({ secret: undefined })).toEqual({ + ok: false, + reason: "no signing secret is configured", + }); + }); + + it("refuses a request missing either header", () => { + expect(verify({ timestamp: undefined }).ok).toBe(false); + expect(verify({ signature: undefined }).ok).toBe(false); + }); + + it("refuses a timestamp that is not a number", () => { + expect(verify({ timestamp: "not-a-time" })).toEqual({ + ok: false, + reason: "the timestamp is not a number", + }); + }); + + it("refuses an empty signature rather than comparing lengths oddly", () => { + expect(verify({ signature: "" }).ok).toBe(false); + }); + + it("signs in the exact form Slack documents", () => { + expect(signature.startsWith(`${SLACK_SIGNATURE.version}=`)).toBe(true); + expect(signature).toHaveLength(SLACK_SIGNATURE.version.length + 1 + 64); + }); +}); diff --git a/packages/db/prisma/migrations/20260826000000_agent_run_slack_channel/migration.sql b/packages/db/prisma/migrations/20260826000000_agent_run_slack_channel/migration.sql new file mode 100644 index 00000000..e20571f2 --- /dev/null +++ b/packages/db/prisma/migrations/20260826000000_agent_run_slack_channel/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "agentRun" ADD COLUMN "slackChannelId" TEXT; + +-- CreateIndex +CREATE INDEX "agentRun_slackChannelId_status_idx" ON "agentRun"("slackChannelId", "status"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 82bc1f36..89881c07 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -793,6 +793,7 @@ model AgentRun { triggerType AgentTriggerType status AgentRunStatus @default(QUEUED) + slackChannelId String? principalId String? sessionId String? @unique idempotencyKey String @unique @@ -827,6 +828,7 @@ model AgentRun { @@index([versionId, createdAt]) @@index([status, createdAt]) @@index([triggerId, createdAt]) + @@index([slackChannelId, status]) @@map("agentRun") } From c5ad91f5844bcc75bb2c54646f12f0ae09abeaf2 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:33:25 -0700 Subject: [PATCH 03/21] feat(agent): take Slack events in, and route them to a run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the inbound half. AgentRun gains slackChannelId so an event that knows a channel can find the run that owns it; slackEventInbox is the landing table, keyed on Slack's event_id so a redelivery is a no-op rather than a second resume. @crm/validation/slack-events parses the envelope and answers the only two questions the ingest needs: is this from us, and is it worth waking an agent for. Both matter — a bot_message that woke the agent would have it answering its own post, forever. Migration generated by `prisma migrate dev` against a scratch database, not written by hand. The local crm database carries another branch's migration, which is why migrate dev refused to author against it; a throwaway database is the way round that, not a hand-rolled file. crm_test was rebuilt: it held a failed record from the hand-written migration this replaces. 17 validation tests, 14 resume tests. Still no HTTP route. --- .../migration.sql | 5 - .../migration.sql | 30 ++++++ packages/db/prisma/schema.prisma | 20 ++++ packages/validation/package.json | 3 +- packages/validation/src/index.ts | 7 ++ packages/validation/src/slack-events.ts | 53 +++++++++ packages/validation/test/slack-events.spec.ts | 101 ++++++++++++++++++ 7 files changed, 213 insertions(+), 6 deletions(-) delete mode 100644 packages/db/prisma/migrations/20260826000000_agent_run_slack_channel/migration.sql create mode 100644 packages/db/prisma/migrations/20260826183121_slack_event_resume/migration.sql create mode 100644 packages/validation/src/slack-events.ts create mode 100644 packages/validation/test/slack-events.spec.ts diff --git a/packages/db/prisma/migrations/20260826000000_agent_run_slack_channel/migration.sql b/packages/db/prisma/migrations/20260826000000_agent_run_slack_channel/migration.sql deleted file mode 100644 index e20571f2..00000000 --- a/packages/db/prisma/migrations/20260826000000_agent_run_slack_channel/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterTable -ALTER TABLE "agentRun" ADD COLUMN "slackChannelId" TEXT; - --- CreateIndex -CREATE INDEX "agentRun_slackChannelId_status_idx" ON "agentRun"("slackChannelId", "status"); diff --git a/packages/db/prisma/migrations/20260826183121_slack_event_resume/migration.sql b/packages/db/prisma/migrations/20260826183121_slack_event_resume/migration.sql new file mode 100644 index 00000000..31005229 --- /dev/null +++ b/packages/db/prisma/migrations/20260826183121_slack_event_resume/migration.sql @@ -0,0 +1,30 @@ +-- AlterTable +ALTER TABLE "agentRun" ADD COLUMN "slackChannelId" TEXT; + +-- CreateTable +CREATE TABLE "slackEventInbox" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "teamId" TEXT, + "channelId" TEXT, + "type" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "runId" TEXT, + "receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "processedAt" TIMESTAMP(3), + "outcome" TEXT, + + CONSTRAINT "slackEventInbox_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "slackEventInbox_eventId_key" ON "slackEventInbox"("eventId"); + +-- CreateIndex +CREATE INDEX "slackEventInbox_processedAt_receivedAt_idx" ON "slackEventInbox"("processedAt", "receivedAt"); + +-- CreateIndex +CREATE INDEX "slackEventInbox_channelId_idx" ON "slackEventInbox"("channelId"); + +-- CreateIndex +CREATE INDEX "agentRun_slackChannelId_status_idx" ON "agentRun"("slackChannelId", "status"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 89881c07..8c88a76b 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -108,6 +108,26 @@ model SlackWorkspaceGrant { @@map("slackWorkspaceGrant") } +model SlackEventInbox { + id String @id @default(cuid()) + + eventId String @unique + teamId String? + channelId String? + type String + payload Json + + runId String? + + receivedAt DateTime @default(now()) + processedAt DateTime? + outcome String? + + @@index([processedAt, receivedAt]) + @@index([channelId]) + @@map("slackEventInbox") +} + model Session { id String @id expiresAt DateTime diff --git a/packages/validation/package.json b/packages/validation/package.json index d6fb2c36..01212f19 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -14,7 +14,8 @@ "./eve-tool": "./src/eve-tool.ts", "./field-backfill": "./src/field-backfill.ts", "./field-templates": "./src/field-templates.ts", - "./saved-view": "./src/saved-view.ts" + "./saved-view": "./src/saved-view.ts", + "./slack-events": "./src/slack-events.ts" }, "scripts": { "check-types": "tsc --noEmit", diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 0cf43c9c..cd2efe1d 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -7,6 +7,7 @@ import * as builderQuestion from "./builder-question"; import * as eveStream from "./eve-stream"; import * as eveTool from "./eve-tool"; import * as slack from "./slack"; +import * as slackEvents from "./slack-events"; export const schemas = { activityMeta, @@ -17,6 +18,7 @@ export const schemas = { eveStream, eveTool, slack, + slackEvents, } as const; export type { ActivityMeta, ActivityMetaFields } from "./activity-meta"; @@ -57,6 +59,11 @@ export type { EveToolOutput, } from "./eve-tool"; export type { AuthTest, JoinPayload, OauthAccess, Reply } from "./slack"; +export type { + EventCallback, + SlackEnvelope, + SlackEvent, +} from "./slack-events"; export class InvalidInput extends Error { override readonly name = "InvalidInput"; diff --git a/packages/validation/src/slack-events.ts b/packages/validation/src/slack-events.ts new file mode 100644 index 00000000..113562d7 --- /dev/null +++ b/packages/validation/src/slack-events.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +const trimmed = z.string().trim().min(1); + +export const SLACK_EVENT_TYPES = { + MEMBER_JOINED: "member_joined_channel", + MESSAGE: "message", +} as const; + +export const urlVerification = z.object({ + type: z.literal("url_verification"), + challenge: trimmed, +}); + +export const slackEvent = z.object({ + type: trimmed, + channel: z.string().trim().optional(), + user: z.string().trim().optional(), + text: z.string().optional(), + ts: z.string().trim().optional(), + thread_ts: z.string().trim().optional(), + bot_id: z.string().trim().optional(), + subtype: z.string().trim().optional(), +}); + +export const eventCallback = z.object({ + type: z.literal("event_callback"), + event_id: trimmed, + team_id: z.string().trim().optional(), + event: slackEvent, +}); + +export const slackEnvelope = z.union([urlVerification, eventCallback]); + +export type SlackEvent = z.infer; +export type EventCallback = z.infer; +export type SlackEnvelope = z.infer; + +export function isFromApp(event: SlackEvent): boolean { + return Boolean(event.bot_id) || event.subtype === "bot_message"; +} + +export function isActionable(event: SlackEvent): boolean { + if (isFromApp(event)) return false; + + if (event.type === SLACK_EVENT_TYPES.MEMBER_JOINED) return true; + + return ( + event.type === SLACK_EVENT_TYPES.MESSAGE && + event.subtype === undefined && + Boolean(event.text?.trim()) + ); +} diff --git a/packages/validation/test/slack-events.spec.ts b/packages/validation/test/slack-events.spec.ts new file mode 100644 index 00000000..d2ee38fc --- /dev/null +++ b/packages/validation/test/slack-events.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "bun:test"; +import { + eventCallback, + isActionable, + isFromApp, + SLACK_EVENT_TYPES, + slackEnvelope, +} from "../src/slack-events"; + +const joined = { + type: "event_callback", + event_id: "Ev123", + team_id: "T1", + event: { + type: SLACK_EVENT_TYPES.MEMBER_JOINED, + channel: "C1", + user: "U1", + }, +}; + +const message = (over: Record = {}) => ({ + type: SLACK_EVENT_TYPES.MESSAGE, + channel: "C1", + user: "U1", + text: "org_abc123", + ts: "1700000000.000100", + ...over, +}); + +describe("the envelope Slack posts", () => { + it("reads the setup handshake", () => { + const parsed = slackEnvelope.parse({ + type: "url_verification", + challenge: "abc", + }); + + expect(parsed).toEqual({ type: "url_verification", challenge: "abc" }); + }); + + it("reads a member-joined callback", () => { + const parsed = eventCallback.parse(joined); + + expect(parsed.event.channel).toBe("C1"); + expect(parsed.event_id).toBe("Ev123"); + }); + + it("refuses a callback with no event id, so nothing is deduplicated by luck", () => { + expect(eventCallback.safeParse({ ...joined, event_id: "" }).success).toBe( + false, + ); + }); + + it("keeps an unknown event type rather than dropping the delivery", () => { + const parsed = eventCallback.parse({ + ...joined, + event: { type: "reaction_added", channel: "C1" }, + }); + + expect(parsed.event.type).toBe("reaction_added"); + }); +}); + +describe("isFromApp", () => { + it("recognises our own bot, so an agent cannot answer itself", () => { + expect(isFromApp({ type: "message", bot_id: "B1" })).toBe(true); + expect(isFromApp({ type: "message", subtype: "bot_message" })).toBe(true); + }); + + it("treats a human message as a human message", () => { + expect(isFromApp(message())).toBe(false); + }); +}); + +describe("isActionable", () => { + it("acts on a member joining", () => { + expect(isActionable(joined.event)).toBe(true); + }); + + it("acts on a plain human message with text", () => { + expect(isActionable(message())).toBe(true); + }); + + it("ignores our own bot posting, which would otherwise loop", () => { + expect(isActionable(message({ bot_id: "B1" }))).toBe(false); + expect(isActionable(message({ subtype: "bot_message" }))).toBe(false); + }); + + it("ignores edits, joins-as-message and other subtypes", () => { + expect(isActionable(message({ subtype: "message_changed" }))).toBe(false); + expect(isActionable(message({ subtype: "channel_join" }))).toBe(false); + }); + + it("ignores an empty message", () => { + expect(isActionable(message({ text: "" }))).toBe(false); + expect(isActionable(message({ text: " " }))).toBe(false); + }); + + it("ignores event types we do not handle", () => { + expect(isActionable({ type: "reaction_added", channel: "C1" })).toBe(false); + }); +}); From abe13b9b164c998f3090421aa1b1dcf5376095fd Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:51:28 -0700 Subject: [PATCH 04/21] feat(api): accept Slack events, verify them, and hand them to the agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /webhooks/slack/events. Answers Slack's setup handshake, verifies every other request, writes an inbox row and pokes the agent. The API decides nothing, per the rule in AGENTS.md: it stores the event and lets the agent work out which run it belongs to and what it means. That also happens to be what keeps the handler inside Slack's three second budget. Refuses by default. With no SLACK_SIGNING_SECRET the endpoint rejects everything rather than trusting the caller, because it is a public POST and the signature is the only thing between a stranger and resuming somebody's run. Answers 200 to a payload it cannot parse, to an event type we do not act on, and to a redelivery, so Slack stops retrying instead of hammering a shape we will never handle. Ignores anything from our own bot; without that the agent answers its own posts forever. The raw body is collected by a small middleware on that path alone. The app runs with bodyParser false and express is not one of its declared dependencies, so importing express.raw would have broken createApp at runtime — as it did, until the tracking-collector spec caught it. 10 endpoint tests, driven by signed fixtures. No Slack workspace needed to run them. --- .env.example | 10 ++ apps/api/src/agent/agent-trigger.service.ts | 32 ++++ apps/api/src/config/env.validation.ts | 4 + apps/api/src/create-app.ts | 19 +- apps/api/src/slack/slack-events.controller.ts | 89 ++++++++++ apps/api/src/slack/slack.module.ts | 2 + apps/api/test/slack-events.spec.ts | 164 ++++++++++++++++++ docs/environment.md | 7 + turbo.json | 1 + 9 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/slack/slack-events.controller.ts create mode 100644 apps/api/test/slack-events.spec.ts diff --git a/.env.example b/.env.example index 12fac543..52fe55f5 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,16 @@ GOOGLE_CLIENT_SECRET="" # SLACK_CLIENT_ID="" # SLACK_CLIENT_SECRET="" +# Optional, and required only for inbound Slack. It is the Signing Secret on the +# Slack app's Basic Information page, not a token. Without it the events +# endpoint refuses every request, which is the safe way round: that endpoint is +# a public POST, so the signature is the only thing between a stranger and +# resuming somebody's agent run. +# +# Point the Slack app's Event Subscriptions request URL at +# API_URL + /webhooks/slack/events +# SLACK_SIGNING_SECRET="" + # Which Entra tenant may sign in. "common" (the default) accepts any work, # school or personal Microsoft account and leans on ALLOWED_SIGN_IN to decide # who actually gets in; your own tenant's GUID refuses everyone else at diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index 3b214330..e764becf 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -555,6 +555,38 @@ export class AgentTriggerService { void this.redeliverCancellations(); } + async slackEventReceived(input: { + eventId: string; + type: string; + teamId?: string; + channelId?: string; + payload: Prisma.InputJsonValue; + }): Promise<{ stored: boolean }> { + const existing = await this.db.slackEventInbox.findUnique({ + where: { eventId: input.eventId }, + select: { id: true }, + }); + + if (existing) return { stored: false }; + + try { + await this.db.slackEventInbox.create({ + data: { + eventId: input.eventId, + type: input.type, + teamId: input.teamId ?? null, + channelId: input.channelId ?? null, + payload: input.payload, + }, + }); + } catch { + return { stored: false }; + } + + this.poke(); + return { stored: true }; + } + private poke(): void { this.pokeRoute("/internal/crm/dispatch"); } diff --git a/apps/api/src/config/env.validation.ts b/apps/api/src/config/env.validation.ts index 08cb676c..47b324b8 100644 --- a/apps/api/src/config/env.validation.ts +++ b/apps/api/src/config/env.validation.ts @@ -76,6 +76,10 @@ export class EnvironmentVariables { @IsString() SLACK_CLIENT_SECRET?: string; + @IsOptional() + @IsString() + SLACK_SIGNING_SECRET?: string; + @IsOptional() @IsUrl({ require_tld: false }) API_URL?: string; diff --git a/apps/api/src/create-app.ts b/apps/api/src/create-app.ts index 4a436e71..984490d0 100644 --- a/apps/api/src/create-app.ts +++ b/apps/api/src/create-app.ts @@ -6,7 +6,7 @@ import { type NestExpressApplication, } from "@nestjs/platform-express"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; -import type { NextFunction, Request, Response } from "express"; +import { type NextFunction, type Request, type Response, raw } from "express"; import helmet from "helmet"; import { AppRouterHost } from "nestjs-trpc"; import { @@ -15,6 +15,7 @@ import { } from "trpc-to-openapi"; import { AppModule } from "./app.module"; import { ContextLogger } from "./logging/context-logger"; +import { SLACK_EVENTS_PATH } from "./slack/slack-events.controller"; import { REST_BRIDGE_PATH } from "./trpc/openapi"; import { createBaseTrpcContext } from "./trpc/trpc.context"; @@ -26,6 +27,7 @@ export async function createApp(): Promise { ); app.use(helmet()); + app.use(SLACK_EVENTS_PATH, collectRawBody); app.useGlobalPipes( new ValidationPipe({ whitelist: true, @@ -114,3 +116,18 @@ export async function createApp(): Promise { return app; } + +function collectRawBody( + request: Request, + _response: Response, + next: NextFunction, +): void { + const chunks: Buffer[] = []; + + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + request.body = Buffer.concat(chunks); + next(); + }); + request.on("error", next); +} diff --git a/apps/api/src/slack/slack-events.controller.ts b/apps/api/src/slack/slack-events.controller.ts new file mode 100644 index 00000000..95da94ee --- /dev/null +++ b/apps/api/src/slack/slack-events.controller.ts @@ -0,0 +1,89 @@ +import { verifySlackSignature } from "@crm/auth"; +import { schemas } from "@crm/validation"; +import { + Body, + Controller, + Headers, + HttpCode, + Logger, + Post, + UnauthorizedException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { ApiExcludeEndpoint } from "@nestjs/swagger"; +import { AllowAnonymous } from "@thallesp/nestjs-better-auth"; +import { AgentTriggerService } from "../agent/agent-trigger.service"; +import type { EnvironmentVariables } from "../config/env.validation"; + +export const SLACK_EVENTS_PATH = "/webhooks/slack/events"; + +@Controller("webhooks/slack") +export class SlackEventsController { + private readonly logger = new Logger(SlackEventsController.name); + private readonly secret: string | undefined; + + constructor( + private readonly agent: AgentTriggerService, + config: ConfigService, + ) { + this.secret = config.get("SLACK_SIGNING_SECRET", { infer: true }); + } + + @Post("events") + @HttpCode(200) + @AllowAnonymous() + @ApiExcludeEndpoint() + async events( + @Body() raw: Buffer, + @Headers("x-slack-request-timestamp") timestamp: string | undefined, + @Headers("x-slack-signature") signature: string | undefined, + ): Promise<{ challenge: string } | { ok: true }> { + const body = Buffer.isBuffer(raw) + ? raw.toString("utf8") + : String(raw ?? ""); + + const verdict = verifySlackSignature({ + body, + timestamp, + signature, + secret: this.secret, + }); + + if (!verdict.ok) { + this.logger.warn({ + message: "A Slack event was refused", + reason: verdict.reason, + }); + throw new UnauthorizedException("The Slack signature did not verify."); + } + + const envelope = schemas.slackEvents.slackEnvelope.safeParse( + JSON.parse(body || "null"), + ); + + if (!envelope.success) return { ok: true }; + + if (envelope.data.type === "url_verification") { + return { challenge: envelope.data.challenge }; + } + + const { event, event_id, team_id } = envelope.data; + + if (!schemas.slackEvents.isActionable(event)) return { ok: true }; + + const { stored } = await this.agent.slackEventReceived({ + eventId: event_id, + type: event.type, + teamId: team_id, + channelId: event.channel, + payload: JSON.parse(body), + }); + + this.logger.log({ + message: stored ? "Slack event stored" : "Slack event already seen", + type: event.type, + }); + + return { ok: true }; + } +} diff --git a/apps/api/src/slack/slack.module.ts b/apps/api/src/slack/slack.module.ts index 8d207e35..da17b819 100644 --- a/apps/api/src/slack/slack.module.ts +++ b/apps/api/src/slack/slack.module.ts @@ -4,9 +4,11 @@ import { TrpcModule } from "../trpc/trpc.module"; import { SlackRouter } from "./slack.router"; import { SlackChannelsService } from "./slack-channels.service"; import { SlackConnectionService } from "./slack-connection.service"; +import { SlackEventsController } from "./slack-events.controller"; @Module({ imports: [TrpcModule, AgentModule], + controllers: [SlackEventsController], providers: [SlackChannelsService, SlackConnectionService, SlackRouter], exports: [SlackConnectionService], }) diff --git a/apps/api/test/slack-events.spec.ts b/apps/api/test/slack-events.spec.ts new file mode 100644 index 00000000..99ba3bc4 --- /dev/null +++ b/apps/api/test/slack-events.spec.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { signBody } from "@crm/auth"; +import { UnauthorizedException } from "@nestjs/common"; +import { SlackEventsController } from "../src/slack/slack-events.controller"; + +const secret = "test-signing-secret"; + +type Stored = { + eventId: string; + type: string; + teamId?: string; + channelId?: string; +}; + +const stored: Stored[] = []; +let nextResult = { stored: true }; + +const agent = { + slackEventReceived: async (input: Stored) => { + stored.push(input); + return nextResult; + }, +} as never; + +const config = { + get: () => secret, +} as never; + +const controller = new SlackEventsController(agent, config); + +const post = ( + payload: unknown, + over: { secret?: string; skew?: number } = {}, +) => { + const body = JSON.stringify(payload); + const timestamp = String(Math.floor(Date.now() / 1000) + (over.skew ?? 0)); + const signature = signBody(body, timestamp, over.secret ?? secret); + + return controller.events(Buffer.from(body), timestamp, signature); +}; + +const callback = (event: Record, eventId = "Ev1") => ({ + type: "event_callback", + event_id: eventId, + team_id: "T1", + event, +}); + +beforeEach(() => { + stored.length = 0; + nextResult = { stored: true }; +}); + +describe("the Slack events endpoint", () => { + it("answers Slack's setup handshake with the challenge", async () => { + const result = await post({ + type: "url_verification", + challenge: "abc123", + }); + + expect(result).toEqual({ challenge: "abc123" }); + expect(stored).toHaveLength(0); + }); + + it("stores a member-joined event for the agent to act on", async () => { + await post( + callback({ type: "member_joined_channel", channel: "C1", user: "U1" }), + ); + + expect(stored).toHaveLength(1); + expect(stored[0]).toMatchObject({ + eventId: "Ev1", + type: "member_joined_channel", + channelId: "C1", + teamId: "T1", + }); + }); + + it("stores a human message", async () => { + await post( + callback({ + type: "message", + channel: "C1", + user: "U1", + text: "org_abc123", + }), + ); + + expect(stored).toHaveLength(1); + }); + + it("refuses a body that was not signed with our secret", async () => { + const attempt = post( + callback({ type: "member_joined_channel", channel: "C1" }), + { secret: "someone-elses-secret" }, + ); + + await expect(attempt).rejects.toThrow(UnauthorizedException); + expect(stored).toHaveLength(0); + }); + + it("refuses a replayed request from outside the window", async () => { + const attempt = post( + callback({ type: "member_joined_channel", channel: "C1" }), + { skew: -3600 }, + ); + + await expect(attempt).rejects.toThrow(UnauthorizedException); + expect(stored).toHaveLength(0); + }); + + it("ignores our own bot, which would otherwise talk to itself", async () => { + await post( + callback({ + type: "message", + channel: "C1", + text: "hello", + bot_id: "B1", + }), + ); + + expect(stored).toHaveLength(0); + }); + + it("ignores an event type we do not act on", async () => { + await post(callback({ type: "reaction_added", channel: "C1" })); + + expect(stored).toHaveLength(0); + }); + + it("answers 200 for a redelivery rather than storing it twice", async () => { + nextResult = { stored: false }; + + const result = await post( + callback({ type: "member_joined_channel", channel: "C1" }), + ); + + expect(result).toEqual({ ok: true }); + }); + + it("answers 200 to a shape it cannot read, so Slack stops retrying", async () => { + const result = await post({ type: "something_new", nested: { a: 1 } }); + + expect(result).toEqual({ ok: true }); + expect(stored).toHaveLength(0); + }); + + it("refuses everything when no signing secret is configured", async () => { + const unconfigured = new SlackEventsController(agent, { + get: () => undefined, + } as never); + + const body = JSON.stringify({ type: "url_verification", challenge: "x" }); + const timestamp = String(Math.floor(Date.now() / 1000)); + + await expect( + unconfigured.events( + Buffer.from(body), + timestamp, + signBody(body, timestamp, secret), + ), + ).rejects.toThrow(UnauthorizedException); + }); +}); diff --git a/docs/environment.md b/docs/environment.md index 22417c60..73f0c57d 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -52,6 +52,13 @@ list, read by the sign-in guard *and* the sync's "which side is external" decisi if they drifted a colleague would be refused at the door or filed as a lead. **An empty list fails closed.** Parsed on demand. `packages/auth/src/workspace.ts`. +**`SLACK_SIGNING_SECRET`** is required only for inbound Slack, and is separate +from the OAuth pair. `/webhooks/slack/events` is a public POST, so the signature +is the whole of its authentication: unset, the endpoint refuses everything rather +than trusting anyone. It is the Signing Secret on the Slack app's Basic +Information page, not a token. Point the app's Event Subscriptions request URL at +`API_URL` + `/webhooks/slack/events`. + ## Where things are - **`API_URL`** (`:3001`) mints session cookies and serves `/api/auth/*`; diff --git a/turbo.json b/turbo.json index 110f9bd9..b4590444 100644 --- a/turbo.json +++ b/turbo.json @@ -16,6 +16,7 @@ "MICROSOFT_CLIENT_SECRET", "SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET", + "SLACK_SIGNING_SECRET", "MICROSOFT_TENANT_ID", "AUTH_COOKIE_DOMAIN", "CRON_SECRET", From 075dcccca6d777adda79fd6f7ea475c2e5737779 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:09:11 -0700 Subject: [PATCH 05/21] feat(agent): drain the Slack inbox into a parked run Closes the loop. A stored event finds the run that owns its channel and resumes it on that run's own continuation token, so the agent carries on from where it parked rather than starting again. Every event settles, including the ones that go nowhere. An event whose channel owns no live run is marked processed with a reason, not left to be retried forever. A row already processed is skipped, so a second drain after a redelivery does nothing. Both are the difference between an inbox and a backlog. The drain hangs off POST /internal/crm/dispatch, which the API already pokes on every stored event. It is deliberately not routed through the channel's receive: receive must return a session, and an event that resumes nothing has none to give. app_mention joins the actionable set. It arrives alongside message.channels when the bot is in the channel, so it changes nothing today, but a mention in a channel the bot has not joined is exactly how a customer asks for help. describe() is what the agent actually reads. It names the channel, the user and the text, and truncates at 2000 characters so one pasted log cannot fill a turn. 9 integration tests. --- apps/agent/agent/channels/crm.ts | 2 + apps/agent/agent/lib/slack-events-config.ts | 4 + apps/agent/agent/lib/slack-events.ts | 122 +++++++++ .../test/slack-events.integration.spec.ts | 253 ++++++++++++++++++ packages/validation/src/slack-events.ts | 4 + 5 files changed, 385 insertions(+) create mode 100644 apps/agent/agent/lib/slack-events-config.ts create mode 100644 apps/agent/agent/lib/slack-events.ts create mode 100644 apps/agent/test/slack-events.integration.spec.ts diff --git a/apps/agent/agent/channels/crm.ts b/apps/agent/agent/channels/crm.ts index aff3af45..98c032a5 100644 --- a/apps/agent/agent/channels/crm.ts +++ b/apps/agent/agent/channels/crm.ts @@ -30,6 +30,7 @@ import { DISPATCH } from "../lib/dispatch-config"; import { settle } from "../lib/enrichment"; import { finishRun, runResultOf } from "../lib/run-runtime"; import { attribute } from "../lib/session-purpose"; +import { drainSlackEvents } from "../lib/slack-events"; import { createSlackChannel } from "../lib/slack-membership"; import { reconcileStaleTasks } from "../lib/stale-tasks"; import { completeTask, taskSubject } from "../lib/tasks"; @@ -146,6 +147,7 @@ export default defineChannel({ }), ); await drainAgentRuns(send); + await drainSlackEvents(send); })(), ); diff --git a/apps/agent/agent/lib/slack-events-config.ts b/apps/agent/agent/lib/slack-events-config.ts new file mode 100644 index 00000000..90c67d05 --- /dev/null +++ b/apps/agent/agent/lib/slack-events-config.ts @@ -0,0 +1,4 @@ +export const SLACK_EVENTS = { + batch: 20, + maxTextChars: 2_000, +} as const; diff --git a/apps/agent/agent/lib/slack-events.ts b/apps/agent/agent/lib/slack-events.ts new file mode 100644 index 00000000..dccb6da9 --- /dev/null +++ b/apps/agent/agent/lib/slack-events.ts @@ -0,0 +1,122 @@ +import { db } from "@crm/db"; +import type { SlackEvent } from "@crm/validation"; +import { schemas } from "@crm/validation"; +import { SLACK_EVENT_TYPES } from "@crm/validation/slack-events"; +import type { SendFn } from "eve/channels"; +import { resumeAgentRun, runOnSlackChannel } from "./run-resume"; +import { SLACK_EVENTS } from "./slack-events-config"; + +export type SlackEventOutcome = { + eventId: string; + resumed: boolean; + outcome: string; +}; + +export async function pendingSlackEventIds(): Promise { + const rows = await db.slackEventInbox.findMany({ + where: { processedAt: null }, + orderBy: { receivedAt: "asc" }, + take: SLACK_EVENTS.batch, + select: { id: true }, + }); + + return rows.map((row) => row.id); +} + +export async function dispatchSlackEvent( + id: string, + send: SendFn, +): Promise { + const row = await db.slackEventInbox.findUnique({ + where: { id }, + select: { + id: true, + eventId: true, + channelId: true, + payload: true, + processedAt: true, + }, + }); + + if (!row || row.processedAt) return null; + + const settle = (outcome: string, resumed = false) => + db.slackEventInbox + .updateMany({ + where: { id, processedAt: null }, + data: { processedAt: new Date(), outcome: outcome.slice(0, 300) }, + }) + .then(() => ({ eventId: row.eventId, resumed, outcome })); + + if (!row.channelId) return settle("The event names no channel."); + + const envelope = schemas.slackEvents.eventCallback.safeParse(row.payload); + if (!envelope.success) return settle("The stored payload cannot be read."); + + const runId = await runOnSlackChannel(row.channelId); + if (!runId) { + return settle(`No live agent run owns ${row.channelId}.`); + } + + const result = await resumeAgentRun( + { + runId, + message: describe(envelope.data.event), + source: `slack.${envelope.data.event.type}`, + attributes: { + slackChannelId: row.channelId, + slackEventId: row.eventId, + }, + }, + send, + ); + + if (result.kind === "resumed") { + await db.slackEventInbox.updateMany({ + where: { id }, + data: { runId }, + }); + return settle(`Resumed run ${runId}.`, true); + } + + return settle(`Run ${runId} was not resumed: ${result.reason}`); +} + +export async function drainSlackEvents(send: SendFn): Promise { + const ids = await pendingSlackEventIds(); + + const outcomes = await Promise.all( + ids.map((id) => + dispatchSlackEvent(id, send).catch((error) => { + console.error( + `[agent] Slack event ${id} could not be dispatched: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return null; + }), + ), + ); + + return outcomes.filter((outcome) => outcome?.resumed).length; +} + +export function describe(event: SlackEvent): string { + if (event.type === SLACK_EVENT_TYPES.MEMBER_JOINED) { + return [ + `Somebody joined the Slack channel this run is working in (${event.channel}).`, + event.user ? `Their Slack user id is ${event.user}.` : "", + "Carry on from where you parked.", + ] + .filter(Boolean) + .join(" "); + } + + const text = event.text?.trim() ?? ""; + + return [ + `A message arrived in the Slack channel this run is working in (${event.channel})`, + event.user ? ` from ${event.user}` : "", + `: ${text.slice(0, SLACK_EVENTS.maxTextChars)}`, + ].join(""); +} diff --git a/apps/agent/test/slack-events.integration.spec.ts b/apps/agent/test/slack-events.integration.spec.ts new file mode 100644 index 00000000..783e246d --- /dev/null +++ b/apps/agent/test/slack-events.integration.spec.ts @@ -0,0 +1,253 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import { db } from "@crm/db"; +import type { SendFn } from "eve/channels"; +import { runToken } from "../agent/lib/custom-agent-dispatch"; +import { claimSlackChannel } from "../agent/lib/run-resume"; +import { + describe as describeEvent, + dispatchSlackEvent, + drainSlackEvents, +} from "../agent/lib/slack-events"; + +const suffix = crypto.randomUUID(); +const userId = `slack-events-user-${suffix}`; + +let agentId = ""; +let versionId = ""; + +const deliveries: { message: string; continuationToken?: string }[] = []; + +const send = (async (message: string, options: Record) => { + deliveries.push({ + message, + continuationToken: options?.continuationToken as string, + }); + return { id: `ses_${deliveries.length}` }; +}) as unknown as SendFn; + +async function makeRun( + channelId: string | null, + status = "WAITING_FOR_APPROVAL", +) { + const unique = crypto.randomUUID(); + const run = await db.agentRun.create({ + data: { + agentId, + versionId, + status: status as "WAITING_FOR_APPROVAL", + triggerType: "MANUAL", + idempotencyKey: `se-${unique}`, + correlationId: `se-${unique}`, + sessionId: `ses_seed_${unique}`, + }, + select: { id: true }, + }); + if (channelId) await claimSlackChannel(run.id, channelId); + return run.id; +} + +async function inbox(event: Record, channelId: string | null) { + const eventId = `Ev-${crypto.randomUUID()}`; + const row = await db.slackEventInbox.create({ + data: { + eventId, + type: String(event.type), + channelId, + teamId: "T1", + payload: { + type: "event_callback", + event_id: eventId, + team_id: "T1", + event, + }, + }, + select: { id: true }, + }); + return row.id; +} + +beforeAll(async () => { + await db.user.create({ + data: { id: userId, name: "Slack Events", email: `${userId}@example.test` }, + }); + const agent = await db.agentDefinition.create({ + data: { + name: `Slack events ${suffix}`, + status: "LIVE", + createdById: userId, + }, + select: { id: true }, + }); + agentId = agent.id; + const version = await db.agentVersion.create({ + data: { + agentId, + number: 1, + status: "DEPLOYED", + createdById: userId, + instructions: "x", + manifest: {}, + modelId: "m", + sandboxPolicy: {}, + }, + select: { id: true }, + }); + versionId = version.id; + await db.agentDefinition.update({ + where: { id: agentId }, + data: { currentVersionId: versionId }, + }); +}); + +afterAll(async () => { + await db.slackEventInbox.deleteMany({ where: { teamId: "T1" } }); + await db.agentRun.deleteMany({ where: { agentId } }); + await db.agentDefinition.updateMany({ + where: { id: agentId }, + data: { currentVersionId: null }, + }); + await db.agentVersion.deleteMany({ where: { agentId } }); + await db.agentDefinition.deleteMany({ where: { id: agentId } }); + await db.user.deleteMany({ where: { id: userId } }); +}); + +beforeEach(() => { + deliveries.length = 0; +}); + +describe("turning a stored Slack event into a resume", () => { + it("resumes the run that owns the channel", async () => { + const channelId = `C-${crypto.randomUUID()}`; + const runId = await makeRun(channelId); + const id = await inbox( + { type: "message", channel: channelId, user: "U1", text: "org_abc" }, + channelId, + ); + + const outcome = await dispatchSlackEvent(id, send); + + expect(outcome?.resumed).toBe(true); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]?.continuationToken).toBe(runToken(runId)); + expect(deliveries[0]?.message).toContain("org_abc"); + }); + + it("marks the row processed, so a second drain does nothing", async () => { + const channelId = `C-${crypto.randomUUID()}`; + await makeRun(channelId); + const id = await inbox( + { type: "message", channel: channelId, text: "hello" }, + channelId, + ); + + await dispatchSlackEvent(id, send); + const again = await dispatchSlackEvent(id, send); + + expect(again).toBeNull(); + expect(deliveries).toHaveLength(1); + }); + + it("settles an event whose channel owns no run, rather than retrying forever", async () => { + const channelId = `C-${crypto.randomUUID()}`; + const id = await inbox( + { type: "message", channel: channelId, text: "nobody home" }, + channelId, + ); + + const outcome = await dispatchSlackEvent(id, send); + + expect(outcome?.resumed).toBe(false); + expect(outcome?.outcome).toContain("No live agent run"); + expect(deliveries).toHaveLength(0); + + const row = await db.slackEventInbox.findUnique({ + where: { id }, + select: { processedAt: true }, + }); + expect(row?.processedAt).not.toBeNull(); + }); + + it("settles an event that names no channel", async () => { + const id = await inbox({ type: "message", text: "no channel" }, null); + + const outcome = await dispatchSlackEvent(id, send); + + expect(outcome?.outcome).toContain("names no channel"); + }); + + it("records the run it resumed against the event", async () => { + const channelId = `C-${crypto.randomUUID()}`; + const runId = await makeRun(channelId); + const id = await inbox( + { type: "member_joined_channel", channel: channelId, user: "U9" }, + channelId, + ); + + await dispatchSlackEvent(id, send); + + const row = await db.slackEventInbox.findUnique({ + where: { id }, + select: { runId: true }, + }); + expect(row?.runId).toBe(runId); + }); + + it("drains every pending event and counts the resumes", async () => { + const channelId = `C-${crypto.randomUUID()}`; + await makeRun(channelId); + await inbox({ type: "message", channel: channelId, text: "a" }, channelId); + await inbox({ type: "message", channel: channelId, text: "b" }, channelId); + await inbox( + { type: "message", channel: "C-nobody", text: "c" }, + "C-nobody", + ); + + const resumed = await drainSlackEvents(send); + + expect(resumed).toBeGreaterThanOrEqual(2); + }); +}); + +describe("what the agent is told", () => { + it("names the channel and the text for a message", () => { + const message = describeEvent({ + type: "message", + channel: "C1", + user: "U1", + text: "org_abc123", + }); + + expect(message).toContain("C1"); + expect(message).toContain("U1"); + expect(message).toContain("org_abc123"); + }); + + it("says somebody joined, and tells the run to carry on", () => { + const message = describeEvent({ + type: "member_joined_channel", + channel: "C1", + user: "U7", + }); + + expect(message).toContain("joined"); + expect(message).toContain("U7"); + expect(message).toContain("Carry on"); + }); + + it("truncates a very long message rather than sending the lot", () => { + const message = describeEvent({ + type: "message", + channel: "C1", + text: "x".repeat(9000), + }); + + expect(message.length).toBeLessThan(3000); + }); +}); diff --git a/packages/validation/src/slack-events.ts b/packages/validation/src/slack-events.ts index 113562d7..a02c3a30 100644 --- a/packages/validation/src/slack-events.ts +++ b/packages/validation/src/slack-events.ts @@ -5,6 +5,7 @@ const trimmed = z.string().trim().min(1); export const SLACK_EVENT_TYPES = { MEMBER_JOINED: "member_joined_channel", MESSAGE: "message", + APP_MENTION: "app_mention", } as const; export const urlVerification = z.object({ @@ -44,6 +45,9 @@ export function isActionable(event: SlackEvent): boolean { if (isFromApp(event)) return false; if (event.type === SLACK_EVENT_TYPES.MEMBER_JOINED) return true; + if (event.type === SLACK_EVENT_TYPES.APP_MENTION) { + return Boolean(event.text?.trim()); + } return ( event.type === SLACK_EVENT_TYPES.MESSAGE && From 0e7b72704d23ce3348eec42ba1ce252c53ae9ac7 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:16:32 -0700 Subject: [PATCH 06/21] feat(agent): let a run open its own Slack channel and watch it open_slack_channel is the first half of the onboarding flow. A deployed agent names the channel in plain words, gets a tidied Slack name, and the run claims the channel so every later message and join wakes that same run. Without this nothing ever sets AgentRun.slackChannelId and the inbox resumes nobody. A name already in use gives back the existing channel instead of an error, so a retried run lands in the channel it made the first time. claimSlackChannel now says which channel the run watches. The claim is write-once, so a run that opens a second channel used to be told it succeeded while its events went elsewhere. The tool reports the truth. app_mention is deduplicated against message. Slack sends both for one human sentence when the bot is in the channel, which resumed the run twice for one thing said once. A unique index on (channelId, messageTs) stops the second at the door, and the agent is told it was mentioned rather than spoken to. 19 tests across validation, the API and the agent. --- apps/agent/agent/lib/run-resume.ts | 9 ++- apps/agent/agent/lib/slack-channel-name.ts | 12 ++++ apps/agent/agent/lib/slack-config.ts | 4 ++ apps/agent/agent/lib/slack-events.ts | 8 +++ apps/agent/agent/lib/slack-membership.ts | 23 +++++++- apps/agent/agent/tools/open_slack_channel.ts | 56 +++++++++++++++++++ .../agent/test/run-resume.integration.spec.ts | 9 +++ apps/agent/test/slack-channel-open.spec.ts | 33 +++++++++++ .../test/slack-events.integration.spec.ts | 13 +++++ apps/api/src/agent/agent-trigger.service.ts | 2 + apps/api/src/slack/slack-events.controller.ts | 1 + apps/api/test/slack-events.spec.ts | 19 +++++++ apps/app/lib/agent-transcript.ts | 1 + .../migration.sql | 11 ++++ packages/db/prisma/schema.prisma | 2 + packages/validation/test/slack-events.spec.ts | 23 ++++++++ 16 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 apps/agent/agent/lib/slack-channel-name.ts create mode 100644 apps/agent/agent/tools/open_slack_channel.ts create mode 100644 apps/agent/test/slack-channel-open.spec.ts create mode 100644 packages/db/prisma/migrations/20260828151505_slack_event_message_ts/migration.sql diff --git a/apps/agent/agent/lib/run-resume.ts b/apps/agent/agent/lib/run-resume.ts index 2da2ec6d..d991e853 100644 --- a/apps/agent/agent/lib/run-resume.ts +++ b/apps/agent/agent/lib/run-resume.ts @@ -111,9 +111,16 @@ export async function runOnSlackChannel( export async function claimSlackChannel( runId: string, channelId: string, -): Promise { +): Promise { await db.agentRun.updateMany({ where: { id: runId, slackChannelId: null }, data: { slackChannelId: channelId.trim() }, }); + + const run = await db.agentRun.findUnique({ + where: { id: runId }, + select: { slackChannelId: true }, + }); + + return run?.slackChannelId ?? null; } diff --git a/apps/agent/agent/lib/slack-channel-name.ts b/apps/agent/agent/lib/slack-channel-name.ts new file mode 100644 index 00000000..e8553062 --- /dev/null +++ b/apps/agent/agent/lib/slack-channel-name.ts @@ -0,0 +1,12 @@ +import { SLACK } from "./slack-config"; + +export function toChannelName(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, SLACK.channel.maxNameChars) + .replace(/-$/, ""); +} diff --git a/apps/agent/agent/lib/slack-config.ts b/apps/agent/agent/lib/slack-config.ts index d7e687c3..0b726ee9 100644 --- a/apps/agent/agent/lib/slack-config.ts +++ b/apps/agent/agent/lib/slack-config.ts @@ -8,6 +8,10 @@ export const SLACK = { retryUnitMs: SECOND_MS, }, + channel: { + maxNameChars: 80, + }, + inventory: { pageSize: 200, channelTypes: "public_channel,private_channel", diff --git a/apps/agent/agent/lib/slack-events.ts b/apps/agent/agent/lib/slack-events.ts index dccb6da9..c773837e 100644 --- a/apps/agent/agent/lib/slack-events.ts +++ b/apps/agent/agent/lib/slack-events.ts @@ -114,6 +114,14 @@ export function describe(event: SlackEvent): string { const text = event.text?.trim() ?? ""; + if (event.type === SLACK_EVENT_TYPES.APP_MENTION) { + return [ + `Somebody mentioned Comp AI in the Slack channel this run is working in (${event.channel})`, + event.user ? ` from ${event.user}` : "", + `: ${text.slice(0, SLACK_EVENTS.maxTextChars)}`, + ].join(""); + } + return [ `A message arrived in the Slack channel this run is working in (${event.channel})`, event.user ? ` from ${event.user}` : "", diff --git a/apps/agent/agent/lib/slack-membership.ts b/apps/agent/agent/lib/slack-membership.ts index c776d5c3..37a003fe 100644 --- a/apps/agent/agent/lib/slack-membership.ts +++ b/apps/agent/agent/lib/slack-membership.ts @@ -15,6 +15,8 @@ const ALREADY_IN_CHANNEL = "already_in_channel"; const CHANNEL_NOT_FOUND = "channel_not_found"; +const NAME_TAKEN = "name_taken"; + const channelInfo = schemas.slack.reply.extend({ channel: z .object({ @@ -222,6 +224,23 @@ function explain(error: string): string { } } +async function channelNamed( + name: string, +): Promise<{ id: string; name: string } | { error: string }> { + const channel = await db.slackChannel.findFirst({ + where: { name }, + select: { id: true, name: true }, + }); + + if (channel) return channel; + + await requestSlackInventorySync(); + + return { + error: `#${name} already exists in Slack, and Comp AI cannot see it yet.`, + }; +} + export async function createSlackChannel( name: string, isPrivate: boolean, @@ -253,7 +272,9 @@ export async function createSlackChannel( return { error: "Slack sent back something unreadable." }; if (!parsed.data.ok || !parsed.data.channel) { - return { error: explain(parsed.data.error ?? "rejected") }; + const error = parsed.data.error ?? "rejected"; + if (error === NAME_TAKEN) return channelNamed(name); + return { error: explain(error) }; } const channel = parsed.data.channel; diff --git a/apps/agent/agent/tools/open_slack_channel.ts b/apps/agent/agent/tools/open_slack_channel.ts new file mode 100644 index 00000000..f8737d72 --- /dev/null +++ b/apps/agent/agent/tools/open_slack_channel.ts @@ -0,0 +1,56 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { claimSlackChannel } from "../lib/run-resume"; +import { requireTeamAgentAttribute } from "../lib/session-purpose"; +import { toChannelName } from "../lib/slack-channel-name"; +import { createSlackChannel } from "../lib/slack-membership"; + +export default defineTool({ + description: + "Open the Slack channel this run works in, and start watching it. Every message and every join in that channel wakes this run up, so open the channel before you invite anybody. A name already in use gives you back the existing channel.", + inputSchema: z.object({ + name: z + .string() + .trim() + .min(1) + .max(120) + .describe( + "What to call it, in plain words: 'Acme onboarding'. Spaces and capitals are fine; Slack gets a tidied version.", + ), + isPrivate: z + .boolean() + .default(false) + .describe( + "True for work the whole workspace must not read. A customer channel is public.", + ), + }), + async execute({ name, isPrivate }, ctx) { + const runId = requireTeamAgentAttribute(ctx, "runId"); + const channelName = toChannelName(name); + + if (!channelName) { + return { + opened: false as const, + reason: "That name has no letters or numbers Slack accepts.", + }; + } + + const outcome = await createSlackChannel(channelName, isPrivate); + if ("error" in outcome) { + return { opened: false as const, reason: outcome.error }; + } + + const watching = await claimSlackChannel(runId, outcome.id); + + return { + opened: true as const, + channelId: outcome.id, + channelName: outcome.name, + watching: watching === outcome.id, + reason: + watching === outcome.id + ? undefined + : `This run already watches ${watching}, so messages in #${outcome.name} do not reach it.`, + }; + }, +}); diff --git a/apps/agent/test/run-resume.integration.spec.ts b/apps/agent/test/run-resume.integration.spec.ts index 264dddd8..b5afa8ff 100644 --- a/apps/agent/test/run-resume.integration.spec.ts +++ b/apps/agent/test/run-resume.integration.spec.ts @@ -291,6 +291,15 @@ describe("finding the run an event belongs to", () => { expect(await runOnSlackChannel(second)).toBeNull(); }); + it("says which channel the run watches, so a second claim is not silent", async () => { + const runId = await makeRun({ status: "RUNNING" }); + const first = `C-${crypto.randomUUID()}`; + const second = `C-${crypto.randomUUID()}`; + + expect(await claimSlackChannel(runId, first)).toBe(first); + expect(await claimSlackChannel(runId, second)).toBe(first); + }); + it("reads an unknown or blank channel as nobody", async () => { expect(await runOnSlackChannel(`C-${crypto.randomUUID()}`)).toBeNull(); expect(await runOnSlackChannel(" ")).toBeNull(); diff --git a/apps/agent/test/slack-channel-open.spec.ts b/apps/agent/test/slack-channel-open.spec.ts new file mode 100644 index 00000000..278c9ad0 --- /dev/null +++ b/apps/agent/test/slack-channel-open.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { toChannelName } from "../agent/lib/slack-channel-name"; + +describe("tidying a channel name for Slack", () => { + it("lowercases and joins words with a dash", () => { + expect(toChannelName("Acme Onboarding")).toBe("acme-onboarding"); + }); + + it("drops punctuation Slack refuses", () => { + expect(toChannelName("Acme, Inc. — onboarding!")).toBe( + "acme-inc-onboarding", + ); + }); + + it("collapses runs of dashes and trims the ends", () => { + expect(toChannelName(" --acme---onboarding-- ")).toBe("acme-onboarding"); + }); + + it("keeps a name Slack already accepts", () => { + expect(toChannelName("acme-onboarding")).toBe("acme-onboarding"); + }); + + it("cuts a long name to the Slack limit and leaves no trailing dash", () => { + const name = toChannelName(`${"acme ".repeat(40)}onboarding`); + + expect(name.length).toBeLessThanOrEqual(80); + expect(name.endsWith("-")).toBe(false); + }); + + it("gives back nothing when the name has no letters or numbers", () => { + expect(toChannelName("!!! ---")).toBe(""); + }); +}); diff --git a/apps/agent/test/slack-events.integration.spec.ts b/apps/agent/test/slack-events.integration.spec.ts index 783e246d..ebb431f8 100644 --- a/apps/agent/test/slack-events.integration.spec.ts +++ b/apps/agent/test/slack-events.integration.spec.ts @@ -241,6 +241,19 @@ describe("what the agent is told", () => { expect(message).toContain("Carry on"); }); + it("says the agent was mentioned, not that a message arrived", () => { + const message = describeEvent({ + type: "app_mention", + channel: "C1", + user: "U3", + text: "<@U1> where are we", + }); + + expect(message).toContain("mentioned"); + expect(message).toContain("U3"); + expect(message).toContain("where are we"); + }); + it("truncates a very long message rather than sending the lot", () => { const message = describeEvent({ type: "message", diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index e764becf..f6715925 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -560,6 +560,7 @@ export class AgentTriggerService { type: string; teamId?: string; channelId?: string; + messageTs?: string; payload: Prisma.InputJsonValue; }): Promise<{ stored: boolean }> { const existing = await this.db.slackEventInbox.findUnique({ @@ -576,6 +577,7 @@ export class AgentTriggerService { type: input.type, teamId: input.teamId ?? null, channelId: input.channelId ?? null, + messageTs: input.messageTs ?? null, payload: input.payload, }, }); diff --git a/apps/api/src/slack/slack-events.controller.ts b/apps/api/src/slack/slack-events.controller.ts index 95da94ee..7b599be5 100644 --- a/apps/api/src/slack/slack-events.controller.ts +++ b/apps/api/src/slack/slack-events.controller.ts @@ -76,6 +76,7 @@ export class SlackEventsController { type: event.type, teamId: team_id, channelId: event.channel, + messageTs: event.ts, payload: JSON.parse(body), }); diff --git a/apps/api/test/slack-events.spec.ts b/apps/api/test/slack-events.spec.ts index 99ba3bc4..1f9d7232 100644 --- a/apps/api/test/slack-events.spec.ts +++ b/apps/api/test/slack-events.spec.ts @@ -10,6 +10,7 @@ type Stored = { type: string; teamId?: string; channelId?: string; + messageTs?: string; }; const stored: Stored[] = []; @@ -89,6 +90,24 @@ describe("the Slack events endpoint", () => { expect(stored).toHaveLength(1); }); + it("stores a mention, which is how somebody asks the agent for help", async () => { + await post( + callback({ + type: "app_mention", + channel: "C1", + user: "U1", + text: "<@U9> where are we", + ts: "1700000000.000100", + }), + ); + + expect(stored).toHaveLength(1); + expect(stored[0]).toMatchObject({ + type: "app_mention", + messageTs: "1700000000.000100", + }); + }); + it("refuses a body that was not signed with our secret", async () => { const attempt = post( callback({ type: "member_joined_channel", channel: "C1" }), diff --git a/apps/app/lib/agent-transcript.ts b/apps/app/lib/agent-transcript.ts index 2898c35b..6dd30e0a 100644 --- a/apps/app/lib/agent-transcript.ts +++ b/apps/app/lib/agent-transcript.ts @@ -77,6 +77,7 @@ const VERBS: ToolVerbs = { schedule_recheck: "Decided when to look again", record_job_change: "Raised a job change", list_deals: "Reviewed the deal pipeline", + open_slack_channel: "Opened a Slack channel and watched it", list_outstanding_work: "Looked for outstanding work", set_chat_title: "Named this chat", list_fields: "Read what this workspace tracks", diff --git a/packages/db/prisma/migrations/20260828151505_slack_event_message_ts/migration.sql b/packages/db/prisma/migrations/20260828151505_slack_event_message_ts/migration.sql new file mode 100644 index 00000000..b35eef67 --- /dev/null +++ b/packages/db/prisma/migrations/20260828151505_slack_event_message_ts/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[channelId,messageTs]` on the table `slackEventInbox` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "slackEventInbox" ADD COLUMN "messageTs" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "slackEventInbox_channelId_messageTs_key" ON "slackEventInbox"("channelId", "messageTs"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 8c88a76b..aa0d8ab9 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -114,6 +114,7 @@ model SlackEventInbox { eventId String @unique teamId String? channelId String? + messageTs String? type String payload Json @@ -123,6 +124,7 @@ model SlackEventInbox { processedAt DateTime? outcome String? + @@unique([channelId, messageTs]) @@index([processedAt, receivedAt]) @@index([channelId]) @@map("slackEventInbox") diff --git a/packages/validation/test/slack-events.spec.ts b/packages/validation/test/slack-events.spec.ts index d2ee38fc..642a2d38 100644 --- a/packages/validation/test/slack-events.spec.ts +++ b/packages/validation/test/slack-events.spec.ts @@ -95,6 +95,29 @@ describe("isActionable", () => { expect(isActionable(message({ text: " " }))).toBe(false); }); + it("acts on a mention, which is how somebody asks the agent for help", () => { + expect( + isActionable({ type: "app_mention", channel: "C1", text: "<@U1> help" }), + ).toBe(true); + }); + + it("ignores a mention with no words after it", () => { + expect( + isActionable({ type: "app_mention", channel: "C1", text: " " }), + ).toBe(false); + }); + + it("ignores a mention our own bot posted", () => { + expect( + isActionable({ + type: "app_mention", + channel: "C1", + text: "<@U1> hi", + bot_id: "B1", + }), + ).toBe(false); + }); + it("ignores event types we do not handle", () => { expect(isActionable({ type: "reaction_added", channel: "C1" })).toBe(false); }); From ee6ac0d8fd53b31722225506efebb1b5c09b4317 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:46:27 -0700 Subject: [PATCH 07/21] feat(agent): invite people to the channel a run opened Closes the standing gap. conversations.connect:write has been requested from every Slack workspace since the connection shipped and nothing used it, so a customer could never reach the channel the agent made. One tool covers both kinds of person. An address Slack already knows is added with conversations.invite. An address it does not know gets a Slack Connect invitation, and the tool hands back the invitation link. The agent does not have to know which somebody is. already_in_channel counts as invited, so re-running the flow is quiet rather than an error. A lookup failure that is not a missing person stops the invitation instead of falling through to Connect, because "reconnect Slack" and "this person is external" need different answers. Every Slack call now goes through one caller in slack-api.ts, which owns the timeout, the rate-limit retry and the parse. Four hand-rolled fetches in slack-membership.ts went with it. 6 tests. --- apps/agent/agent/lib/run-resume.ts | 16 +- apps/agent/agent/lib/slack-api.ts | 86 ++++++++++ apps/agent/agent/lib/slack-invite.ts | 103 ++++++++++++ apps/agent/agent/lib/slack-membership.ts | 102 ++++-------- .../agent/tools/invite_to_slack_channel.ts | 50 ++++++ .../test/slack-invite.integration.spec.ts | 151 ++++++++++++++++++ apps/app/lib/agent-transcript.ts | 1 + packages/validation/src/slack.ts | 9 ++ 8 files changed, 445 insertions(+), 73 deletions(-) create mode 100644 apps/agent/agent/lib/slack-api.ts create mode 100644 apps/agent/agent/lib/slack-invite.ts create mode 100644 apps/agent/agent/tools/invite_to_slack_channel.ts create mode 100644 apps/agent/test/slack-invite.integration.spec.ts diff --git a/apps/agent/agent/lib/run-resume.ts b/apps/agent/agent/lib/run-resume.ts index d991e853..9c21f5f9 100644 --- a/apps/agent/agent/lib/run-resume.ts +++ b/apps/agent/agent/lib/run-resume.ts @@ -108,6 +108,15 @@ export async function runOnSlackChannel( return run?.id ?? null; } +export async function channelOfRun(runId: string): Promise { + const run = await db.agentRun.findUnique({ + where: { id: runId }, + select: { slackChannelId: true }, + }); + + return run?.slackChannelId ?? null; +} + export async function claimSlackChannel( runId: string, channelId: string, @@ -117,10 +126,5 @@ export async function claimSlackChannel( data: { slackChannelId: channelId.trim() }, }); - const run = await db.agentRun.findUnique({ - where: { id: runId }, - select: { slackChannelId: true }, - }); - - return run?.slackChannelId ?? null; + return channelOfRun(runId); } diff --git a/apps/agent/agent/lib/slack-api.ts b/apps/agent/agent/lib/slack-api.ts new file mode 100644 index 00000000..915ac061 --- /dev/null +++ b/apps/agent/agent/lib/slack-api.ts @@ -0,0 +1,86 @@ +import type { z } from "zod"; +import { SLACK } from "./slack-config"; + +type Reply = { ok: boolean; error?: string }; + +export type SlackOutcome = + | { ok: true; data: T } + | { ok: false; error: string }; + +const UNREADABLE = "unreadable_reply"; +const RATELIMITED = "ratelimited"; +const REJECTED = "rejected"; + +async function read( + response: Response, + schema: z.ZodType, + again: () => Promise>, + attempt: number, +): Promise> { + const parsed = schema.safeParse(await response.json().catch(() => null)); + if (!parsed.success) return { ok: false, error: UNREADABLE }; + if (parsed.data.ok) return { ok: true, data: parsed.data }; + + if ( + parsed.data.error === RATELIMITED && + attempt < SLACK.request.maxAttempts + ) { + const wait = Number(response.headers.get("retry-after") ?? "1"); + await new Promise((resolve) => + setTimeout(resolve, wait * SLACK.request.retryUnitMs), + ); + return again(); + } + + return { ok: false, error: parsed.data.error ?? REJECTED }; +} + +export async function slackPost( + token: string, + method: string, + body: Record, + schema: z.ZodType, + attempt = 1, +): Promise> { + const response = await fetch(`https://slack.com/api/${method}`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json; charset=utf-8", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(SLACK.request.timeoutMs), + }); + + return read( + response, + schema, + () => slackPost(token, method, body, schema, attempt + 1), + attempt, + ); +} + +export async function slackGet( + token: string, + method: string, + query: Record, + schema: z.ZodType, + attempt = 1, +): Promise> { + const url = new URL(`https://slack.com/api/${method}`); + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value); + } + + const response = await fetch(url, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(SLACK.request.timeoutMs), + }); + + return read( + response, + schema, + () => slackGet(token, method, query, schema, attempt + 1), + attempt, + ); +} diff --git a/apps/agent/agent/lib/slack-invite.ts b/apps/agent/agent/lib/slack-invite.ts new file mode 100644 index 00000000..11b0c76e --- /dev/null +++ b/apps/agent/agent/lib/slack-invite.ts @@ -0,0 +1,103 @@ +import { schemas } from "@crm/validation"; +import { slackGet, slackPost } from "./slack-api"; +import { slackAccessToken } from "./slack-connection"; + +export type InviteOutcome = + | { invited: true; email: string; kind: "member" | "connect"; url?: string } + | { invited: false; email: string; reason: string }; + +const ALREADY_IN_CHANNEL = "already_in_channel"; +const USERS_NOT_FOUND = "users_not_found"; + +export async function inviteToSlackChannel( + channelId: string, + email: string, +): Promise { + const token = await slackAccessToken(); + if (!token) { + return { invited: false, email, reason: "Slack is not connected." }; + } + + const found = await slackGet( + token, + "users.lookupByEmail", + { email }, + schemas.slack.lookupByEmail, + ); + + if (found.ok && found.data.user) { + return inviteMember(token, channelId, email, found.data.user.id); + } + + if (!found.ok && found.error !== USERS_NOT_FOUND) { + return { invited: false, email, reason: explain(found.error) }; + } + + return inviteGuest(token, channelId, email); +} + +async function inviteMember( + token: string, + channelId: string, + email: string, + userId: string, +): Promise { + const outcome = await slackPost( + token, + "conversations.invite", + { channel: channelId, users: userId }, + schemas.slack.reply, + ); + + if (outcome.ok || outcome.error === ALREADY_IN_CHANNEL) { + return { invited: true, email, kind: "member" }; + } + + return { invited: false, email, reason: explain(outcome.error) }; +} + +async function inviteGuest( + token: string, + channelId: string, + email: string, +): Promise { + const outcome = await slackPost( + token, + "conversations.inviteShared", + { channel: channelId, emails: [email] }, + schemas.slack.inviteShared, + ); + + if (outcome.ok) { + return { invited: true, email, kind: "connect", url: outcome.data.url }; + } + + if (outcome.error === ALREADY_IN_CHANNEL) { + return { invited: true, email, kind: "connect" }; + } + + return { invited: false, email, reason: explain(outcome.error) }; +} + +function explain(error: string): string { + switch (error) { + case "not_in_channel": + return "Comp AI is not in that channel, so it cannot invite anybody."; + case "channel_not_found": + return "Slack cannot see that channel."; + case "invalid_email": + return "Slack refused that address."; + case "cannot_invite_self": + return "That address is Comp AI itself."; + case "missing_scope": + case "restricted_action": + return "Slack refused: this workspace does not allow Comp AI to send this invitation."; + case "org_level_email_not_allowed": + return "This workspace blocks Slack Connect invitations to that address."; + case "invalid_auth": + case "token_revoked": + return "Slack needs to be reconnected."; + default: + return `Slack refused the invitation (${error}).`; + } +} diff --git a/apps/agent/agent/lib/slack-membership.ts b/apps/agent/agent/lib/slack-membership.ts index 37a003fe..8f174060 100644 --- a/apps/agent/agent/lib/slack-membership.ts +++ b/apps/agent/agent/lib/slack-membership.ts @@ -1,7 +1,7 @@ import { db } from "@crm/db"; import { schemas } from "@crm/validation"; import { z } from "zod"; -import { SLACK } from "./slack-config"; +import { slackGet, slackPost } from "./slack-api"; import { slackAccessToken, slackUserToken } from "./slack-connection"; import { requestSlackInventorySync } from "./slack-people"; @@ -30,70 +30,42 @@ async function call( token: string, method: string, body: Record, - attempt = 1, ): Promise<{ ok: boolean; error?: string }> { - const response = await fetch(`https://slack.com/api/${method}`, { - method: "POST", - headers: { - authorization: `Bearer ${token}`, - "content-type": "application/json; charset=utf-8", - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(SLACK.request.timeoutMs), - }); - - const parsed = schemas.slack.reply.safeParse(await response.json()); - if (!parsed.success) return { ok: false, error: "unreadable_reply" }; - if (parsed.data.ok) return { ok: true }; - - if ( - parsed.data.error === "ratelimited" && - attempt < SLACK.request.maxAttempts - ) { - const wait = Number(response.headers.get("retry-after") ?? "1"); - await new Promise((resolve) => - setTimeout(resolve, wait * SLACK.request.retryUnitMs), - ); - return call(token, method, body, attempt + 1); - } - - return { ok: false, error: parsed.data.error }; + const outcome = await slackPost(token, method, body, schemas.slack.reply); + return outcome.ok ? { ok: true } : { ok: false, error: outcome.error }; } async function botUserId(token: string): Promise { - const response = await fetch("https://slack.com/api/auth.test", { - headers: { authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(SLACK.request.timeoutMs), - }); - const parsed = schemas.slack.authTest.safeParse(await response.json()); - return parsed.success && parsed.data.ok - ? (parsed.data.user_id ?? null) - : null; + const outcome = await slackGet( + token, + "auth.test", + {}, + schemas.slack.authTest, + ); + + return outcome.ok ? (outcome.data.user_id ?? null) : null; } async function liveChannelState( token: string, channelId: string, ): Promise { - const url = new URL("https://slack.com/api/conversations.info"); - url.searchParams.set("channel", channelId); - try { - const response = await fetch(url, { - headers: { authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(SLACK.request.timeoutMs), - }); - const parsed = channelInfo.safeParse(await response.json()); - if (!parsed.success) return null; - - if (parsed.data.ok && parsed.data.channel) { + const outcome = await slackGet( + token, + "conversations.info", + { channel: channelId }, + channelInfo, + ); + + if (outcome.ok && outcome.data.channel) { return { - isPrivate: parsed.data.channel.is_private ?? false, - isMember: parsed.data.channel.is_member ?? false, + isPrivate: outcome.data.channel.is_private ?? false, + isMember: outcome.data.channel.is_member ?? false, }; } - return parsed.data.error === CHANNEL_NOT_FOUND + return !outcome.ok && outcome.error === CHANNEL_NOT_FOUND ? { isPrivate: true, isMember: false } : null; } catch { @@ -257,27 +229,23 @@ export async function createSlackChannel( }; } - const response = await fetch("https://slack.com/api/conversations.create", { - method: "POST", - headers: { - authorization: `Bearer ${token}`, - "content-type": "application/json; charset=utf-8", - }, - body: JSON.stringify({ name, is_private: isPrivate }), - signal: AbortSignal.timeout(SLACK.request.timeoutMs), - }); + const outcome = await slackPost( + token, + "conversations.create", + { name, is_private: isPrivate }, + schemas.slack.createReply, + ); - const parsed = schemas.slack.createReply.safeParse(await response.json()); - if (!parsed.success) - return { error: "Slack sent back something unreadable." }; + if (!outcome.ok) { + if (outcome.error === NAME_TAKEN) return channelNamed(name); + return { error: explain(outcome.error) }; + } - if (!parsed.data.ok || !parsed.data.channel) { - const error = parsed.data.error ?? "rejected"; - if (error === NAME_TAKEN) return channelNamed(name); - return { error: explain(error) }; + if (!outcome.data.channel) { + return { error: "Slack sent back something unreadable." }; } - const channel = parsed.data.channel; + const channel = outcome.data.channel; await db.slackChannel.upsert({ where: { id: channel.id }, diff --git a/apps/agent/agent/tools/invite_to_slack_channel.ts b/apps/agent/agent/tools/invite_to_slack_channel.ts new file mode 100644 index 00000000..6a89e340 --- /dev/null +++ b/apps/agent/agent/tools/invite_to_slack_channel.ts @@ -0,0 +1,50 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { channelOfRun } from "../lib/run-resume"; +import { requireTeamAgentAttribute } from "../lib/session-purpose"; +import { inviteToSlackChannel } from "../lib/slack-invite"; + +export default defineTool({ + description: + "Invite people to the Slack channel this run opened. An address inside this workspace is added straight away. An address outside it gets a Slack Connect invitation, which that person has to accept before they can read anything.", + inputSchema: z.object({ + emails: z + .array(z.email()) + .min(1) + .max(10) + .describe( + "Who to invite, by email address. Customers and colleagues both go here.", + ), + channelId: z + .string() + .trim() + .optional() + .describe( + "Leave this out. It defaults to the channel this run opened and watches.", + ), + }), + async execute({ emails, channelId }, ctx) { + const runId = requireTeamAgentAttribute(ctx, "runId"); + const channel = channelId?.trim() || (await channelOfRun(runId)); + + if (!channel) { + return { + reason: + "This run has no Slack channel yet. Open one with open_slack_channel first.", + invited: [], + refused: [], + }; + } + + const outcomes = []; + for (const email of emails) { + outcomes.push(await inviteToSlackChannel(channel, email)); + } + + return { + channelId: channel, + invited: outcomes.filter((outcome) => outcome.invited), + refused: outcomes.filter((outcome) => !outcome.invited), + }; + }, +}); diff --git a/apps/agent/test/slack-invite.integration.spec.ts b/apps/agent/test/slack-invite.integration.spec.ts new file mode 100644 index 00000000..f4269f41 --- /dev/null +++ b/apps/agent/test/slack-invite.integration.spec.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { inviteToSlackChannel } from "../agent/lib/slack-invite"; + +const USER_ID = "slack-invite-spec-user"; +const ACCOUNT_ID = "slack-invite-spec-account"; +const CHANNEL_ID = "CINVITESPEC1"; + +const realFetch = globalThis.fetch; +const requested: { url: string; body: unknown }[] = []; + +function replies(reply: (url: string) => object) { + globalThis.fetch = (async (input: URL | RequestInfo, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + requested.push({ + url, + body: init?.body ? JSON.parse(String(init.body)) : null, + }); + return new Response(JSON.stringify(reply(url)), { + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +} + +const sent = (fragment: string) => + requested.find((call) => call.url.includes(fragment)); + +beforeEach(async () => { + requested.length = 0; + await db.user.upsert({ + where: { id: USER_ID }, + create: { + id: USER_ID, + name: "Slack Invite Spec", + email: `${USER_ID}@example.com`, + }, + update: {}, + }); + await db.account.upsert({ + where: { id: ACCOUNT_ID }, + create: { + id: ACCOUNT_ID, + accountId: "T-INVITE-SPEC", + providerId: "slack", + userId: USER_ID, + accessToken: "xoxb-invite-spec", + }, + update: { accessToken: "xoxb-invite-spec" }, + }); +}); + +afterEach(async () => { + globalThis.fetch = realFetch; + await db.account.deleteMany({ where: { id: ACCOUNT_ID } }); + await db.user.deleteMany({ where: { id: USER_ID } }); +}); + +describe("inviting somebody to a channel", () => { + it("adds a colleague straight away, because Slack already knows them", async () => { + replies((url) => + url.includes("users.lookupByEmail") + ? { ok: true, user: { id: "U7" } } + : { ok: true }, + ); + + const outcome = await inviteToSlackChannel(CHANNEL_ID, "rep@ours.test"); + + expect(outcome).toMatchObject({ invited: true, kind: "member" }); + expect(sent("conversations.invite")?.body).toMatchObject({ + channel: CHANNEL_ID, + users: "U7", + }); + expect(sent("conversations.inviteShared")).toBeUndefined(); + }); + + it("sends a Slack Connect invitation to somebody outside the workspace", async () => { + replies((url) => + url.includes("users.lookupByEmail") + ? { ok: false, error: "users_not_found" } + : { ok: true, invite_id: "I1", url: "https://slack.com/invite/abc" }, + ); + + const outcome = await inviteToSlackChannel( + CHANNEL_ID, + "buyer@customer.test", + ); + + expect(outcome).toMatchObject({ + invited: true, + kind: "connect", + url: "https://slack.com/invite/abc", + }); + expect(sent("conversations.inviteShared")?.body).toMatchObject({ + channel: CHANNEL_ID, + emails: ["buyer@customer.test"], + }); + }); + + it("treats somebody already in the channel as invited, so a retry is quiet", async () => { + replies((url) => + url.includes("users.lookupByEmail") + ? { ok: true, user: { id: "U7" } } + : { ok: false, error: "already_in_channel" }, + ); + + const outcome = await inviteToSlackChannel(CHANNEL_ID, "rep@ours.test"); + + expect(outcome).toMatchObject({ invited: true, kind: "member" }); + }); + + it("says the workspace refused, rather than claiming the invitation went", async () => { + replies((url) => + url.includes("users.lookupByEmail") + ? { ok: false, error: "users_not_found" } + : { ok: false, error: "restricted_action" }, + ); + + const outcome = await inviteToSlackChannel( + CHANNEL_ID, + "buyer@customer.test", + ); + + expect(outcome.invited).toBe(false); + expect(outcome).toMatchObject({ + reason: expect.stringContaining("does not allow"), + }); + }); + + it("stops on a lookup failure that is not a missing person", async () => { + replies(() => ({ ok: false, error: "invalid_auth" })); + + const outcome = await inviteToSlackChannel(CHANNEL_ID, "rep@ours.test"); + + expect(outcome.invited).toBe(false); + expect(outcome).toMatchObject({ + reason: expect.stringContaining("reconnected"), + }); + expect(sent("conversations.inviteShared")).toBeUndefined(); + }); + + it("refuses when Slack is not connected at all", async () => { + await db.account.deleteMany({ where: { id: ACCOUNT_ID } }); + + const outcome = await inviteToSlackChannel(CHANNEL_ID, "rep@ours.test"); + + expect(outcome).toMatchObject({ + invited: false, + reason: "Slack is not connected.", + }); + }); +}); diff --git a/apps/app/lib/agent-transcript.ts b/apps/app/lib/agent-transcript.ts index 6dd30e0a..d2862070 100644 --- a/apps/app/lib/agent-transcript.ts +++ b/apps/app/lib/agent-transcript.ts @@ -78,6 +78,7 @@ const VERBS: ToolVerbs = { record_job_change: "Raised a job change", list_deals: "Reviewed the deal pipeline", open_slack_channel: "Opened a Slack channel and watched it", + invite_to_slack_channel: "Invited people to the Slack channel", list_outstanding_work: "Looked for outstanding work", set_chat_title: "Named this chat", list_fields: "Read what this workspace tracks", diff --git a/packages/validation/src/slack.ts b/packages/validation/src/slack.ts index 7d276a21..e49e797b 100644 --- a/packages/validation/src/slack.ts +++ b/packages/validation/src/slack.ts @@ -28,6 +28,15 @@ export const reply = z.object({ error: z.string().optional(), }); +export const lookupByEmail = reply.extend({ + user: z.object({ id: z.string().trim().min(1) }).nullish(), +}); + +export const inviteShared = reply.extend({ + invite_id: z.string().trim().min(1).optional(), + url: z.string().trim().min(1).optional(), +}); + export const authTest = reply.extend({ user_id: z.string().trim().min(1).optional(), }); From c810748c8b1207c7c1a3c76f7a0e2bacfbdc7960 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:47:32 -0700 Subject: [PATCH 08/21] feat(dev): give the Slack tunnel a hostname that survives a restart Slack stores its request URL once. A quick cloudflared tunnel invents a new hostname every restart, so the stored URL goes stale and delivery stops with no error anywhere: the endpoint is simply never called again. tunnel:slack runs a named tunnel. It creates the tunnel if it is missing, points the DNS record at it, prints the request URL and runs it. Re-running is safe, and the hostname never changes, so Slack is configured once. The script refuses clearly rather than half-working: no hostname, no cloudflared, and not signed in each say what to do next. docs/setup.md also records that Socket Mode swallows event delivery while still showing the request URL as Verified. That cost an afternoon. --- .env.example | 9 +++++++ docs/setup.md | 24 ++++++++++++++++++ package.json | 3 ++- scripts/slack-tunnel.sh | 56 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100755 scripts/slack-tunnel.sh diff --git a/.env.example b/.env.example index 52fe55f5..7713b699 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,15 @@ GOOGLE_CLIENT_SECRET="" # API_URL + /webhooks/slack/events # SLACK_SIGNING_SECRET="" +# Optional, and for local development only. The hostname `bun run tunnel:slack` +# gives your machine, on a domain in your own Cloudflare account. A named tunnel +# keeps the same hostname across restarts, so Slack's request URL is set once +# and never again; the quick `cloudflared --url` tunnel invents a new hostname +# every time and Slack silently stops delivering. +# +# Nothing reads this at runtime. The script reads it, and Slack remembers it. +# SLACK_TUNNEL_HOSTNAME="crm-dev.example.com" + # Which Entra tenant may sign in. "common" (the default) accepts any work, # school or personal Microsoft account and leans on ALLOWED_SIGN_IN to decide # who actually gets in; your own tenant's GUID refuses everyone else at diff --git a/docs/setup.md b/docs/setup.md index 03b8912f..7dfd33a3 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -49,6 +49,30 @@ AGENT_BRIDGE_SECRET="$(openssl rand -base64 32)" the bridge — send `-H 'Host: agent.example.com'`. `GET /eve/v1/info` is the whole inventory, including a `diagnostics` count that finds files eve silently ignored. +## Inbound Slack needs one hostname, forever + +Slack posts events to a request URL it stores once. `cloudflared tunnel --url` +invents a new hostname on every restart, so the stored URL goes stale and Slack +stops delivering **without an error anywhere** — the endpoint is simply never +called. A named tunnel keeps the hostname across restarts. + +```sh +brew install cloudflared +cloudflared tunnel login # opens a browser, once +SLACK_TUNNEL_HOSTNAME="crm-dev.example.com" bun run tunnel:slack +``` + +The hostname must be on a domain in your own Cloudflare account. `tunnel:slack` +creates the tunnel if it is missing, points the DNS record at it, prints the +request URL and then runs it. Re-running is safe. Put the hostname in `.env` and +the variable can be dropped from the command. + +Paste the printed URL into the Slack app's Event Subscriptions page and +subscribe to `message.channels`, `app_mention` and `member_joined_channel`. + +**Socket Mode swallows events.** With Socket Mode on, the Request URL still shows +"Verified" and no HTTP delivery ever happens. Turn it off. + ## Running the agent The agent package's default `dev` command is interactive `eve dev`. The root diff --git a/package.json b/package.json index 534ec9d0..8e11fda6 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "db:reset": "turbo run db:reset", "db:seed": "turbo run db:seed", "db:studio": "turbo run db:studio", - "db:test": "bun run --filter=@crm/db db:test" + "db:test": "bun run --filter=@crm/db db:test", + "tunnel:slack": "bash scripts/slack-tunnel.sh" }, "devDependencies": { "@biomejs/biome": "^2.4.10", diff --git a/scripts/slack-tunnel.sh b/scripts/slack-tunnel.sh new file mode 100755 index 00000000..11f1a928 --- /dev/null +++ b/scripts/slack-tunnel.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +hostname="${SLACK_TUNNEL_HOSTNAME:-}" +name="${SLACK_TUNNEL_NAME:-crm-dev}" +port="${SLACK_TUNNEL_PORT:-3001}" + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [ -z "$hostname" ] && [ -f "$root/.env" ]; then + hostname="$(grep -E '^SLACK_TUNNEL_HOSTNAME=' "$root/.env" | tail -1 | cut -d= -f2- | tr -d '"' | tr -d "'" || true)" +fi + +if [ -z "$hostname" ]; then + cat >&2 <<'MESSAGE' +Set SLACK_TUNNEL_HOSTNAME to the name you want, on a domain in your Cloudflare +account. It is the hostname Slack keeps, so pick one and never change it: + + SLACK_TUNNEL_HOSTNAME="crm-dev.example.com" + +MESSAGE + exit 1 +fi + +if ! command -v cloudflared >/dev/null 2>&1; then + echo "cloudflared is not installed. brew install cloudflared" >&2 + exit 1 +fi + +if [ ! -f "$HOME/.cloudflared/cert.pem" ]; then + cat >&2 <<'MESSAGE' +cloudflared is not signed in. This opens a browser once, and once only: + + cloudflared tunnel login + +MESSAGE + exit 1 +fi + +if ! cloudflared tunnel info "$name" >/dev/null 2>&1; then + echo "Creating the $name tunnel." + cloudflared tunnel create "$name" +fi + +echo "Pointing $hostname at the $name tunnel." +cloudflared tunnel route dns --overwrite-dns "$name" "$hostname" + +cat < Date: Fri, 28 Aug 2026 10:14:47 -0700 Subject: [PATCH 09/21] feat(agent): teach the agent how to onboard a customer into Slack The tools to open a channel and invite people existed with nothing telling an agent the order to use them in, so the one ordering that matters was left to chance: the channel must be opened with open_slack_channel first, because a channel opened any other way is a channel the run does not watch, and every later reply is lost. The skill also says that waiting is the work. An agent that treats a parked run as an unfinished job polls, reschedules, or reports success that has not happened. A Slack Connect invitation takes a person a day to accept, and the run is supposed to sit there. retireExhausted now joins a once-evaluated subquery, the same shape claimDue already used, instead of IN (SELECT ... LIMIT ...). The planner is free to re-execute a sublink, so the row cap was a request rather than a guarantee. Two queries doing the same job now read the same way. --- apps/agent/agent/lib/tasks.ts | 5 +- .../agent/agent/skills/customer-onboarding.md | 61 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 apps/agent/agent/skills/customer-onboarding.md diff --git a/apps/agent/agent/lib/tasks.ts b/apps/agent/agent/lib/tasks.ts index 9d8a912c..94ce8bfc 100644 --- a/apps/agent/agent/lib/tasks.ts +++ b/apps/agent/agent/lib/tasks.ts @@ -79,7 +79,7 @@ export async function retireExhausted( UPDATE "agentTask" AS t SET "finishedAt" = ${now}, "outcome" = ${RETIRED_OUTCOME} - WHERE t.id IN ( + FROM ( SELECT c.id FROM "agentTask" AS c WHERE c."finishedAt" IS NULL @@ -88,7 +88,8 @@ export async function retireExhausted( ORDER BY c."dueAt" ASC LIMIT ${limit} FOR UPDATE SKIP LOCKED - ) + ) AS exhausted + WHERE t.id = exhausted.id RETURNING t.id, t."contactId", t."companyId", t."dealId", t.kind; `; } diff --git a/apps/agent/agent/skills/customer-onboarding.md b/apps/agent/agent/skills/customer-onboarding.md new file mode 100644 index 00000000..c44ba823 --- /dev/null +++ b/apps/agent/agent/skills/customer-onboarding.md @@ -0,0 +1,61 @@ +--- +description: Use when a deal closes won and the customer needs a shared Slack channel — the order the steps must run in, and what to do while you wait for a person. +--- + +# Onboarding a customer into Slack + +A deal closed won. The customer needs one place to talk to us. That place is a +Slack channel they can reach from their own workspace, which means Slack +Connect, which means somebody on their side has to accept an invitation before +anything else can happen. + +That wait is the whole shape of this job. You do not finish this work in one +turn, and you must not try. + +## The order is fixed + +1. `open_slack_channel` — name it after the customer, in plain words. This also + makes the run watch the channel, so anything said in it comes back to you. +2. `invite_to_slack_channel` — the buyer, and anybody on our side who owns the + account. +3. Stop and wait. + +Step 1 is first because it is what makes steps 2 and 3 possible. A channel you +did not open with this tool is a channel this run does not watch, so nobody's +reply will ever reach you. + +## Waiting is the work, not a failure + +When you have sent the invitations, say what you did and end your turn. Do not +poll, do not schedule a recheck, and do not report the onboarding as finished. + +The run parks. When the customer joins, or anybody writes in that channel, or +somebody mentions you there, the run wakes up with the message and you carry on +from where you stopped. You keep everything you already knew. + +A person can take a day to accept a Slack Connect invitation. That is normal and +it is not a problem to solve. + +## When you wake up + +You are told what happened: somebody joined, or somebody said something. Read it +and decide whether it needs you. + +- **Somebody joined** — greet them by name in the channel, say who we are and + what happens next. Then stop again. +- **Somebody wrote** — answer if you can, and stop. If it needs a person on our + side, say so in the channel and name them. +- **Nothing needs doing** — stop without posting. An agent that speaks every time + a channel moves is an agent people mute. + +## What goes wrong, and what it means + +- **`open_slack_channel` gives back a channel that already exists.** Somebody ran + this before, or the deal reopened and closed again. Use it. Do not invent a + second name to get a fresh channel. +- **`invited: false` with a reason.** Read the reason. "Slack is not connected" + and "this workspace does not allow it" both need a person, and neither is + fixed by trying again. +- **`watching: false`.** This run already watches a different channel, so replies + in the new one will never reach you. Say so plainly rather than waiting for a + message that cannot arrive. From acce304aed5d66667c7417d43dd06fee5e2202f0 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:29:11 -0700 Subject: [PATCH 10/21] feat(agent): let a deployed run open a Slack channel and invite the customer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first live test found the real gap: a deployed run executes inside the agent_runner subagent, which has its own sandboxed tool list, so the root-level tools were never reachable. The run identified the deal and the buyer, then wrote a summary because it had nothing to act with. Copying the tools down would have skipped the discipline every other external action follows. Opening a channel and inviting people are now manifest-approved actions with AgentAction rows, claimed and settled by idempotency key, so a retried run rejoins the channel it already made instead of making a second one and inviting the customer twice. Three guards move failure earlier, where somebody can fix it: - A manifest with a Slack action but no slack:workspace resource no longer parses. It used to deploy and then fail on every single run. - AGENT_ACTION_EXECUTORS and AGENT_ACTION_DEPENDENCIES are exhaustive over AgentActionType, so a new action cannot ship without a tool and a connection requirement. Both refused to compile until they were filled in, and the builder's draft schema had to learn the actions too. - DraftAction was a hand-written twin of its own Zod schema and had already drifted. It is now inferred from the schema. The event chain is verified end to end against the running agent: a closed deal queues the task, the EVENT trigger matches, and the run is created. The second run stops at the dependency preflight with "Connect Slack in Settings → Connections", which is correct — Slack has never been connected in this workspace, and the guard refuses before spending a model call. Test cleanup deletes AgentAction rows before runs. Without that the agent definition survived, the user delete failed on its restricted foreign key, and six orphaned users broke an unrelated auth spec. 7 tests. --- apps/agent/agent/lib/agent-actions.ts | 19 +- apps/agent/agent/lib/builder-runtime.ts | 25 +- apps/agent/agent/lib/run-runtime.ts | 220 +++++++++++++- .../agent_builder/lib/draft-input.ts | 12 + .../tools/invite_to_slack_channel.ts | 25 ++ .../agent_runner/tools/open_slack_channel.ts | 32 +++ .../agent/tools/invite_to_slack_channel.ts | 50 ---- apps/agent/agent/tools/open_slack_channel.ts | 56 ---- apps/agent/fire-deal-closed.ts | 33 +++ apps/agent/test/custom-agent-runtime.spec.ts | 2 +- .../slack-channel-actions.integration.spec.ts | 271 ++++++++++++++++++ .../test/slack-events.integration.spec.ts | 1 + packages/validation/src/agent-manifest.ts | 30 ++ 13 files changed, 631 insertions(+), 145 deletions(-) create mode 100644 apps/agent/agent/subagents/agent_runner/tools/invite_to_slack_channel.ts create mode 100644 apps/agent/agent/subagents/agent_runner/tools/open_slack_channel.ts delete mode 100644 apps/agent/agent/tools/invite_to_slack_channel.ts delete mode 100644 apps/agent/agent/tools/open_slack_channel.ts create mode 100644 apps/agent/fire-deal-closed.ts create mode 100644 apps/agent/test/slack-channel-actions.integration.spec.ts diff --git a/apps/agent/agent/lib/agent-actions.ts b/apps/agent/agent/lib/agent-actions.ts index 8f403b4a..cee01144 100644 --- a/apps/agent/agent/lib/agent-actions.ts +++ b/apps/agent/agent/lib/agent-actions.ts @@ -1,12 +1,15 @@ import { AGENT_ACTION_TYPES, type AgentActionType, + SLACK_WORKSPACE_RESOURCE_ID, } from "@crm/validation/agent-manifest"; export const AGENT_ACTION_EXECUTORS = { [AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE]: "create_crm_activity", [AGENT_ACTION_TYPES.RUN_SUMMARY]: "finish_run", [AGENT_ACTION_TYPES.SLACK_MESSAGE_POST]: "post_slack_message", + [AGENT_ACTION_TYPES.SLACK_CHANNEL_OPEN]: "open_slack_channel", + [AGENT_ACTION_TYPES.SLACK_CHANNEL_INVITE]: "invite_to_slack_channel", } as const satisfies Record; export function isAgentActionType(value: string): value is AgentActionType { @@ -22,15 +25,19 @@ export type AgentActionDependency = { readonly fix: string; }; +const SLACK_DEPENDENCY = { + id: "slack", + label: "Slack", + resourceId: SLACK_WORKSPACE_RESOURCE_ID, + fix: "Connect Slack in Settings → Connections.", +} as const satisfies AgentActionDependency; + export const AGENT_ACTION_DEPENDENCIES = { [AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE]: null, [AGENT_ACTION_TYPES.RUN_SUMMARY]: null, - [AGENT_ACTION_TYPES.SLACK_MESSAGE_POST]: { - id: "slack", - label: "Slack", - resourceId: "slack:workspace", - fix: "Connect Slack in Settings → Connections.", - }, + [AGENT_ACTION_TYPES.SLACK_MESSAGE_POST]: SLACK_DEPENDENCY, + [AGENT_ACTION_TYPES.SLACK_CHANNEL_OPEN]: SLACK_DEPENDENCY, + [AGENT_ACTION_TYPES.SLACK_CHANNEL_INVITE]: SLACK_DEPENDENCY, } as const satisfies Record; export function actionDependency( diff --git a/apps/agent/agent/lib/builder-runtime.ts b/apps/agent/agent/lib/builder-runtime.ts index 4a2a2f9a..f6449315 100644 --- a/apps/agent/agent/lib/builder-runtime.ts +++ b/apps/agent/agent/lib/builder-runtime.ts @@ -9,6 +9,7 @@ import { readAgentModel } from "@crm/db/settings"; import { WORKSPACE_ID } from "@crm/db/workspace"; import { AGENT_ACTION_TYPES } from "@crm/validation/agent-manifest"; import { z } from "zod"; +import type { DraftAction } from "../subagents/agent_builder/lib/draft-input"; import { actionDependency } from "./agent-actions"; import { requestStaleSlackInventorySync } from "./slack-people"; @@ -43,29 +44,7 @@ export type DraftTrigger = { intervalMinutes?: number | null; }; -export type DraftAction = - | { - type: typeof AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE; - provider: "crm"; - summary: string; - activityTypes: ("NOTE" | "TASK")[]; - } - | { - type: typeof AGENT_ACTION_TYPES.RUN_SUMMARY; - provider: "crm"; - summary: string; - } - | { - type: typeof AGENT_ACTION_TYPES.SLACK_MESSAGE_POST; - provider: "slack"; - summary: string; - destination: { - kind: "channel" | "user"; - resolution: "chosen"; - id: string; - label: string; - }; - }; +export type { DraftAction }; export type DraftAgentInput = { name: string; diff --git a/apps/agent/agent/lib/run-runtime.ts b/apps/agent/agent/lib/run-runtime.ts index bc9d9c2f..e19c2c02 100644 --- a/apps/agent/agent/lib/run-runtime.ts +++ b/apps/agent/agent/lib/run-runtime.ts @@ -6,6 +6,7 @@ import { AGENT_ACTION_TYPES, type AgentManifestResource, parseAgentManifest, + SLACK_WORKSPACE_RESOURCE_ID, } from "@crm/validation/agent-manifest"; import { z } from "zod"; import { readCompanyHistory, readDealHistory } from "./accounts"; @@ -13,12 +14,16 @@ import { AGENT_ACTION_EXECUTORS, isAgentActionType } from "./agent-actions"; import { readCrmHistory } from "./crm"; import { DISPATCH } from "./dispatch-config"; import { searchCrm } from "./lookup"; +import { channelOfRun, claimSlackChannel } from "./run-resume"; import { type LockedAgentRun, lockAgentRun, runTerminalEventId, } from "./run-state"; +import { toChannelName } from "./slack-channel-name"; import { slackAccessToken } from "./slack-connection"; +import { inviteToSlackChannel } from "./slack-invite"; +import { createSlackChannel } from "./slack-membership"; const ACTION_LEASE_MS = DISPATCH.run.actionLeaseMs; const NO_ACTION_TRIGGER_TYPES = new Set( @@ -1021,18 +1026,202 @@ function assertActivityAllowed( } } +function assertSlackActionApproved( + manifest: Prisma.JsonValue, + type: (typeof AGENT_ACTION_TYPES)[keyof typeof AGENT_ACTION_TYPES], +): void { + if (!manifestActions(manifest).some((action) => action.type === type)) { + throw new Error(`Agent version does not allow ${type}.`); + } +} + +export async function openRunSlackChannel( + runId: string, + callId: string, + input: { name: string; isPrivate: boolean }, +) { + const run = await activeRunForSlack( + runId, + AGENT_ACTION_TYPES.SLACK_CHANNEL_OPEN, + ); + + const channelName = toChannelName(input.name); + if (!channelName) { + throw new Error("That name has no letters or numbers Slack accepts."); + } + + const idempotencyKey = `${runId}:${callId}`; + const requestHash = hashRequest({ channelName, isPrivate: input.isPrivate }); + const existing = await findRunAction(idempotencyKey, requestHash); + if (existing?.status === "SUCCEEDED") { + return { + actionId: existing.id, + channelId: existing.externalId, + channelName, + watching: (await channelOfRun(runId)) === existing.externalId, + replayed: true, + }; + } + + const claim = await claimRunAction(existing, idempotencyKey, requestHash, { + agentId: run.agentId, + runId, + type: AGENT_ACTION_TYPES.SLACK_CHANNEL_OPEN, + provider: "slack", + targetType: "channel", + targetId: channelName, + targetLabel: `#${channelName}`, + summary: `Open #${channelName}`, + }); + if (!claim.claimed) { + return { + actionId: claim.actionId, + channelId: claim.externalId, + channelName, + watching: (await channelOfRun(runId)) === claim.externalId, + replayed: true, + }; + } + + try { + await assertRunActive(runId); + const outcome = await createSlackChannel(channelName, input.isPrivate); + if ("error" in outcome) throw new Error(outcome.error); + + const watching = await claimSlackChannel(runId, outcome.id); + await settleRunAction(claim, outcome.id); + + return { + actionId: claim.actionId, + channelId: outcome.id, + channelName: outcome.name, + watching: watching === outcome.id, + replayed: false, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await failRunAction(claim, slackActionErrorCode(message), message); + throw error; + } +} + +export async function inviteToRunSlackChannel( + runId: string, + callId: string, + input: { emails: string[] }, +) { + const run = await activeRunForSlack( + runId, + AGENT_ACTION_TYPES.SLACK_CHANNEL_INVITE, + ); + + const channelId = await channelOfRun(runId); + if (!channelId) { + throw new Error( + "This run has no Slack channel yet. Open one with open_slack_channel first.", + ); + } + + const emails = [...new Set(input.emails.map((email) => email.trim()))].sort(); + const idempotencyKey = `${runId}:${callId}`; + const requestHash = hashRequest({ channelId, emails: emails.join(",") }); + const existing = await findRunAction(idempotencyKey, requestHash); + if (existing?.status === "SUCCEEDED") { + return { actionId: existing.id, channelId, replayed: true }; + } + + const claim = await claimRunAction(existing, idempotencyKey, requestHash, { + agentId: run.agentId, + runId, + type: AGENT_ACTION_TYPES.SLACK_CHANNEL_INVITE, + provider: "slack", + targetType: "channel", + targetId: channelId, + targetLabel: channelId, + summary: `Invite ${emails.length} to ${channelId}`, + }); + if (!claim.claimed) { + return { actionId: claim.actionId, channelId, replayed: true }; + } + + try { + await assertRunActive(runId); + const outcomes = []; + for (const email of emails) { + outcomes.push(await inviteToSlackChannel(channelId, email)); + } + + const invited = outcomes.filter((outcome) => outcome.invited); + const refused = outcomes.filter((outcome) => !outcome.invited); + if (invited.length === 0) { + throw new Error( + refused.map((outcome) => outcome.reason).join(" ") || + "Slack refused every invitation.", + ); + } + + await settleRunAction(claim, channelId); + + return { + actionId: claim.actionId, + channelId, + invited, + refused, + replayed: false, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await failRunAction(claim, slackActionErrorCode(message), message); + throw error; + } +} + +async function activeRunForSlack( + runId: string, + type: (typeof AGENT_ACTION_TYPES)[keyof typeof AGENT_ACTION_TYPES], +) { + const run = await db.agentRun.findUnique({ + where: { id: runId }, + select: { + id: true, + status: true, + agentId: true, + version: { select: { manifest: true } }, + }, + }); + if (!run) throw new Error("This agent run is unavailable."); + if (run.status !== "RUNNING") { + throw new Error("This agent run is not active."); + } + + assertSlackWorkspaceApproved(run.version.manifest); + assertSlackActionApproved(run.version.manifest, type); + + return run; +} + +async function settleRunAction( + claim: Extract, + externalId: string, +): Promise { + const completed = await db.agentAction.updateMany({ + where: { + id: claim.actionId, + status: "RUNNING", + startedAt: claim.claimedAt, + }, + data: { status: "SUCCEEDED", externalId, completedAt: new Date() }, + }); + if (completed.count === 0) { + await recordDeliveryOutsideClaim(claim.actionId, externalId); + throw new Error("This agent run stopped while Slack was still working."); + } +} + export function approvedSlackDestination( manifest: Prisma.JsonValue, ): SlackRunDestination { - const scope = manifestDataScope(manifest); - if ( - !scope.resources.some( - (resource) => - resource.kind === "integration" && resource.id === "slack:workspace", - ) - ) { - throw new Error("Agent version does not allow Slack."); - } + assertSlackWorkspaceApproved(manifest); const destinations = manifestActions(manifest).flatMap((action) => action.type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST @@ -1055,6 +1244,19 @@ export function approvedSlackDestination( return destination; } +function assertSlackWorkspaceApproved(manifest: Prisma.JsonValue): void { + const scope = manifestDataScope(manifest); + if ( + !scope.resources.some( + (resource) => + resource.kind === "integration" && + resource.id === SLACK_WORKSPACE_RESOURCE_ID, + ) + ) { + throw new Error("Agent version does not allow Slack."); + } +} + function assertResourceAllowed( mode: RunRecordScope, resources: AgentManifestResource[], diff --git a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts index 67d1d926..89f6e4c1 100644 --- a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts +++ b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts @@ -55,8 +55,20 @@ const action = z.discriminatedUnion("type", [ label: z.string().trim().min(1).max(120), }), }), + z.object({ + type: z.literal(AGENT_ACTION_TYPES.SLACK_CHANNEL_OPEN), + provider: z.literal("slack"), + summary: z.string().trim().min(1).max(240), + }), + z.object({ + type: z.literal(AGENT_ACTION_TYPES.SLACK_CHANNEL_INVITE), + provider: z.literal("slack"), + summary: z.string().trim().min(1).max(240), + }), ]); +export type DraftAction = z.infer; + export const builderDraftToolInput = z.object({ name: z.string().trim().min(1).max(100), description: z.string().trim().min(1).max(320), diff --git a/apps/agent/agent/subagents/agent_runner/tools/invite_to_slack_channel.ts b/apps/agent/agent/subagents/agent_runner/tools/invite_to_slack_channel.ts new file mode 100644 index 00000000..94504ad6 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/invite_to_slack_channel.ts @@ -0,0 +1,25 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { inviteToRunSlackChannel } from "../../../lib/run-runtime"; +import { requireTeamAgentAttribute } from "../../../lib/session-purpose"; + +export default defineTool({ + description: + "Invite people to the Slack channel this run opened. An address inside this workspace is added straight away. An address outside it gets a Slack Connect invitation, which that person has to accept before they can read anything.", + inputSchema: z.object({ + emails: z + .array(z.email()) + .min(1) + .max(10) + .describe( + "Who to invite, by email address. Customers and colleagues both go here.", + ), + }), + async execute(input, ctx) { + return inviteToRunSlackChannel( + requireTeamAgentAttribute(ctx, "runId"), + ctx.callId, + input, + ); + }, +}); diff --git a/apps/agent/agent/subagents/agent_runner/tools/open_slack_channel.ts b/apps/agent/agent/subagents/agent_runner/tools/open_slack_channel.ts new file mode 100644 index 00000000..ffc76f65 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/open_slack_channel.ts @@ -0,0 +1,32 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { openRunSlackChannel } from "../../../lib/run-runtime"; +import { requireTeamAgentAttribute } from "../../../lib/session-purpose"; + +export default defineTool({ + description: + "Open the Slack channel this run works in, and start watching it. Every message and every join in that channel wakes this run up, so open the channel before you invite anybody. A name already in use gives you back the existing channel.", + inputSchema: z.object({ + name: z + .string() + .trim() + .min(1) + .max(120) + .describe( + "What to call it, in plain words: 'Acme onboarding'. Spaces and capitals are fine; Slack gets a tidied version.", + ), + isPrivate: z + .boolean() + .default(false) + .describe( + "True for work the whole workspace must not read. A customer channel is public.", + ), + }), + async execute(input, ctx) { + return openRunSlackChannel( + requireTeamAgentAttribute(ctx, "runId"), + ctx.callId, + input, + ); + }, +}); diff --git a/apps/agent/agent/tools/invite_to_slack_channel.ts b/apps/agent/agent/tools/invite_to_slack_channel.ts deleted file mode 100644 index 6a89e340..00000000 --- a/apps/agent/agent/tools/invite_to_slack_channel.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { defineTool } from "eve/tools"; -import { z } from "zod"; -import { channelOfRun } from "../lib/run-resume"; -import { requireTeamAgentAttribute } from "../lib/session-purpose"; -import { inviteToSlackChannel } from "../lib/slack-invite"; - -export default defineTool({ - description: - "Invite people to the Slack channel this run opened. An address inside this workspace is added straight away. An address outside it gets a Slack Connect invitation, which that person has to accept before they can read anything.", - inputSchema: z.object({ - emails: z - .array(z.email()) - .min(1) - .max(10) - .describe( - "Who to invite, by email address. Customers and colleagues both go here.", - ), - channelId: z - .string() - .trim() - .optional() - .describe( - "Leave this out. It defaults to the channel this run opened and watches.", - ), - }), - async execute({ emails, channelId }, ctx) { - const runId = requireTeamAgentAttribute(ctx, "runId"); - const channel = channelId?.trim() || (await channelOfRun(runId)); - - if (!channel) { - return { - reason: - "This run has no Slack channel yet. Open one with open_slack_channel first.", - invited: [], - refused: [], - }; - } - - const outcomes = []; - for (const email of emails) { - outcomes.push(await inviteToSlackChannel(channel, email)); - } - - return { - channelId: channel, - invited: outcomes.filter((outcome) => outcome.invited), - refused: outcomes.filter((outcome) => !outcome.invited), - }; - }, -}); diff --git a/apps/agent/agent/tools/open_slack_channel.ts b/apps/agent/agent/tools/open_slack_channel.ts deleted file mode 100644 index f8737d72..00000000 --- a/apps/agent/agent/tools/open_slack_channel.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { defineTool } from "eve/tools"; -import { z } from "zod"; -import { claimSlackChannel } from "../lib/run-resume"; -import { requireTeamAgentAttribute } from "../lib/session-purpose"; -import { toChannelName } from "../lib/slack-channel-name"; -import { createSlackChannel } from "../lib/slack-membership"; - -export default defineTool({ - description: - "Open the Slack channel this run works in, and start watching it. Every message and every join in that channel wakes this run up, so open the channel before you invite anybody. A name already in use gives you back the existing channel.", - inputSchema: z.object({ - name: z - .string() - .trim() - .min(1) - .max(120) - .describe( - "What to call it, in plain words: 'Acme onboarding'. Spaces and capitals are fine; Slack gets a tidied version.", - ), - isPrivate: z - .boolean() - .default(false) - .describe( - "True for work the whole workspace must not read. A customer channel is public.", - ), - }), - async execute({ name, isPrivate }, ctx) { - const runId = requireTeamAgentAttribute(ctx, "runId"); - const channelName = toChannelName(name); - - if (!channelName) { - return { - opened: false as const, - reason: "That name has no letters or numbers Slack accepts.", - }; - } - - const outcome = await createSlackChannel(channelName, isPrivate); - if ("error" in outcome) { - return { opened: false as const, reason: outcome.error }; - } - - const watching = await claimSlackChannel(runId, outcome.id); - - return { - opened: true as const, - channelId: outcome.id, - channelName: outcome.name, - watching: watching === outcome.id, - reason: - watching === outcome.id - ? undefined - : `This run already watches ${watching}, so messages in #${outcome.name} do not reach it.`, - }; - }, -}); diff --git a/apps/agent/fire-deal-closed.ts b/apps/agent/fire-deal-closed.ts new file mode 100644 index 00000000..2d09b74d --- /dev/null +++ b/apps/agent/fire-deal-closed.ts @@ -0,0 +1,33 @@ +import { db } from "@crm/db"; +import { PRIORITY } from "@crm/db/agent-tasks"; + +const dealId = process.argv[2] ?? "seed-deal-cal-com-0"; + +const deal = await db.deal.update({ + where: { id: dealId }, + data: { stage: "CLOSED_WON", closedAt: new Date() }, + select: { id: true, name: true, stage: true, companyId: true }, +}); + +const task = await db.agentTask.create({ + data: { + dealId: deal.id, + contactId: null, + companyId: null, + kind: "agent-event", + reason: "deal.closed", + payload: { + type: "deal.closed", + record: { kind: "deal", id: deal.id }, + occurredAt: new Date().toISOString(), + data: { stage: "CLOSED_WON" }, + }, + priority: PRIORITY.event, + budget: 1, + dueAt: new Date(), + }, + select: { id: true }, +}); + +console.log(JSON.stringify({ deal, taskId: task.id }, null, 2)); +process.exit(0); diff --git a/apps/agent/test/custom-agent-runtime.spec.ts b/apps/agent/test/custom-agent-runtime.spec.ts index 330d2a58..1eebb0b3 100644 --- a/apps/agent/test/custom-agent-runtime.spec.ts +++ b/apps/agent/test/custom-agent-runtime.spec.ts @@ -245,7 +245,7 @@ describe("deployed Slack actions", () => { ...manifest, dataScope: { ...manifest.dataScope, resources: [] }, }), - ).toThrow("does not allow Slack"); + ).toThrow("needs the slack:workspace resource"); }); it("posts with a stable Slack replay id", async () => { diff --git a/apps/agent/test/slack-channel-actions.integration.spec.ts b/apps/agent/test/slack-channel-actions.integration.spec.ts new file mode 100644 index 00000000..7c295af3 --- /dev/null +++ b/apps/agent/test/slack-channel-actions.integration.spec.ts @@ -0,0 +1,271 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import { db } from "@crm/db"; +import { channelOfRun } from "../agent/lib/run-resume"; +import { + inviteToRunSlackChannel, + openRunSlackChannel, +} from "../agent/lib/run-runtime"; + +const suffix = crypto.randomUUID(); +const userId = `slack-actions-user-${suffix}`; +const accountId = `slack-actions-account-${suffix}`; + +const realFetch = globalThis.fetch; +let created = 0; + +function slackReplies(reply: (url: string) => object) { + globalThis.fetch = (async (input: URL | RequestInfo) => { + const url = String(input instanceof Request ? input.url : input); + return new Response(JSON.stringify(reply(url)), { + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +} + +const slackManifest = (actions: unknown[]) => ({ + triggers: [ + { type: "MANUAL", name: "Run now", summary: "Run on demand", config: {} }, + ], + dataScope: { + mode: "WORKSPACE", + summary: "Workspace CRM records", + resources: [{ kind: "integration", id: "slack:workspace", label: "Slack" }], + }, + actions, +}); + +const openAction = { + type: "slack.channel.open", + provider: "slack", + summary: "Open the customer channel", +}; +const inviteAction = { + type: "slack.channel.invite", + provider: "slack", + summary: "Invite the buyer", +}; +const summaryAction = { + type: "run.summary", + provider: "crm", + summary: "Say what happened", +}; + +let agentId = ""; + +async function makeRun(actions: unknown[]) { + const unique = crypto.randomUUID(); + const version = await db.agentVersion.create({ + data: { + agentId, + number: (await db.agentVersion.count({ where: { agentId } })) + 1, + status: "DEPLOYED", + createdById: userId, + instructions: "x", + manifest: slackManifest(actions), + modelId: "m", + sandboxPolicy: {}, + }, + select: { id: true }, + }); + const run = await db.agentRun.create({ + data: { + agentId, + versionId: version.id, + status: "RUNNING", + triggerType: "MANUAL", + idempotencyKey: `sca-${unique}`, + correlationId: `sca-${unique}`, + sessionId: `ses_${unique}`, + }, + select: { id: true }, + }); + return run.id; +} + +beforeAll(async () => { + await db.user.create({ + data: { + id: userId, + name: "Slack Actions", + email: `${userId}@example.test`, + }, + }); + const agent = await db.agentDefinition.create({ + data: { + name: `Slack actions ${suffix}`, + status: "LIVE", + createdById: userId, + }, + select: { id: true }, + }); + agentId = agent.id; + await db.account.create({ + data: { + id: accountId, + accountId: `T-${suffix}`, + providerId: "slack", + userId, + accessToken: "xoxb-slack-actions", + }, + }); +}); + +afterAll(async () => { + globalThis.fetch = realFetch; + await db.agentAction.deleteMany({ where: { agentId } }); + await db.agentRun.deleteMany({ where: { agentId } }); + await db.agentDefinition.updateMany({ + where: { id: agentId }, + data: { currentVersionId: null }, + }); + await db.agentVersion.deleteMany({ where: { agentId } }); + await db.agentDefinition.deleteMany({ where: { id: agentId } }); + await db.account.deleteMany({ where: { id: accountId } }); + await db.user.deleteMany({ where: { id: userId } }); +}); + +beforeEach(() => { + created = 0; + globalThis.fetch = realFetch; +}); + +describe("opening a channel as a deployed run", () => { + it("refuses when the version does not approve opening a channel", async () => { + const runId = await makeRun([summaryAction]); + + await expect( + openRunSlackChannel(runId, "call-1", { + name: "Acme onboarding", + isPrivate: false, + }), + ).rejects.toThrow("does not allow slack.channel.open"); + }); + + it("opens the channel and makes the run watch it", async () => { + const runId = await makeRun([openAction, summaryAction]); + const channelId = `C-${crypto.randomUUID()}`; + slackReplies(() => { + created += 1; + return { ok: true, channel: { id: channelId, name: "acme-onboarding" } }; + }); + + const outcome = await openRunSlackChannel(runId, "call-1", { + name: "Acme Onboarding", + isPrivate: false, + }); + + expect(outcome).toMatchObject({ + channelId, + channelName: "acme-onboarding", + watching: true, + replayed: false, + }); + expect(await channelOfRun(runId)).toBe(channelId); + await db.slackChannel.deleteMany({ where: { id: channelId } }); + }); + + it("replays the same call instead of opening a second channel", async () => { + const runId = await makeRun([openAction, summaryAction]); + const channelId = `C-${crypto.randomUUID()}`; + slackReplies(() => { + created += 1; + return { ok: true, channel: { id: channelId, name: "acme-onboarding" } }; + }); + + await openRunSlackChannel(runId, "call-1", { + name: "Acme Onboarding", + isPrivate: false, + }); + const again = await openRunSlackChannel(runId, "call-1", { + name: "Acme Onboarding", + isPrivate: false, + }); + + expect(again.replayed).toBe(true); + expect(again.channelId).toBe(channelId); + expect(created).toBe(1); + await db.slackChannel.deleteMany({ where: { id: channelId } }); + }); + + it("refuses a name with nothing Slack accepts", async () => { + const runId = await makeRun([openAction, summaryAction]); + + await expect( + openRunSlackChannel(runId, "call-1", { + name: "!!! ---", + isPrivate: false, + }), + ).rejects.toThrow("no letters or numbers"); + }); +}); + +describe("inviting people as a deployed run", () => { + it("refuses before a channel exists, rather than inviting nobody", async () => { + const runId = await makeRun([inviteAction, summaryAction]); + + await expect( + inviteToRunSlackChannel(runId, "call-1", { emails: ["buyer@x.test"] }), + ).rejects.toThrow("no Slack channel yet"); + }); + + it("invites into the channel the run opened", async () => { + const runId = await makeRun([openAction, inviteAction, summaryAction]); + const channelId = `C-${crypto.randomUUID()}`; + slackReplies((url) => + url.includes("conversations.create") + ? { ok: true, channel: { id: channelId, name: "acme-onboarding" } } + : url.includes("users.lookupByEmail") + ? { ok: false, error: "users_not_found" } + : { ok: true, invite_id: "I1", url: "https://slack.com/invite/x" }, + ); + + await openRunSlackChannel(runId, "open-1", { + name: "Acme Onboarding", + isPrivate: false, + }); + const outcome = await inviteToRunSlackChannel(runId, "invite-1", { + emails: ["buyer@customer.test"], + }); + + expect(outcome).toMatchObject({ channelId, replayed: false }); + expect(outcome.invited).toHaveLength(1); + await db.slackChannel.deleteMany({ where: { id: channelId } }); + }); + + it("fails the action when Slack refuses every address", async () => { + const runId = await makeRun([openAction, inviteAction, summaryAction]); + const channelId = `C-${crypto.randomUUID()}`; + slackReplies((url) => + url.includes("conversations.create") + ? { ok: true, channel: { id: channelId, name: "acme-onboarding" } } + : url.includes("users.lookupByEmail") + ? { ok: false, error: "users_not_found" } + : { ok: false, error: "restricted_action" }, + ); + + await openRunSlackChannel(runId, "open-1", { + name: "Acme Onboarding", + isPrivate: false, + }); + + await expect( + inviteToRunSlackChannel(runId, "invite-1", { + emails: ["buyer@customer.test"], + }), + ).rejects.toThrow("does not allow"); + + const action = await db.agentAction.findFirst({ + where: { runId, type: "slack.channel.invite" }, + select: { status: true }, + }); + expect(action?.status).toBe("FAILED"); + await db.slackChannel.deleteMany({ where: { id: channelId } }); + }); +}); diff --git a/apps/agent/test/slack-events.integration.spec.ts b/apps/agent/test/slack-events.integration.spec.ts index ebb431f8..6d331f7e 100644 --- a/apps/agent/test/slack-events.integration.spec.ts +++ b/apps/agent/test/slack-events.integration.spec.ts @@ -108,6 +108,7 @@ beforeAll(async () => { afterAll(async () => { await db.slackEventInbox.deleteMany({ where: { teamId: "T1" } }); + await db.agentAction.deleteMany({ where: { agentId } }); await db.agentRun.deleteMany({ where: { agentId } }); await db.agentDefinition.updateMany({ where: { id: agentId }, diff --git a/packages/validation/src/agent-manifest.ts b/packages/validation/src/agent-manifest.ts index 9a496e8d..9536ab8a 100644 --- a/packages/validation/src/agent-manifest.ts +++ b/packages/validation/src/agent-manifest.ts @@ -5,8 +5,12 @@ export const AGENT_ACTION_TYPES = { CRM_ACTIVITY_CREATE: "crm.activity.create", RUN_SUMMARY: "run.summary", SLACK_MESSAGE_POST: "slack.message.post", + SLACK_CHANNEL_OPEN: "slack.channel.open", + SLACK_CHANNEL_INVITE: "slack.channel.invite", } as const; +export const SLACK_WORKSPACE_RESOURCE_ID = "slack:workspace"; + export type AgentActionType = (typeof AGENT_ACTION_TYPES)[keyof typeof AGENT_ACTION_TYPES]; @@ -44,6 +48,16 @@ export const agentManifestAction = z.discriminatedUnion("type", [ summary: z.string(), destination: slackDestination, }), + z.object({ + type: z.literal(AGENT_ACTION_TYPES.SLACK_CHANNEL_OPEN), + provider: z.literal("slack"), + summary: z.string(), + }), + z.object({ + type: z.literal(AGENT_ACTION_TYPES.SLACK_CHANNEL_INVITE), + provider: z.literal("slack"), + summary: z.string(), + }), ]); export const agentScheduleTriggerConfig = z.object({ @@ -105,6 +119,22 @@ export const agentManifest = z } actionTypes.add(action.type); } + + const slack = manifest.actions.findIndex( + (action) => action.provider === "slack", + ); + const workspace = manifest.dataScope.resources.some( + (resource) => + resource.kind === "integration" && + resource.id === SLACK_WORKSPACE_RESOURCE_ID, + ); + if (slack >= 0 && !workspace) { + context.addIssue({ + code: "custom", + path: ["actions", slack, "provider"], + message: `A Slack action needs the ${SLACK_WORKSPACE_RESOURCE_ID} resource, or it fails on every run`, + }); + } }); export type SlackDestination = z.infer; From b46767635d19591e72481e4fbb7a56ea0e59870f Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:02:34 -0700 Subject: [PATCH 11/21] feat(agent): add the deal owner to the channel a run opens A customer channel with only the customer in it is not a channel anybody uses. The person who closed the deal has to be there from the start, and asking the model to remember that would make it optional. The run's input already names the record the event fired for, so the deal is known without asking the agent for it. SlackMemberMatch turns the CRM owner into a Slack user id, and the owner is invited with conversations.invite, which works on a free workspace. Slack Connect does not, so the customer half still needs a paid plan. An owner Slack cannot match is reported, not thrown. The channel is already open by then, and losing it to a missing account would leave a real Slack channel with no run watching it. Run input is parsed with a schema rather than read out of the Json column by hand. --- apps/agent/agent/lib/run-runtime.ts | 3 + apps/agent/agent/lib/slack-owner.ts | 62 ++++++++++++++++++ .../slack-channel-actions.integration.spec.ts | 64 +++++++++++++++++++ packages/validation/src/agent-events.ts | 16 +++++ 4 files changed, 145 insertions(+) create mode 100644 apps/agent/agent/lib/slack-owner.ts diff --git a/apps/agent/agent/lib/run-runtime.ts b/apps/agent/agent/lib/run-runtime.ts index e19c2c02..04736d0b 100644 --- a/apps/agent/agent/lib/run-runtime.ts +++ b/apps/agent/agent/lib/run-runtime.ts @@ -24,6 +24,7 @@ import { toChannelName } from "./slack-channel-name"; import { slackAccessToken } from "./slack-connection"; import { inviteToSlackChannel } from "./slack-invite"; import { createSlackChannel } from "./slack-membership"; +import { addDealOwner } from "./slack-owner"; const ACTION_LEASE_MS = DISPATCH.run.actionLeaseMs; const NO_ACTION_TRIGGER_TYPES = new Set( @@ -1089,6 +1090,7 @@ export async function openRunSlackChannel( if ("error" in outcome) throw new Error(outcome.error); const watching = await claimSlackChannel(runId, outcome.id); + const owner = await addDealOwner(runId, outcome.id).catch(() => null); await settleRunAction(claim, outcome.id); return { @@ -1096,6 +1098,7 @@ export async function openRunSlackChannel( channelId: outcome.id, channelName: outcome.name, watching: watching === outcome.id, + owner, replayed: false, }; } catch (error) { diff --git a/apps/agent/agent/lib/slack-owner.ts b/apps/agent/agent/lib/slack-owner.ts new file mode 100644 index 00000000..2bed3e9e --- /dev/null +++ b/apps/agent/agent/lib/slack-owner.ts @@ -0,0 +1,62 @@ +import { db } from "@crm/db"; +import { schemas } from "@crm/validation"; +import { runRecord } from "@crm/validation/agent-events"; +import { slackPost } from "./slack-api"; +import { slackAccessToken } from "./slack-connection"; + +export type OwnerInvite = + | { added: true; slackUserId: string; name: string } + | { added: false; reason: string }; + +export async function addDealOwner( + runId: string, + channelId: string, +): Promise { + const run = await db.agentRun.findUnique({ + where: { id: runId }, + select: { input: true }, + }); + + const record = runRecord(run?.input); + if (record?.kind !== "deal") return null; + + const deal = await db.deal.findUnique({ + where: { id: record.id }, + select: { owner: { select: { id: true, name: true } } }, + }); + if (!deal) return null; + + const match = await db.slackMemberMatch.findUnique({ + where: { crmUserId: deal.owner.id }, + select: { slackUserId: true }, + }); + if (!match?.slackUserId) { + return { + added: false, + reason: `${deal.owner.name} has no matching Slack account.`, + }; + } + + const token = await slackAccessToken(); + if (!token) return { added: false, reason: "Slack is not connected." }; + + const outcome = await slackPost( + token, + "conversations.invite", + { channel: channelId, users: match.slackUserId }, + schemas.slack.reply, + ); + + if (outcome.ok || outcome.error === "already_in_channel") { + return { + added: true, + slackUserId: match.slackUserId, + name: deal.owner.name, + }; + } + + return { + added: false, + reason: `Slack refused to add ${deal.owner.name} (${outcome.error}).`, + }; +} diff --git a/apps/agent/test/slack-channel-actions.integration.spec.ts b/apps/agent/test/slack-channel-actions.integration.spec.ts index 7c295af3..92781e56 100644 --- a/apps/agent/test/slack-channel-actions.integration.spec.ts +++ b/apps/agent/test/slack-channel-actions.integration.spec.ts @@ -194,6 +194,70 @@ describe("opening a channel as a deployed run", () => { await db.slackChannel.deleteMany({ where: { id: channelId } }); }); + it("adds the deal owner when Slack knows them", async () => { + const dealId = `deal-${crypto.randomUUID()}`; + const company = await db.company.create({ + data: { name: `Owner Co ${suffix}`, domain: `${dealId}.test` }, + select: { id: true }, + }); + await db.deal.create({ + data: { + id: dealId, + name: "Owner deal", + stage: "CLOSED_WON", + companyId: company.id, + ownerId: userId, + amount: 1, + currency: "USD", + }, + }); + await db.slackMemberMatch.create({ + data: { crmUserId: userId, slackUserId: "U-OWNER" }, + }); + + const runId = await makeRun([openAction, summaryAction]); + await db.agentRun.update({ + where: { id: runId }, + data: { input: { record: { kind: "deal", id: dealId } } }, + }); + + const channelId = `C-${crypto.randomUUID()}`; + const invited: unknown[] = []; + globalThis.fetch = (async ( + input: URL | RequestInfo, + init?: RequestInit, + ) => { + const url = String(input instanceof Request ? input.url : input); + if (url.includes("conversations.invite")) { + invited.push(JSON.parse(String(init?.body))); + } + return new Response( + JSON.stringify( + url.includes("conversations.create") + ? { ok: true, channel: { id: channelId, name: "owner-co" } } + : { ok: true }, + ), + { headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; + + const outcome = await openRunSlackChannel(runId, "call-1", { + name: "Owner Co", + isPrivate: false, + }); + + expect(outcome.owner).toMatchObject({ + added: true, + slackUserId: "U-OWNER", + }); + expect(invited).toEqual([{ channel: channelId, users: "U-OWNER" }]); + + await db.slackChannel.deleteMany({ where: { id: channelId } }); + await db.slackMemberMatch.deleteMany({ where: { crmUserId: userId } }); + await db.deal.deleteMany({ where: { id: dealId } }); + await db.company.deleteMany({ where: { id: company.id } }); + }); + it("refuses a name with nothing Slack accepts", async () => { const runId = await makeRun([openAction, summaryAction]); diff --git a/packages/validation/src/agent-events.ts b/packages/validation/src/agent-events.ts index d4ce5bb2..401b726d 100644 --- a/packages/validation/src/agent-events.ts +++ b/packages/validation/src/agent-events.ts @@ -20,3 +20,19 @@ export const crmEventTask = z ); export type CrmEventTask = z.infer; + +export const agentRunInput = z.object({ + event: z.object({ type: z.enum(CRM_EVENT_TYPES) }).optional(), + record: z + .object({ + kind: z.enum(["company", "contact", "deal"]), + id: z.string().trim().min(1), + }) + .optional(), +}); + +export type AgentRunInput = z.infer; + +export function runRecord(value: unknown): AgentRunInput["record"] { + return agentRunInput.safeParse(value).data?.record; +} From 0ddfbc5417a4589059a152b0d602a8155137d0c6 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:40:00 -0700 Subject: [PATCH 12/21] fix(auth): request app_mentions:read, without which mentions never arrive Slack refuses to save an app manifest that subscribes to app_mention without this scope: "app_mention event is missing scope(s)". The event was already in the actionable set, so a mention in a channel was meant to wake a parked run and silently could not. Found by creating the staging app from our own scope list. The manifest would not validate. --- packages/auth/src/slack-scopes.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/auth/src/slack-scopes.ts b/packages/auth/src/slack-scopes.ts index 898036c9..939b7685 100644 --- a/packages/auth/src/slack-scopes.ts +++ b/packages/auth/src/slack-scopes.ts @@ -32,6 +32,12 @@ export const SLACK_SCOPES: readonly SlackScope[] = [ grant: "See private channels it has been added to", sensitive: false, }, + { + scope: "app_mentions:read", + group: "read", + grant: "See messages that mention it, so somebody can ask it for help", + sensitive: false, + }, { scope: "channels:history", group: "read", From 8387329fb00699b67c418a890808053eab95aed3 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:44:22 -0700 Subject: [PATCH 13/21] fix(agent): keep Slack's invite so a run can prove it was sent --- apps/agent/agent/lib/run-runtime.ts | 115 ++++++++++++++++-- apps/agent/agent/lib/slack-invite.ts | 16 ++- .../slack-channel-actions.integration.spec.ts | 44 ++++++- .../test/slack-invite.integration.spec.ts | 1 + apps/api/src/agent/agent-runs.service.ts | 3 + apps/api/src/agent/agents.contracts.ts | 1 + .../agent-builder/agent-history.tsx | 14 ++- .../migration.sql | 2 + packages/db/prisma/schema.prisma | 1 + packages/validation/package.json | 1 + packages/validation/src/agent-action.ts | 69 +++++++++++ packages/validation/src/index.ts | 3 + packages/validation/test/agent-action.spec.ts | 90 ++++++++++++++ 13 files changed, 343 insertions(+), 17 deletions(-) create mode 100644 packages/db/prisma/migrations/20260830083000_agent_action_result/migration.sql create mode 100644 packages/validation/src/agent-action.ts create mode 100644 packages/validation/test/agent-action.spec.ts diff --git a/apps/agent/agent/lib/run-runtime.ts b/apps/agent/agent/lib/run-runtime.ts index 04736d0b..3ce3809c 100644 --- a/apps/agent/agent/lib/run-runtime.ts +++ b/apps/agent/agent/lib/run-runtime.ts @@ -2,6 +2,11 @@ import { createHash, randomUUID } from "node:crypto"; import { ActivityType, db, type Prisma } from "@crm/db"; import type { AgentActionStatus, AgentTriggerType } from "@crm/db/enums"; import { lockIdempotencyKey } from "@crm/db/idempotency"; +import { + type AgentActionResult, + parseAgentActionResult, + readAgentActionResult, +} from "@crm/validation/agent-action"; import { AGENT_ACTION_TYPES, type AgentManifestResource, @@ -22,7 +27,7 @@ import { } from "./run-state"; import { toChannelName } from "./slack-channel-name"; import { slackAccessToken } from "./slack-connection"; -import { inviteToSlackChannel } from "./slack-invite"; +import { type InviteOutcome, inviteToSlackChannel } from "./slack-invite"; import { createSlackChannel } from "./slack-membership"; import { addDealOwner } from "./slack-owner"; @@ -92,14 +97,21 @@ const noActionResult = z type RunActionRow = { id: string; + type: string; status: AgentActionStatus; externalId: string | null; requestHash: string | null; metadata: Prisma.JsonValue; + result: Prisma.JsonValue | null; }; type RunActionClaim = - | { claimed: false; actionId: string; externalId: string | null } + | { + claimed: false; + actionId: string; + externalId: string | null; + result: AgentActionResult | null; + } | { claimed: true; actionId: string; @@ -109,12 +121,39 @@ type RunActionClaim = const RUN_ACTION_FIELDS = { id: true, + type: true, status: true, externalId: true, requestHash: true, metadata: true, + result: true, } as const; +function storedActionResult(result: AgentActionResult): Prisma.InputJsonValue { + return json.parse(JSON.parse(JSON.stringify(result))); +} + +function slackInviteResult(channelId: string, outcomes: InviteOutcome[]) { + const invited: Extract[] = []; + for (const outcome of outcomes) { + if (outcome.invited) invited.push(outcome); + } + const chosen = invited.find((outcome) => outcome.invite_id) ?? invited[0]; + if (!chosen) { + throw new Error("Slack returned no invitation to store."); + } + return { + externalId: chosen.invite_id ?? channelId, + result: parseAgentActionResult({ + type: AGENT_ACTION_TYPES.SLACK_CHANNEL_INVITE, + email: chosen.email, + kind: chosen.kind, + invite_id: chosen.invite_id, + url: chosen.url, + }), + }; +} + export async function approvedRunInstructions(runId: string): Promise { const run = await db.agentRun.findUnique({ where: { id: runId }, @@ -272,6 +311,7 @@ export async function createRunActivity( return { actionId: existing.id, activityId: existing.externalId, + result: readAgentActionResult(existing.type, existing.result), replayed: true, }; } @@ -308,6 +348,7 @@ export async function createRunActivity( return { actionId: claim.actionId, activityId: claim.externalId, + result: claim.result, replayed: true, }; } @@ -315,6 +356,10 @@ export async function createRunActivity( try { const activityId = `agent-action-${claim.actionId}`; const now = new Date(); + const result = parseAgentActionResult({ + type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + activityId, + }); await db.$transaction(async (tx) => { const activeRun = await lockAgentRun(tx, runId); @@ -368,12 +413,18 @@ export async function createRunActivity( data: { status: "SUCCEEDED", externalId: activityId, + result: storedActionResult(result), completedAt: now, }, }); }); - return { actionId: claim.actionId, activityId, replayed: false }; + return { + actionId: claim.actionId, + activityId, + result, + replayed: false, + }; } catch (error) { const message = error instanceof Error ? error.message : String(error); await failRunAction(claim, "ACTION_REJECTED", message); @@ -409,6 +460,7 @@ export async function postRunSlackMessage( actionId: existing.id, messageId: existing.externalId, destination: destination.label, + result: readAgentActionResult(existing.type, existing.result), replayed: true, }; } @@ -432,6 +484,7 @@ export async function postRunSlackMessage( actionId: claim.actionId, messageId: claim.externalId, destination: destination.label, + result: claim.result, replayed: true, }; } @@ -456,17 +509,23 @@ export async function postRunSlackMessage( beforePost: () => holdRunActionClaim(runId, actionId, claimedAt), }, ); + const result = parseAgentActionResult({ + type: AGENT_ACTION_TYPES.SLACK_MESSAGE_POST, + channel: posted.channel, + ts: posted.ts, + }); const messageId = `${posted.channel}:${posted.ts}`; const completed = await db.agentAction.updateMany({ where: { id: actionId, status: "RUNNING", startedAt: claimedAt }, data: { status: "SUCCEEDED", externalId: messageId, + result: storedActionResult(result), completedAt: new Date(), }, }); if (completed.count === 0) { - await recordDeliveryOutsideClaim(actionId, messageId); + await recordDeliveryOutsideClaim(actionId, messageId, result); throw new Error( "This agent run stopped while Slack was accepting the message.", ); @@ -476,6 +535,7 @@ export async function postRunSlackMessage( actionId, messageId, destination: destination.label, + result, replayed: false, }; } catch (error) { @@ -529,6 +589,7 @@ async function claimRunAction( claimed: false, actionId: action.id, externalId: action.externalId, + result: readAgentActionResult(action.type, action.result), }; } @@ -556,13 +617,14 @@ async function claimRunAction( if (claimed.count === 0) { const current = await db.agentAction.findUnique({ where: { id: action.id }, - select: { status: true, externalId: true }, + select: { status: true, externalId: true, result: true }, }); if (current?.status === "SUCCEEDED") { return { claimed: false, actionId: action.id, externalId: current.externalId, + result: readAgentActionResult(action.type, current.result), }; } throw new Error("This agent action is already in progress."); @@ -677,7 +739,8 @@ async function holdRunActionClaim( async function recordDeliveryOutsideClaim( actionId: string, - messageId: string, + externalId: string, + result: AgentActionResult, ): Promise { const delivered = "Slack accepted this message before the run stopped, and it cannot be withdrawn."; @@ -690,7 +753,8 @@ async function recordDeliveryOutsideClaim( await db.agentAction.updateMany({ where: { id: actionId, status: { not: "SUCCEEDED" }, externalId: null }, data: { - externalId: messageId, + externalId, + result: storedActionResult(result), errorMessage: current.errorMessage ? `${current.errorMessage} ${delivered}` : delivered, @@ -1060,6 +1124,7 @@ export async function openRunSlackChannel( channelId: existing.externalId, channelName, watching: (await channelOfRun(runId)) === existing.externalId, + result: readAgentActionResult(existing.type, existing.result), replayed: true, }; } @@ -1080,6 +1145,7 @@ export async function openRunSlackChannel( channelId: claim.externalId, channelName, watching: (await channelOfRun(runId)) === claim.externalId, + result: claim.result, replayed: true, }; } @@ -1091,7 +1157,11 @@ export async function openRunSlackChannel( const watching = await claimSlackChannel(runId, outcome.id); const owner = await addDealOwner(runId, outcome.id).catch(() => null); - await settleRunAction(claim, outcome.id); + const result = parseAgentActionResult({ + type: AGENT_ACTION_TYPES.SLACK_CHANNEL_OPEN, + channelId: outcome.id, + }); + await settleRunAction(claim, outcome.id, result); return { actionId: claim.actionId, @@ -1099,6 +1169,7 @@ export async function openRunSlackChannel( channelName: outcome.name, watching: watching === outcome.id, owner, + result, replayed: false, }; } catch (error) { @@ -1130,7 +1201,12 @@ export async function inviteToRunSlackChannel( const requestHash = hashRequest({ channelId, emails: emails.join(",") }); const existing = await findRunAction(idempotencyKey, requestHash); if (existing?.status === "SUCCEEDED") { - return { actionId: existing.id, channelId, replayed: true }; + return { + actionId: existing.id, + channelId, + result: readAgentActionResult(existing.type, existing.result), + replayed: true, + }; } const claim = await claimRunAction(existing, idempotencyKey, requestHash, { @@ -1144,7 +1220,12 @@ export async function inviteToRunSlackChannel( summary: `Invite ${emails.length} to ${channelId}`, }); if (!claim.claimed) { - return { actionId: claim.actionId, channelId, replayed: true }; + return { + actionId: claim.actionId, + channelId, + result: claim.result, + replayed: true, + }; } try { @@ -1163,11 +1244,13 @@ export async function inviteToRunSlackChannel( ); } - await settleRunAction(claim, channelId); + const stored = slackInviteResult(channelId, outcomes); + await settleRunAction(claim, stored.externalId, stored.result); return { actionId: claim.actionId, channelId, + result: stored.result, invited, refused, replayed: false, @@ -1206,6 +1289,7 @@ async function activeRunForSlack( async function settleRunAction( claim: Extract, externalId: string, + result: AgentActionResult, ): Promise { const completed = await db.agentAction.updateMany({ where: { @@ -1213,10 +1297,15 @@ async function settleRunAction( status: "RUNNING", startedAt: claim.claimedAt, }, - data: { status: "SUCCEEDED", externalId, completedAt: new Date() }, + data: { + status: "SUCCEEDED", + externalId, + result: storedActionResult(result), + completedAt: new Date(), + }, }); if (completed.count === 0) { - await recordDeliveryOutsideClaim(claim.actionId, externalId); + await recordDeliveryOutsideClaim(claim.actionId, externalId, result); throw new Error("This agent run stopped while Slack was still working."); } } diff --git a/apps/agent/agent/lib/slack-invite.ts b/apps/agent/agent/lib/slack-invite.ts index 11b0c76e..d468ae54 100644 --- a/apps/agent/agent/lib/slack-invite.ts +++ b/apps/agent/agent/lib/slack-invite.ts @@ -3,7 +3,13 @@ import { slackGet, slackPost } from "./slack-api"; import { slackAccessToken } from "./slack-connection"; export type InviteOutcome = - | { invited: true; email: string; kind: "member" | "connect"; url?: string } + | { + invited: true; + email: string; + kind: "member" | "connect"; + invite_id?: string; + url?: string; + } | { invited: false; email: string; reason: string }; const ALREADY_IN_CHANNEL = "already_in_channel"; @@ -69,7 +75,13 @@ async function inviteGuest( ); if (outcome.ok) { - return { invited: true, email, kind: "connect", url: outcome.data.url }; + return { + invited: true, + email, + kind: "connect", + invite_id: outcome.data.invite_id, + url: outcome.data.url, + }; } if (outcome.error === ALREADY_IN_CHANNEL) { diff --git a/apps/agent/test/slack-channel-actions.integration.spec.ts b/apps/agent/test/slack-channel-actions.integration.spec.ts index 92781e56..437e85c9 100644 --- a/apps/agent/test/slack-channel-actions.integration.spec.ts +++ b/apps/agent/test/slack-channel-actions.integration.spec.ts @@ -298,8 +298,50 @@ describe("inviting people as a deployed run", () => { emails: ["buyer@customer.test"], }); - expect(outcome).toMatchObject({ channelId, replayed: false }); + expect(outcome).toMatchObject({ + channelId, + replayed: false, + result: { + type: "slack.channel.invite", + invite_id: "I1", + url: "https://slack.com/invite/x", + email: "buyer@customer.test", + kind: "connect", + }, + }); expect(outcome.invited).toHaveLength(1); + expect(outcome.invited?.[0]).toMatchObject({ + invite_id: "I1", + url: "https://slack.com/invite/x", + }); + + const row = await db.agentAction.findFirst({ + where: { runId, type: "slack.channel.invite" }, + select: { externalId: true, result: true }, + }); + expect(row?.externalId).toBe("I1"); + expect(row?.result).toMatchObject({ + type: "slack.channel.invite", + invite_id: "I1", + url: "https://slack.com/invite/x", + email: "buyer@customer.test", + kind: "connect", + }); + + const replay = await inviteToRunSlackChannel(runId, "invite-1", { + emails: ["buyer@customer.test"], + }); + expect(replay).toMatchObject({ + channelId, + replayed: true, + result: { + type: "slack.channel.invite", + invite_id: "I1", + url: "https://slack.com/invite/x", + email: "buyer@customer.test", + kind: "connect", + }, + }); await db.slackChannel.deleteMany({ where: { id: channelId } }); }); diff --git a/apps/agent/test/slack-invite.integration.spec.ts b/apps/agent/test/slack-invite.integration.spec.ts index f4269f41..46915e7c 100644 --- a/apps/agent/test/slack-invite.integration.spec.ts +++ b/apps/agent/test/slack-invite.integration.spec.ts @@ -88,6 +88,7 @@ describe("inviting somebody to a channel", () => { expect(outcome).toMatchObject({ invited: true, kind: "connect", + invite_id: "I1", url: "https://slack.com/invite/abc", }); expect(sent("conversations.inviteShared")?.body).toMatchObject({ diff --git a/apps/api/src/agent/agent-runs.service.ts b/apps/api/src/agent/agent-runs.service.ts index 6cff770b..dca64159 100644 --- a/apps/api/src/agent/agent-runs.service.ts +++ b/apps/api/src/agent/agent-runs.service.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { type Db, Prisma } from "@crm/db"; import type { AgentRunStatus } from "@crm/db/enums"; import { lockIdempotencyKey } from "@crm/db/idempotency"; +import { readAgentActionResult } from "@crm/validation/agent-action"; import { BadRequestException, ConflictException, @@ -85,6 +86,7 @@ export class AgentRunsService { attemptCount: true, errorCode: true, errorMessage: true, + result: true, plannedAt: true, startedAt: true, completedAt: true, @@ -110,6 +112,7 @@ export class AgentRunsService { })), actions: run.actions.map((action) => ({ ...action, + result: readAgentActionResult(action.type, action.result), plannedAt: action.plannedAt.toISOString(), startedAt: action.startedAt?.toISOString() ?? null, completedAt: action.completedAt?.toISOString() ?? null, diff --git a/apps/api/src/agent/agents.contracts.ts b/apps/api/src/agent/agents.contracts.ts index 844e16dc..664baa7d 100644 --- a/apps/api/src/agent/agents.contracts.ts +++ b/apps/api/src/agent/agents.contracts.ts @@ -269,6 +269,7 @@ const agentRunActionOutput = z.object({ attemptCount: z.number(), errorCode: z.string().nullable(), errorMessage: z.string().nullable(), + result: schemas.agentAction.storedResult, plannedAt: z.string(), startedAt: z.string().nullable(), completedAt: z.string().nullable(), diff --git a/apps/app/components/agent-builder/agent-history.tsx b/apps/app/components/agent-builder/agent-history.tsx index 7df33a1e..6e251fee 100644 --- a/apps/app/components/agent-builder/agent-history.tsx +++ b/apps/app/components/agent-builder/agent-history.tsx @@ -17,6 +17,7 @@ import { } from "@crm/ui/components/alert-dialog"; import { Button } from "@crm/ui/components/button"; import { Icon } from "@crm/ui/components/icon"; +import { Link } from "@crm/ui/components/link"; import { cn } from "@crm/ui/lib/utils"; import { useState } from "react"; import { z } from "zod"; @@ -296,7 +297,7 @@ function ExpandedRun({ run }: { run: RunRow }) { - {entry.action.externalId ?? entry.action.id.slice(0, 12)} + {actionReceipt(entry.action)} ), @@ -408,6 +409,17 @@ export function AgentActivity({ activity }: { activity: Activity }) { ); } +function actionReceipt(action: RunRow["actions"][number]) { + if (action.result?.type === "slack.channel.invite" && action.result.url) { + return ( + + {action.result.url} + + ); + } + return action.externalId ?? action.id.slice(0, 12); +} + function humanStatus(value: string): string { return value .toLowerCase() diff --git a/packages/db/prisma/migrations/20260830083000_agent_action_result/migration.sql b/packages/db/prisma/migrations/20260830083000_agent_action_result/migration.sql new file mode 100644 index 00000000..750a4a42 --- /dev/null +++ b/packages/db/prisma/migrations/20260830083000_agent_action_result/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "agentAction" ADD COLUMN "result" JSONB; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index aa0d8ab9..f52ccbfc 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -886,6 +886,7 @@ model AgentAction { targetLabel String? summary String metadata Json? + result Json? status AgentActionStatus @default(PLANNED) idempotencyKey String @unique diff --git a/packages/validation/package.json b/packages/validation/package.json index 01212f19..77fc8263 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -7,6 +7,7 @@ ".": "./src/index.ts", "./activity-meta": "./src/activity-meta.ts", "./agent-events": "./src/agent-events.ts", + "./agent-action": "./src/agent-action.ts", "./agent-manifest": "./src/agent-manifest.ts", "./builder-question": "./src/builder-question.ts", "./enrichment-queue": "./src/enrichment-queue.ts", diff --git a/packages/validation/src/agent-action.ts b/packages/validation/src/agent-action.ts new file mode 100644 index 00000000..536a8acd --- /dev/null +++ b/packages/validation/src/agent-action.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { AGENT_ACTION_TYPES } from "./agent-manifest"; + +const present = z.string().trim().min(1); + +export const result = z.discriminatedUnion("type", [ + z.object({ + type: z.literal(AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE), + activityId: present, + }), + z.object({ + type: z.literal(AGENT_ACTION_TYPES.RUN_SUMMARY), + }), + z.object({ + type: z.literal(AGENT_ACTION_TYPES.SLACK_MESSAGE_POST), + channel: present, + ts: present, + }), + z.object({ + type: z.literal(AGENT_ACTION_TYPES.SLACK_CHANNEL_OPEN), + channelId: present, + }), + z.object({ + type: z.literal(AGENT_ACTION_TYPES.SLACK_CHANNEL_INVITE), + invite_id: present.optional(), + url: present.optional(), + email: z.email(), + kind: z.enum(["member", "connect"]), + }), +]); + +export const storedResult = result.nullable(); + +export type AgentActionResult = z.infer; + +function issues(error: z.ZodError): string { + return error.issues + .map((issue) => + issue.path.length > 0 + ? `${issue.path.join(".")} ${issue.message}` + : issue.message, + ) + .join("; "); +} + +export function parseAgentActionResult(value: unknown): AgentActionResult { + const parsed = result.safeParse(value); + if (parsed.success) return parsed.data; + throw new Error(`This agent action result: ${issues(parsed.error)}`); +} + +export function readAgentActionResult( + type: string, + value: unknown, +): AgentActionResult | null { + if (value === null || value === undefined) return null; + + const parsed = storedResult.safeParse(value); + if (!parsed.success) { + throw new Error(`This agent action result: ${issues(parsed.error)}`); + } + if (parsed.data === null) return null; + if (parsed.data.type !== type) { + throw new Error( + `This agent action result: type ${parsed.data.type} does not match action ${type}`, + ); + } + return parsed.data; +} diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index cd2efe1d..3e6f6c71 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -1,5 +1,6 @@ import type { ZodType, z } from "zod"; import * as activityMeta from "./activity-meta"; +import * as agentAction from "./agent-action"; import * as agentEvents from "./agent-events"; import * as agentManifest from "./agent-manifest"; import * as agents from "./agents"; @@ -11,6 +12,7 @@ import * as slackEvents from "./slack-events"; export const schemas = { activityMeta, + agentAction, agentEvents, agentManifest, agents, @@ -22,6 +24,7 @@ export const schemas = { } as const; export type { ActivityMeta, ActivityMetaFields } from "./activity-meta"; +export type { AgentActionResult } from "./agent-action"; export type { CrmEventTask } from "./agent-events"; export type { AgentActionType, diff --git a/packages/validation/test/agent-action.spec.ts b/packages/validation/test/agent-action.spec.ts new file mode 100644 index 00000000..1c4ccfec --- /dev/null +++ b/packages/validation/test/agent-action.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "bun:test"; +import { InvalidInput, parse, schemas } from "../src/index"; + +describe("AgentAction result", () => { + it("keeps invite_id and url on a Slack Connect invitation", () => { + expect( + parse( + schemas.agentAction.result, + { + type: "slack.channel.invite", + invite_id: "I1", + url: "https://slack.com/invite/x", + email: "buyer@customer.test", + kind: "connect", + }, + "This agent action result", + ), + ).toEqual({ + type: "slack.channel.invite", + invite_id: "I1", + url: "https://slack.com/invite/x", + email: "buyer@customer.test", + kind: "connect", + }); + }); + + it("keeps a member invitation that Slack never gave an id", () => { + expect( + parse( + schemas.agentAction.result, + { + type: "slack.channel.invite", + email: "rep@ours.test", + kind: "member", + }, + "This agent action result", + ), + ).toEqual({ + type: "slack.channel.invite", + email: "rep@ours.test", + kind: "member", + }); + }); + + it("stores the Slack message channel and ts", () => { + expect( + parse( + schemas.agentAction.result, + { type: "slack.message.post", channel: "C1", ts: "1.2" }, + "This agent action result", + ), + ).toEqual({ type: "slack.message.post", channel: "C1", ts: "1.2" }); + }); + + it("stores the opened channel id", () => { + expect( + parse( + schemas.agentAction.result, + { type: "slack.channel.open", channelId: "C1" }, + "This agent action result", + ), + ).toEqual({ type: "slack.channel.open", channelId: "C1" }); + }); + + it("stores the created activity id", () => { + expect( + parse( + schemas.agentAction.result, + { type: "crm.activity.create", activityId: "act-1" }, + "This agent action result", + ), + ).toEqual({ type: "crm.activity.create", activityId: "act-1" }); + }); + + it("reads a missing result as null", () => { + expect( + parse(schemas.agentAction.storedResult, null, "This agent action result"), + ).toBeNull(); + }); + + it("rejects a Connect invitation that names nobody", () => { + expect(() => + parse( + schemas.agentAction.result, + { type: "slack.channel.invite", kind: "connect" }, + "This agent action result", + ), + ).toThrow(InvalidInput); + }); +}); From a12b23a48e164adb2effe7396fc79827141b86be Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:44:58 -0700 Subject: [PATCH 14/21] fix(agent): store parsed action results as Prisma JSON --- apps/agent/agent/lib/run-runtime.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/agent/agent/lib/run-runtime.ts b/apps/agent/agent/lib/run-runtime.ts index 3ce3809c..9c6b252f 100644 --- a/apps/agent/agent/lib/run-runtime.ts +++ b/apps/agent/agent/lib/run-runtime.ts @@ -130,7 +130,9 @@ const RUN_ACTION_FIELDS = { } as const; function storedActionResult(result: AgentActionResult): Prisma.InputJsonValue { - return json.parse(JSON.parse(JSON.stringify(result))); + return parseAgentActionResult( + JSON.parse(JSON.stringify(result)), + ) as Prisma.InputJsonValue; } function slackInviteResult(channelId: string, outcomes: InviteOutcome[]) { From 9c3a6c53c396f44dac64a4f535adef542bd96e43 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:46:33 -0700 Subject: [PATCH 15/21] fix(agent): name Slack request bodies so lint:slop can pass --- apps/agent/agent/lib/slack-api.ts | 10 +++++++++- apps/agent/test/run-resume.integration.spec.ts | 15 ++++++++++----- apps/agent/test/slack-events.integration.spec.ts | 11 ++++++++--- apps/api/test/slack-events.spec.ts | 5 +++-- packages/validation/test/slack-events.spec.ts | 3 ++- 5 files changed, 32 insertions(+), 12 deletions(-) diff --git a/apps/agent/agent/lib/slack-api.ts b/apps/agent/agent/lib/slack-api.ts index 915ac061..61cceb65 100644 --- a/apps/agent/agent/lib/slack-api.ts +++ b/apps/agent/agent/lib/slack-api.ts @@ -3,6 +3,14 @@ import { SLACK } from "./slack-config"; type Reply = { ok: boolean; error?: string }; +export type SlackPostBody = { + channel?: string; + users?: string; + emails?: string[]; + name?: string; + is_private?: boolean; +}; + export type SlackOutcome = | { ok: true; data: T } | { ok: false; error: string }; @@ -38,7 +46,7 @@ async function read( export async function slackPost( token: string, method: string, - body: Record, + body: SlackPostBody, schema: z.ZodType, attempt = 1, ): Promise> { diff --git a/apps/agent/test/run-resume.integration.spec.ts b/apps/agent/test/run-resume.integration.spec.ts index b5afa8ff..38edb86d 100644 --- a/apps/agent/test/run-resume.integration.spec.ts +++ b/apps/agent/test/run-resume.integration.spec.ts @@ -21,15 +21,20 @@ type Delivery = { attributes?: Record; }; +type EveSendOptions = { + continuationToken?: string; + mode?: string; + auth?: { attributes?: Record }; +}; + const deliveries: Delivery[] = []; -const send = (async (message: string, options: Record) => { - const auth = options?.auth as { attributes?: Record }; +const send = (async (message: string, options?: EveSendOptions) => { deliveries.push({ message, - continuationToken: options?.continuationToken as string, - mode: options?.mode as string, - attributes: auth?.attributes, + continuationToken: options?.continuationToken, + mode: options?.mode, + attributes: options?.auth?.attributes, }); return { id: `ses_${deliveries.length}` }; }) as unknown as SendFn; diff --git a/apps/agent/test/slack-events.integration.spec.ts b/apps/agent/test/slack-events.integration.spec.ts index 6d331f7e..1a3ccd39 100644 --- a/apps/agent/test/slack-events.integration.spec.ts +++ b/apps/agent/test/slack-events.integration.spec.ts @@ -7,6 +7,7 @@ import { it, } from "bun:test"; import { db } from "@crm/db"; +import type { SlackEvent } from "@crm/validation"; import type { SendFn } from "eve/channels"; import { runToken } from "../agent/lib/custom-agent-dispatch"; import { claimSlackChannel } from "../agent/lib/run-resume"; @@ -24,10 +25,14 @@ let versionId = ""; const deliveries: { message: string; continuationToken?: string }[] = []; -const send = (async (message: string, options: Record) => { +type EveSendOptions = { + continuationToken?: string; +}; + +const send = (async (message: string, options?: EveSendOptions) => { deliveries.push({ message, - continuationToken: options?.continuationToken as string, + continuationToken: options?.continuationToken, }); return { id: `ses_${deliveries.length}` }; }) as unknown as SendFn; @@ -53,7 +58,7 @@ async function makeRun( return run.id; } -async function inbox(event: Record, channelId: string | null) { +async function inbox(event: SlackEvent, channelId: string | null) { const eventId = `Ev-${crypto.randomUUID()}`; const row = await db.slackEventInbox.create({ data: { diff --git a/apps/api/test/slack-events.spec.ts b/apps/api/test/slack-events.spec.ts index 1f9d7232..bc6e2a49 100644 --- a/apps/api/test/slack-events.spec.ts +++ b/apps/api/test/slack-events.spec.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { signBody } from "@crm/auth"; +import type { SlackEnvelope, SlackEvent } from "@crm/validation"; import { UnauthorizedException } from "@nestjs/common"; import { SlackEventsController } from "../src/slack/slack-events.controller"; @@ -30,7 +31,7 @@ const config = { const controller = new SlackEventsController(agent, config); const post = ( - payload: unknown, + payload: SlackEnvelope | { type: string; nested?: { a: number } }, over: { secret?: string; skew?: number } = {}, ) => { const body = JSON.stringify(payload); @@ -40,7 +41,7 @@ const post = ( return controller.events(Buffer.from(body), timestamp, signature); }; -const callback = (event: Record, eventId = "Ev1") => ({ +const callback = (event: SlackEvent, eventId = "Ev1") => ({ type: "event_callback", event_id: eventId, team_id: "T1", diff --git a/packages/validation/test/slack-events.spec.ts b/packages/validation/test/slack-events.spec.ts index 642a2d38..971adc13 100644 --- a/packages/validation/test/slack-events.spec.ts +++ b/packages/validation/test/slack-events.spec.ts @@ -5,6 +5,7 @@ import { isFromApp, SLACK_EVENT_TYPES, slackEnvelope, + type SlackEvent, } from "../src/slack-events"; const joined = { @@ -18,7 +19,7 @@ const joined = { }, }; -const message = (over: Record = {}) => ({ +const message = (over: Partial = {}) => ({ type: SLACK_EVENT_TYPES.MESSAGE, channel: "C1", user: "U1", From d1549da338042c0adaa471548f3d1f3fc58c86e3 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:46:48 -0700 Subject: [PATCH 16/21] fix(validation): sort SlackEvent imports so biome can lint --- packages/validation/test/slack-events.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/validation/test/slack-events.spec.ts b/packages/validation/test/slack-events.spec.ts index 971adc13..de48be2c 100644 --- a/packages/validation/test/slack-events.spec.ts +++ b/packages/validation/test/slack-events.spec.ts @@ -4,8 +4,8 @@ import { isActionable, isFromApp, SLACK_EVENT_TYPES, - slackEnvelope, type SlackEvent, + slackEnvelope, } from "../src/slack-events"; const joined = { From 70da1033b3b93c01b5804fe2a7aa1067491cf0c2 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:49:03 -0700 Subject: [PATCH 17/21] fix(validation): export agentActionResult under one name The history contract and the run store both parse the same shape. Naming it in two places lets them drift. One export, used on write and every read. --- apps/api/src/agent/agents.contracts.ts | 3 ++- packages/validation/src/agent-action.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/api/src/agent/agents.contracts.ts b/apps/api/src/agent/agents.contracts.ts index 664baa7d..402bae74 100644 --- a/apps/api/src/agent/agents.contracts.ts +++ b/apps/api/src/agent/agents.contracts.ts @@ -1,4 +1,5 @@ import { schemas } from "@crm/validation"; +import { agentActionResult } from "@crm/validation/agent-action"; import { z } from "zod"; export const agentManifest = schemas.agents.capabilities.loose(); @@ -269,7 +270,7 @@ const agentRunActionOutput = z.object({ attemptCount: z.number(), errorCode: z.string().nullable(), errorMessage: z.string().nullable(), - result: schemas.agentAction.storedResult, + result: agentActionResult.nullable(), plannedAt: z.string(), startedAt: z.string().nullable(), completedAt: z.string().nullable(), diff --git a/packages/validation/src/agent-action.ts b/packages/validation/src/agent-action.ts index 536a8acd..22eaf344 100644 --- a/packages/validation/src/agent-action.ts +++ b/packages/validation/src/agent-action.ts @@ -31,6 +31,8 @@ export const result = z.discriminatedUnion("type", [ export const storedResult = result.nullable(); +export const agentActionResult = result; + export type AgentActionResult = z.infer; function issues(error: z.ZodError): string { From 42b85a0246888e23a959995f65e7ff3472d00d2f Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:23:54 -0700 Subject: [PATCH 18/21] fix(agent): ask Slack for the Connect invite url conversations.inviteShared defaults external_limited to true, so Slack omits url. The request now sets external_limited false. The timeline shows invite_id and the url Slack returned. --- apps/agent/agent/lib/slack-api.ts | 1 + apps/agent/agent/lib/slack-invite.ts | 2 +- .../slack-channel-actions.integration.spec.ts | 40 +++++++++++++++---- .../test/slack-invite.integration.spec.ts | 33 ++++++++++++++- .../agent-builder/agent-history.tsx | 25 ++++++++---- 5 files changed, 85 insertions(+), 16 deletions(-) diff --git a/apps/agent/agent/lib/slack-api.ts b/apps/agent/agent/lib/slack-api.ts index 61cceb65..3d1f2fdd 100644 --- a/apps/agent/agent/lib/slack-api.ts +++ b/apps/agent/agent/lib/slack-api.ts @@ -7,6 +7,7 @@ export type SlackPostBody = { channel?: string; users?: string; emails?: string[]; + external_limited?: boolean; name?: string; is_private?: boolean; }; diff --git a/apps/agent/agent/lib/slack-invite.ts b/apps/agent/agent/lib/slack-invite.ts index d468ae54..539e5511 100644 --- a/apps/agent/agent/lib/slack-invite.ts +++ b/apps/agent/agent/lib/slack-invite.ts @@ -70,7 +70,7 @@ async function inviteGuest( const outcome = await slackPost( token, "conversations.inviteShared", - { channel: channelId, emails: [email] }, + { channel: channelId, emails: [email], external_limited: false }, schemas.slack.inviteShared, ); diff --git a/apps/agent/test/slack-channel-actions.integration.spec.ts b/apps/agent/test/slack-channel-actions.integration.spec.ts index 437e85c9..2f6fc3ba 100644 --- a/apps/agent/test/slack-channel-actions.integration.spec.ts +++ b/apps/agent/test/slack-channel-actions.integration.spec.ts @@ -282,13 +282,31 @@ describe("inviting people as a deployed run", () => { it("invites into the channel the run opened", async () => { const runId = await makeRun([openAction, inviteAction, summaryAction]); const channelId = `C-${crypto.randomUUID()}`; - slackReplies((url) => - url.includes("conversations.create") - ? { ok: true, channel: { id: channelId, name: "acme-onboarding" } } - : url.includes("users.lookupByEmail") - ? { ok: false, error: "users_not_found" } - : { ok: true, invite_id: "I1", url: "https://slack.com/invite/x" }, - ); + const posted: { url: string; body: unknown }[] = []; + globalThis.fetch = (async ( + input: URL | RequestInfo, + init?: RequestInit, + ) => { + const url = String(input instanceof Request ? input.url : input); + posted.push({ + url, + body: init?.body ? JSON.parse(String(init.body)) : null, + }); + return new Response( + JSON.stringify( + url.includes("conversations.create") + ? { ok: true, channel: { id: channelId, name: "acme-onboarding" } } + : url.includes("users.lookupByEmail") + ? { ok: false, error: "users_not_found" } + : { + ok: true, + invite_id: "I1", + url: "https://slack.com/invite/x", + }, + ), + { headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; await openRunSlackChannel(runId, "open-1", { name: "Acme Onboarding", @@ -314,6 +332,14 @@ describe("inviting people as a deployed run", () => { invite_id: "I1", url: "https://slack.com/invite/x", }); + expect( + posted.find((call) => call.url.includes("conversations.inviteShared")) + ?.body, + ).toMatchObject({ + channel: channelId, + emails: ["buyer@customer.test"], + external_limited: false, + }); const row = await db.agentAction.findFirst({ where: { runId, type: "slack.channel.invite" }, diff --git a/apps/agent/test/slack-invite.integration.spec.ts b/apps/agent/test/slack-invite.integration.spec.ts index 46915e7c..6a87df42 100644 --- a/apps/agent/test/slack-invite.integration.spec.ts +++ b/apps/agent/test/slack-invite.integration.spec.ts @@ -65,7 +65,11 @@ describe("inviting somebody to a channel", () => { const outcome = await inviteToSlackChannel(CHANNEL_ID, "rep@ours.test"); - expect(outcome).toMatchObject({ invited: true, kind: "member" }); + expect(outcome).toEqual({ + invited: true, + email: "rep@ours.test", + kind: "member", + }); expect(sent("conversations.invite")?.body).toMatchObject({ channel: CHANNEL_ID, users: "U7", @@ -94,6 +98,33 @@ describe("inviting somebody to a channel", () => { expect(sent("conversations.inviteShared")?.body).toMatchObject({ channel: CHANNEL_ID, emails: ["buyer@customer.test"], + external_limited: false, + }); + }); + + it("keeps invite_id when Slack withholds the url", async () => { + replies((url) => + url.includes("users.lookupByEmail") + ? { ok: false, error: "users_not_found" } + : { ok: true, invite_id: "I1" }, + ); + + const outcome = await inviteToSlackChannel( + CHANNEL_ID, + "buyer@customer.test", + ); + + expect(outcome).toEqual({ + invited: true, + email: "buyer@customer.test", + kind: "connect", + invite_id: "I1", + url: undefined, + }); + expect(sent("conversations.inviteShared")?.body).toMatchObject({ + channel: CHANNEL_ID, + emails: ["buyer@customer.test"], + external_limited: false, }); }); diff --git a/apps/app/components/agent-builder/agent-history.tsx b/apps/app/components/agent-builder/agent-history.tsx index 6e251fee..10735397 100644 --- a/apps/app/components/agent-builder/agent-history.tsx +++ b/apps/app/components/agent-builder/agent-history.tsx @@ -410,14 +410,25 @@ export function AgentActivity({ activity }: { activity: Activity }) { } function actionReceipt(action: RunRow["actions"][number]) { - if (action.result?.type === "slack.channel.invite" && action.result.url) { - return ( - - {action.result.url} - - ); + if (action.result?.type !== "slack.channel.invite") { + return action.externalId ?? action.id.slice(0, 12); } - return action.externalId ?? action.id.slice(0, 12); + + const inviteId = action.result.invite_id ?? action.externalId; + const url = action.result.url; + if (!inviteId && !url) return action.id.slice(0, 12); + + return ( + <> + {inviteId} + {inviteId && url ? " · " : null} + {url ? ( + + {url} + + ) : null} + + ); } function humanStatus(value: string): string { From 966c958e5069ff509f0813c27d5b1d9c35d16948 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:44:49 -0700 Subject: [PATCH 19/21] fix: close Slack resume holes so a drain cannot lose or double-handle an event A public Slack POST was unbounded, a failed inbox write looked like a duplicate, and two drains could resume the same row. --- apps/agent/agent/lib/slack-events-config.ts | 4 ++ apps/agent/agent/lib/slack-events.ts | 53 +++++++++++++------ apps/agent/fire-deal-closed.ts | 33 ------------ apps/agent/test/custom-agent-runtime.spec.ts | 9 +++- .../test/slack-events.integration.spec.ts | 17 ++++++ apps/api/src/agent/agent-runs.service.ts | 10 +++- apps/api/src/agent/agent-trigger.service.ts | 14 ++++- apps/api/src/create-app.ts | 21 ++------ apps/api/src/slack/slack-config.ts | 3 ++ apps/api/test/agent-events.spec.ts | 44 +++++++++++++++ apps/api/test/agent-runs.spec.ts | 37 +++++++++++++ apps/api/test/slack-events.spec.ts | 14 +++++ .../migration.sql | 2 + packages/db/prisma/schema.prisma | 1 + 14 files changed, 194 insertions(+), 68 deletions(-) delete mode 100644 apps/agent/fire-deal-closed.ts create mode 100644 packages/db/prisma/migrations/20260830093300_slack_event_inbox_lease/migration.sql diff --git a/apps/agent/agent/lib/slack-events-config.ts b/apps/agent/agent/lib/slack-events-config.ts index 90c67d05..331be05f 100644 --- a/apps/agent/agent/lib/slack-events-config.ts +++ b/apps/agent/agent/lib/slack-events-config.ts @@ -1,4 +1,8 @@ +const SECOND_MS = 1_000; +const MINUTE_MS = 60 * SECOND_MS; + export const SLACK_EVENTS = { batch: 20, maxTextChars: 2_000, + leaseMs: MINUTE_MS, } as const; diff --git a/apps/agent/agent/lib/slack-events.ts b/apps/agent/agent/lib/slack-events.ts index c773837e..5aa663f6 100644 --- a/apps/agent/agent/lib/slack-events.ts +++ b/apps/agent/agent/lib/slack-events.ts @@ -1,4 +1,4 @@ -import { db } from "@crm/db"; +import { db, type Prisma } from "@crm/db"; import type { SlackEvent } from "@crm/validation"; import { schemas } from "@crm/validation"; import { SLACK_EVENT_TYPES } from "@crm/validation/slack-events"; @@ -12,9 +12,20 @@ export type SlackEventOutcome = { outcome: string; }; +type ClaimedSlackEvent = { + id: string; + eventId: string; + channelId: string | null; + payload: Prisma.JsonValue; +}; + export async function pendingSlackEventIds(): Promise { + const now = new Date(); const rows = await db.slackEventInbox.findMany({ - where: { processedAt: null }, + where: { + processedAt: null, + OR: [{ leasedUntil: null }, { leasedUntil: { lt: now } }], + }, orderBy: { receivedAt: "asc" }, take: SLACK_EVENTS.batch, select: { id: true }, @@ -23,27 +34,39 @@ export async function pendingSlackEventIds(): Promise { return rows.map((row) => row.id); } +async function claimSlackEvent(id: string): Promise { + const now = new Date(); + const until = new Date(now.getTime() + SLACK_EVENTS.leaseMs); + + const claimed = await db.$queryRaw` + UPDATE "slackEventInbox" AS t + SET "leasedUntil" = ${until} + FROM ( + SELECT t2.id FROM "slackEventInbox" AS t2 + WHERE t2.id = ${id} + AND t2."processedAt" IS NULL + AND (t2."leasedUntil" IS NULL OR t2."leasedUntil" < ${now}) + FOR UPDATE SKIP LOCKED + ) AS due + WHERE t.id = due.id + RETURNING t.id, t."eventId", t."channelId", t.payload; + `; + + return claimed[0] ?? null; +} + export async function dispatchSlackEvent( id: string, send: SendFn, ): Promise { - const row = await db.slackEventInbox.findUnique({ - where: { id }, - select: { - id: true, - eventId: true, - channelId: true, - payload: true, - processedAt: true, - }, - }); + const row = await claimSlackEvent(id); - if (!row || row.processedAt) return null; + if (!row) return null; const settle = (outcome: string, resumed = false) => db.slackEventInbox .updateMany({ - where: { id, processedAt: null }, + where: { id: row.id, processedAt: null }, data: { processedAt: new Date(), outcome: outcome.slice(0, 300) }, }) .then(() => ({ eventId: row.eventId, resumed, outcome })); @@ -73,7 +96,7 @@ export async function dispatchSlackEvent( if (result.kind === "resumed") { await db.slackEventInbox.updateMany({ - where: { id }, + where: { id: row.id }, data: { runId }, }); return settle(`Resumed run ${runId}.`, true); diff --git a/apps/agent/fire-deal-closed.ts b/apps/agent/fire-deal-closed.ts deleted file mode 100644 index 2d09b74d..00000000 --- a/apps/agent/fire-deal-closed.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { db } from "@crm/db"; -import { PRIORITY } from "@crm/db/agent-tasks"; - -const dealId = process.argv[2] ?? "seed-deal-cal-com-0"; - -const deal = await db.deal.update({ - where: { id: dealId }, - data: { stage: "CLOSED_WON", closedAt: new Date() }, - select: { id: true, name: true, stage: true, companyId: true }, -}); - -const task = await db.agentTask.create({ - data: { - dealId: deal.id, - contactId: null, - companyId: null, - kind: "agent-event", - reason: "deal.closed", - payload: { - type: "deal.closed", - record: { kind: "deal", id: deal.id }, - occurredAt: new Date().toISOString(), - data: { stage: "CLOSED_WON" }, - }, - priority: PRIORITY.event, - budget: 1, - dueAt: new Date(), - }, - select: { id: true }, -}); - -console.log(JSON.stringify({ deal, taskId: task.id }, null, 2)); -process.exit(0); diff --git a/apps/agent/test/custom-agent-runtime.spec.ts b/apps/agent/test/custom-agent-runtime.spec.ts index 1eebb0b3..b0924476 100644 --- a/apps/agent/test/custom-agent-runtime.spec.ts +++ b/apps/agent/test/custom-agent-runtime.spec.ts @@ -244,8 +244,15 @@ describe("deployed Slack actions", () => { approvedSlackDestination({ ...manifest, dataScope: { ...manifest.dataScope, resources: [] }, + actions: [ + { + type: "run.summary", + provider: "crm", + summary: "Summarize the Slack delivery", + }, + ], }), - ).toThrow("needs the slack:workspace resource"); + ).toThrow("Agent version does not allow Slack."); }); it("posts with a stable Slack replay id", async () => { diff --git a/apps/agent/test/slack-events.integration.spec.ts b/apps/agent/test/slack-events.integration.spec.ts index 1a3ccd39..b60e5bb7 100644 --- a/apps/agent/test/slack-events.integration.spec.ts +++ b/apps/agent/test/slack-events.integration.spec.ts @@ -160,6 +160,23 @@ describe("turning a stored Slack event into a resume", () => { expect(deliveries).toHaveLength(1); }); + it("claims the row so two drains resume the event once", async () => { + const channelId = `C-${crypto.randomUUID()}`; + await makeRun(channelId); + const id = await inbox( + { type: "message", channel: channelId, text: "once" }, + channelId, + ); + + const outcomes = await Promise.all([ + dispatchSlackEvent(id, send), + dispatchSlackEvent(id, send), + ]); + + expect(outcomes.filter((outcome) => outcome?.resumed)).toHaveLength(1); + expect(deliveries).toHaveLength(1); + }); + it("settles an event whose channel owns no run, rather than retrying forever", async () => { const channelId = `C-${crypto.randomUUID()}`; const id = await inbox( diff --git a/apps/api/src/agent/agent-runs.service.ts b/apps/api/src/agent/agent-runs.service.ts index dca64159..ee86ca38 100644 --- a/apps/api/src/agent/agent-runs.service.ts +++ b/apps/api/src/agent/agent-runs.service.ts @@ -112,7 +112,7 @@ export class AgentRunsService { })), actions: run.actions.map((action) => ({ ...action, - result: readAgentActionResult(action.type, action.result), + result: listedActionResult(action.type, action.result), plannedAt: action.plannedAt.toISOString(), startedAt: action.startedAt?.toISOString() ?? null, completedAt: action.completedAt?.toISOString() ?? null, @@ -446,3 +446,11 @@ export class AgentRunsService { } } } + +function listedActionResult(type: string, value: Prisma.JsonValue | null) { + try { + return readAgentActionResult(type, value); + } catch { + return null; + } +} diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index f6715925..731bd2e5 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -581,8 +581,18 @@ export class AgentTriggerService { payload: input.payload, }, }); - } catch { - return { stored: false }; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + return { stored: false }; + } + this.logger.error( + { message: "Could not store Slack event", eventId: input.eventId }, + error instanceof Error ? error.stack : String(error), + ); + throw error; } this.poke(); diff --git a/apps/api/src/create-app.ts b/apps/api/src/create-app.ts index 984490d0..6885950c 100644 --- a/apps/api/src/create-app.ts +++ b/apps/api/src/create-app.ts @@ -15,6 +15,7 @@ import { } from "trpc-to-openapi"; import { AppModule } from "./app.module"; import { ContextLogger } from "./logging/context-logger"; +import { SLACK } from "./slack/slack-config"; import { SLACK_EVENTS_PATH } from "./slack/slack-events.controller"; import { REST_BRIDGE_PATH } from "./trpc/openapi"; import { createBaseTrpcContext } from "./trpc/trpc.context"; @@ -27,7 +28,10 @@ export async function createApp(): Promise { ); app.use(helmet()); - app.use(SLACK_EVENTS_PATH, collectRawBody); + app.use( + SLACK_EVENTS_PATH, + raw({ type: "*/*", limit: SLACK.events.maxBodyBytes }), + ); app.useGlobalPipes( new ValidationPipe({ whitelist: true, @@ -116,18 +120,3 @@ export async function createApp(): Promise { return app; } - -function collectRawBody( - request: Request, - _response: Response, - next: NextFunction, -): void { - const chunks: Buffer[] = []; - - request.on("data", (chunk: Buffer) => chunks.push(chunk)); - request.on("end", () => { - request.body = Buffer.concat(chunks); - next(); - }); - request.on("error", next); -} diff --git a/apps/api/src/slack/slack-config.ts b/apps/api/src/slack/slack-config.ts index 32408718..5009d38d 100644 --- a/apps/api/src/slack/slack-config.ts +++ b/apps/api/src/slack/slack-config.ts @@ -10,6 +10,9 @@ export const SLACK = { pageSize: 50, maxPageSize: 100, }, + events: { + maxBodyBytes: 64 * 1024, + }, } as const; export const SLACK_SYNC_STATES = ["idle", "syncing", "stalled"] as const; diff --git a/apps/api/test/agent-events.spec.ts b/apps/api/test/agent-events.spec.ts index 2fe38f52..745cb73b 100644 --- a/apps/api/test/agent-events.spec.ts +++ b/apps/api/test/agent-events.spec.ts @@ -43,6 +43,9 @@ beforeAll(async () => { }); afterAll(async () => { + await db.slackEventInbox.deleteMany({ + where: { eventId: { startsWith: `Ev-${suffix}-` } }, + }); await db.agentTask.deleteMany({ where: { OR: [ @@ -255,3 +258,44 @@ describe("CRM agent events", () => { ).toBe(2); }); }); + +describe("Slack event inbox writes", () => { + it("stores a Slack event once and treats a duplicate as already seen", async () => { + const eventId = `Ev-${suffix}-dup`; + const payload = { type: "event_callback", event_id: eventId }; + + expect( + await service.slackEventReceived({ + eventId, + type: "message", + payload, + }), + ).toEqual({ stored: true }); + expect( + await service.slackEventReceived({ + eventId, + type: "message", + payload, + }), + ).toEqual({ stored: false }); + }); + + it("rethrows a Slack inbox write that is not a duplicate", async () => { + const original = db.slackEventInbox.create.bind(db.slackEventInbox); + db.slackEventInbox.create = (async () => { + throw new Error("the inbox is unreachable"); + }) as typeof db.slackEventInbox.create; + + try { + await expect( + service.slackEventReceived({ + eventId: `Ev-${suffix}-fail`, + type: "message", + payload: { type: "event_callback" }, + }), + ).rejects.toThrow("the inbox is unreachable"); + } finally { + db.slackEventInbox.create = original; + } + }); +}); diff --git a/apps/api/test/agent-runs.spec.ts b/apps/api/test/agent-runs.spec.ts index 22d4d8c4..7f533f3e 100644 --- a/apps/api/test/agent-runs.spec.ts +++ b/apps/api/test/agent-runs.spec.ts @@ -224,6 +224,43 @@ describe("manual agent runs", () => { }); }); + it("returns null for one unreadable action result and still lists the run", async () => { + const { id: runId } = await service.runNow( + { id: agentId, clientRequestId: crypto.randomUUID() }, + userId, + ); + await db.agentAction.createMany({ + data: [ + { + agentId, + runId, + type: "run.summary", + provider: "crm", + summary: "Broken result", + status: "SUCCEEDED", + idempotencyKey: `bad-result-${crypto.randomUUID()}`, + result: { not: "a stored result" }, + }, + { + agentId, + runId, + type: "run.summary", + provider: "crm", + summary: "Good result", + status: "SUCCEEDED", + idempotencyKey: `good-result-${crypto.randomUUID()}`, + result: { type: "run.summary" }, + }, + ], + }); + + const runs = await service.list(agentId, 1, userId); + expect(runs).toHaveLength(1); + const results = runs[0]?.actions.map((action) => action.result) ?? []; + expect(results).toContain(null); + expect(results).toContainEqual({ type: "run.summary" }); + }); + it("rejects a manual run while an agent is not live", async () => { const beforePokeCount = pokeCount; const draft = await db.agentDefinition.create({ diff --git a/apps/api/test/slack-events.spec.ts b/apps/api/test/slack-events.spec.ts index bc6e2a49..17468639 100644 --- a/apps/api/test/slack-events.spec.ts +++ b/apps/api/test/slack-events.spec.ts @@ -182,3 +182,17 @@ describe("the Slack events endpoint", () => { ).rejects.toThrow(UnauthorizedException); }); }); + +describe("the Slack events body cap", () => { + it("uses Express raw middleware with an explicit size limit, then verifies", async () => { + const source = await Bun.file( + new URL("../src/create-app.ts", import.meta.url), + ).text(); + + expect(source).toContain( + 'raw({ type: "*/*", limit: SLACK.events.maxBodyBytes })', + ); + expect(source).not.toContain("collectRawBody"); + expect(source).toContain("SLACK_EVENTS_PATH"); + }); +}); diff --git a/packages/db/prisma/migrations/20260830093300_slack_event_inbox_lease/migration.sql b/packages/db/prisma/migrations/20260830093300_slack_event_inbox_lease/migration.sql new file mode 100644 index 00000000..c91b656c --- /dev/null +++ b/packages/db/prisma/migrations/20260830093300_slack_event_inbox_lease/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "slackEventInbox" ADD COLUMN "leasedUntil" TIMESTAMP(3); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index f52ccbfc..aed6d00f 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -121,6 +121,7 @@ model SlackEventInbox { runId String? receivedAt DateTime @default(now()) + leasedUntil DateTime? processedAt DateTime? outcome String? From bc1a4b345243e438c6c231ec7976364fd936a939 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:46:06 -0700 Subject: [PATCH 20/21] fix(api): declare Express so the Slack raw-body cap can load The events path now uses Express raw middleware. Without Express as a direct dependency, createApp cannot start in tests. --- apps/api/package.json | 1 + bun.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/api/package.json b/apps/api/package.json index 151aec0f..70a6add3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -43,6 +43,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "context.dev": "^2.7.0", + "express": "^5.2.1", "helmet": "^8.3.0", "nestjs-trpc": "^2.13.0", "reflect-metadata": "^0.2.2", diff --git a/bun.lock b/bun.lock index 1c6df3e6..ce9f1476 100644 --- a/bun.lock +++ b/bun.lock @@ -57,6 +57,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "context.dev": "^2.7.0", + "express": "^5.2.1", "helmet": "^8.3.0", "nestjs-trpc": "^2.13.0", "reflect-metadata": "^0.2.2", From 40d08e8ea5502638f5b1824f2f97da242b436fa0 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Sun, 30 Aug 2026 03:04:03 -0700 Subject: [PATCH 21/21] copy: apply Cal's CRM-13 Slack strings --- apps/agent/agent/lib/run-runtime.ts | 9 ++++--- apps/agent/agent/lib/slack-invite.ts | 2 +- .../agent_builder/lib/draft-input.ts | 25 ++++++++++++++++--- apps/agent/test/custom-agent-runtime.spec.ts | 4 ++- .../slack-channel-actions.integration.spec.ts | 4 ++- .../test/slack-invite.integration.spec.ts | 4 ++- .../agent-builder/agent-history.tsx | 2 +- apps/app/lib/agent-transcript.ts | 2 +- 8 files changed, 39 insertions(+), 13 deletions(-) diff --git a/apps/agent/agent/lib/run-runtime.ts b/apps/agent/agent/lib/run-runtime.ts index 9c6b252f..ee2840ae 100644 --- a/apps/agent/agent/lib/run-runtime.ts +++ b/apps/agent/agent/lib/run-runtime.ts @@ -142,7 +142,7 @@ function slackInviteResult(channelId: string, outcomes: InviteOutcome[]) { } const chosen = invited.find((outcome) => outcome.invite_id) ?? invited[0]; if (!chosen) { - throw new Error("Slack returned no invitation to store."); + throw new Error("Slack didn't send an invite we could save."); } return { externalId: chosen.invite_id ?? channelId, @@ -788,7 +788,7 @@ async function slackApiRequest( const reason = envelope.error ?? "rejected"; if (reason === "not_in_channel") { throw new Error( - "The Slack bot is not in the selected channel. Invite the app to that channel and retry the run.", + "Comp AI isn't in that channel. Invite it there, then try again.", ); } if (reason === "missing_scope") { @@ -1219,7 +1219,10 @@ export async function inviteToRunSlackChannel( targetType: "channel", targetId: channelId, targetLabel: channelId, - summary: `Invite ${emails.length} to ${channelId}`, + summary: + emails.length === 1 + ? "Invited 1 person" + : `Invited ${emails.length} people`, }); if (!claim.claimed) { return { diff --git a/apps/agent/agent/lib/slack-invite.ts b/apps/agent/agent/lib/slack-invite.ts index 539e5511..7ded7d1c 100644 --- a/apps/agent/agent/lib/slack-invite.ts +++ b/apps/agent/agent/lib/slack-invite.ts @@ -103,7 +103,7 @@ function explain(error: string): string { return "That address is Comp AI itself."; case "missing_scope": case "restricted_action": - return "Slack refused: this workspace does not allow Comp AI to send this invitation."; + return "This workspace doesn't let Comp AI send that invitation."; case "org_level_email_not_allowed": return "This workspace blocks Slack Connect invitations to that address."; case "invalid_auth": diff --git a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts index 89f6e4c1..d1efb24f 100644 --- a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts +++ b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts @@ -89,6 +89,19 @@ const ACTIVITY_ACCESS = { const ACTIVITY_ORDER = ["NOTE", "TASK"] as const; +const SLACK_ACCESS = { + [AGENT_ACTION_TYPES.SLACK_MESSAGE_POST]: + "Post to approved Slack destinations", + [AGENT_ACTION_TYPES.SLACK_CHANNEL_OPEN]: "Open Slack channels", + [AGENT_ACTION_TYPES.SLACK_CHANNEL_INVITE]: "Invite people to Slack channels", +} as const; + +const SLACK_ORDER = [ + AGENT_ACTION_TYPES.SLACK_MESSAGE_POST, + AGENT_ACTION_TYPES.SLACK_CHANNEL_OPEN, + AGENT_ACTION_TYPES.SLACK_CHANNEL_INVITE, +] as const; + const INTEGRATIONS = { gmail: { kind: "integration", id: "google:gmail", label: "Gmail" }, calendar: { @@ -111,16 +124,20 @@ export function draftInputFromTool( : [], ), ); + const slackTypes = new Set(input.actions.map((entry) => entry.type)); const access = [ input.recordScope === "WORKSPACE" ? "Read workspace CRM records" : "Read selected CRM records", - ...integrations.map((integration) => { - if (integration === "gmail") return "Read connected Gmail messages"; + ...integrations.flatMap((integration) => { + if (integration === "gmail") return ["Read connected Gmail messages"]; if (integration === "calendar") { - return "Read connected Google Calendar events"; + return ["Read connected Google Calendar events"]; } - return "Post to approved Slack destinations"; + const lines = SLACK_ORDER.filter((type) => slackTypes.has(type)).map( + (type) => SLACK_ACCESS[type], + ); + return lines.length > 0 ? lines : ["Post to approved Slack destinations"]; }), ...ACTIVITY_ORDER.filter((type) => activityTypes.has(type)).map( (type) => ACTIVITY_ACCESS[type], diff --git a/apps/agent/test/custom-agent-runtime.spec.ts b/apps/agent/test/custom-agent-runtime.spec.ts index b0924476..f72260bd 100644 --- a/apps/agent/test/custom-agent-runtime.spec.ts +++ b/apps/agent/test/custom-agent-runtime.spec.ts @@ -316,7 +316,9 @@ describe("deployed Slack actions", () => { "7d3e8854-79f9-48dd-a933-8cfb5994f99e", { fetcher }, ), - ).rejects.toThrow("Invite the app"); + ).rejects.toThrow( + "Comp AI isn't in that channel. Invite it there, then try again.", + ); }); }); diff --git a/apps/agent/test/slack-channel-actions.integration.spec.ts b/apps/agent/test/slack-channel-actions.integration.spec.ts index 2f6fc3ba..4e3b1685 100644 --- a/apps/agent/test/slack-channel-actions.integration.spec.ts +++ b/apps/agent/test/slack-channel-actions.integration.spec.ts @@ -391,7 +391,9 @@ describe("inviting people as a deployed run", () => { inviteToRunSlackChannel(runId, "invite-1", { emails: ["buyer@customer.test"], }), - ).rejects.toThrow("does not allow"); + ).rejects.toThrow( + "This workspace doesn't let Comp AI send that invitation.", + ); const action = await db.agentAction.findFirst({ where: { runId, type: "slack.channel.invite" }, diff --git a/apps/agent/test/slack-invite.integration.spec.ts b/apps/agent/test/slack-invite.integration.spec.ts index 6a87df42..b79d8de1 100644 --- a/apps/agent/test/slack-invite.integration.spec.ts +++ b/apps/agent/test/slack-invite.integration.spec.ts @@ -154,7 +154,9 @@ describe("inviting somebody to a channel", () => { expect(outcome.invited).toBe(false); expect(outcome).toMatchObject({ - reason: expect.stringContaining("does not allow"), + reason: expect.stringContaining( + "This workspace doesn't let Comp AI send that invitation.", + ), }); }); diff --git a/apps/app/components/agent-builder/agent-history.tsx b/apps/app/components/agent-builder/agent-history.tsx index 10735397..76c6f6b3 100644 --- a/apps/app/components/agent-builder/agent-history.tsx +++ b/apps/app/components/agent-builder/agent-history.tsx @@ -424,7 +424,7 @@ function actionReceipt(action: RunRow["actions"][number]) { {inviteId && url ? " · " : null} {url ? ( - {url} + Invite link ) : null} diff --git a/apps/app/lib/agent-transcript.ts b/apps/app/lib/agent-transcript.ts index d2862070..1dc001eb 100644 --- a/apps/app/lib/agent-transcript.ts +++ b/apps/app/lib/agent-transcript.ts @@ -77,7 +77,7 @@ const VERBS: ToolVerbs = { schedule_recheck: "Decided when to look again", record_job_change: "Raised a job change", list_deals: "Reviewed the deal pipeline", - open_slack_channel: "Opened a Slack channel and watched it", + open_slack_channel: "Opened a Slack channel", invite_to_slack_channel: "Invited people to the Slack channel", list_outstanding_work: "Looked for outstanding work", set_chat_title: "Named this chat",