feat(agent): open, watch and resume Slack channels from agent runs - #203
Open
github-actions[bot] wants to merge 56 commits into
Open
feat(agent): open, watch and resume Slack channels from agent runs#203github-actions[bot] wants to merge 56 commits into
github-actions[bot] wants to merge 56 commits into
Conversation
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.
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.
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.
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.
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.
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.
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.
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.
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.
…ustomer 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.
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.
…rive 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.
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.
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.
… an event A public Slack POST was unbounded, a failed inbox write looked like a duplicate, and two drains could resume the same row.
The events path now uses Express raw middleware. Without Express as a direct dependency, createApp cannot start in tests.
CRM-1. A run-channel destination resolves to AgentRun.slackChannelId at post time.
CRM-1. Capabilities and the API switch on resolution instead of probing id.
CRM-1. Tests that stored a Slack destination now match slackDestination.
(cherry picked from commit d1687f04a45d682abeec32e4fadc738ca55ee9d7) (cherry picked from commit e506f9f61c2e317b772a94624e311b89ce61398d)
(cherry picked from commit 5f1e19c82cb079b01aee096442cc1c4c8b9c1561)
…feat/crm-1-run-channel # Conflicts: # apps/agent/agent/lib/run-runtime.ts # apps/agent/agent/lib/slack-membership.ts # apps/agent/test/slack-events.integration.spec.ts # apps/api/src/slack/slack-events.controller.ts
…eat/agent-run-visibility
SLACK_TUNNEL_PORT was the only source, so PORT=4000 left the tunnel forwarding to 3001 and every Slack event came back 502. The port now follows PORT, with SLACK_TUNNEL_PORT still the explicit override, and the .env fallback is shared with the hostname read.
invite_to_slack_channel never returns a top-level invited: false. It returns invited and refused arrays, and it throws when Slack refuses every address. The skill named a field that does not exist, so the agent could not act on a partial refusal.
The skill called every outright failure a Slack refusal. A missing channel and a stopped run reach the same throw with different text. It also told the agent to read refused after a replay, which carries result only.
An exported PORT outranked SLACK_TUNNEL_PORT in .env, so the tunnel followed the generic variable and pointed at the wrong port. The tunnel-specific variable now resolves from the environment and .env before PORT is read.
Reusing the channel a run owns skipped every Slack check, so an archived channel or a kicked bot still settled the open action SUCCEEDED and every later run-channel post failed. The reuse path now rejects an unavailable channel and rejoins before it hands the channel back.
A throw inside the paused window left the shared agent PAUSED, which breaks every later test that expects a live resume.
An unavailable row also covers a private channel the bot can no longer see, so the message asserted something Slack never said.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Opened automatically when
feat/agent-run-visibilitywas pushed.The title is written from the diff and rewritten as you push, because this is squashed onto
mainand the title becomes the commit subject and the changelog line. Retitle it yourself and it is yours — the automation stops touching it.Summary by cubic
Implements CRM-1: an agent run can now open its own Slack channel, invite people, and post into it, and gets woken up when messages or joins arrive in that channel. Previously Slack actions could only target a destination chosen when the agent was saved; now a run can create and claim a channel, and inbound Slack events resume that same parked run instead of starting a second one.
Behavior
open_slack_channelandinvite_to_slack_channeltools are manifest-approved actions, with the same dependency checks and idempotency as existing actions; reopening a name already in use joins the existing channel, and the run keeps the channel it already owns.resolution: "run-channel", which resolves to the channel the run opened./webhooks/slack/eventsendpoint verifies signatures and stores events inslackEventInbox; the agent drains them on the cron, in order, retrying undelivered resumes for five minutes.SLACK_SIGNING_SECRET, the endpoint rejects every request, and answers 200 to bodies it cannot parse so Slack stops retrying.invite_to_slack_channelreturns invited and refused arrays, and throws only when Slack refuses every address; a paused agent's Slack message is kept for replay.Setup / migration
SLACK_SIGNING_SECRETand the newapp_mentions:readSlack scope; existing Slack connections must be re-authorized.agentRun.slackChannelId,slackEventInbox, andagentAction.result.resolutionfield (chosenorrun-channel); saved agents with older Slack destinations need that field set.bun run tunnel:slackprovides a stable local hostname for Slack event delivery; it followsPORTunlessSLACK_TUNNEL_PORToverrides it.Written for commit 900b1ef. Summary will update on new commits.