Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 143 additions & 40 deletions apps/chat-bot/src/relay/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,24 @@
* the database, and none of it belongs on the path Cloudflare evaluates when it validates the
* uploaded script.
*/
import { ConnectorCredentials, type ChatConnector, type InboundEvent } from "@maple/chat-platform"
import {
ConnectorCredentials,
WORKSPACE_CREDENTIALS,
type ChatConnector,
type ConnectorConfig,
type InboundEvent,
} from "@maple/chat-platform"
import { connectors } from "@maple/chat-platform/connectors"
import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare"
import { chatSessionStub } from "@maple/domain/chat-session-stub"
import type { ChatConnectorId, OrgId } from "@maple/domain/primitives"
import type { IntegrationsPersistenceError } from "@maple/domain/http"
import { workerTelemetryConfig } from "@maple/infra/worker-telemetry"
import { workerEnvLayer } from "@maple/infra/worker-runtime"
import { Cause, Effect, Layer, Option } from "effect"
import { Cause, Effect, Layer, Option, Schema } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { summarizeCause } from "@maple/backend/platform/describe-cause"
import { parseBase64Aes256GcmKey } from "@maple/backend/platform/Crypto"
import { chatChartImageUrl } from "@maple/backend/services/chat/chat-chart"
import { Database } from "@maple/backend/platform/DatabaseLive"
import { layerPg } from "@maple/backend/platform/DatabasePgLive"
Expand Down Expand Up @@ -51,6 +58,39 @@ const APP_BASE_URL_FALLBACK = "https://app.maple.dev"
const appBaseUrl = (env: Record<string, unknown>): string =>
setting(env, "MAPLE_APP_BASE_URL") ?? APP_BASE_URL_FALLBACK

/** `MAPLE_INGEST_KEY_ENCRYPTION_KEY` is set on this deployment but is not a usable key. */
class CredentialKeyUnusable extends Schema.TaggedError<CredentialKeyUnusable>()(
"@maple/chat-bot/CredentialKeyUnusable",
{ message: Schema.String },
) {}

/**
* The key a connector's per-workspace credential was sealed with, or `null` where this deployment
* binds none.
*
* Optional because most of this Worker does not need it: a connector whose credential is one
* deployment-wide secret stores nothing to open, and a stage without the key runs those normally.
* A workspace that DID store one then fails its lookup rather than resolving without its token —
* see `chat-workspace-rows.ts`.
*/
const credentialKey = (env: Record<string, unknown>): Effect.Effect<Buffer | null> =>
parseBase64Aes256GcmKey(
setting(env, "MAPLE_INGEST_KEY_ENCRYPTION_KEY") ?? "",
(message) => new CredentialKeyUnusable({ message }),
).pipe(
// Absent is the ordinary case on a stage running no connector that stores a credential, so
// it is not an error — but a key that IS set and unusable is a deployment mistake that
// would otherwise surface only as an unreadable workspace on every mention, reason nowhere.
Effect.tapError((error) =>
setting(env, "MAPLE_INGEST_KEY_ENCRYPTION_KEY") === undefined
? Effect.void
: Effect.logError("Chat workspace credential key is unusable").pipe(
Effect.annotateLogs({ "error.type": error._tag, "error.message": error.message }),
),
),
Effect.orElseSucceed(() => null),
)

export interface RelayHost {
readonly env: Record<string, unknown>
/** Whether this conversation has already been told that its workspace is not linked. */
Expand Down Expand Up @@ -96,45 +136,96 @@ const lookupFailed =
),
)

const ports = (
/**
* The workspace this event belongs to: which org, which Maple user the clicker linked, and the
* credential the connector posts with — in ONE connection.
*
* Sequential rather than parallel because the second question needs the first one's answer: a link
* is per org, and the org is what the workspace names.
*
* The credential rides along because it comes out of the same row, and reading that row twice —
* once to answer the relay and once to build the transport — would be two Postgres connections for
* one mention.
*/
const lookupWorkspace = (
host: RelayHost,
connector: ChatConnector<HttpClient.HttpClient | ConnectorCredentials>,
): RelayPorts<HttpClient.HttpClient | ConnectorCredentials> => ({
outbound: connector.outbound,
supportsIdentity: connector.identity !== undefined,
/**
* The workspace, and — when a caller names a chat account — the Maple user it is linked to, in
* ONE connection.
*
* Sequential rather than parallel because the second question needs the first one's answer: a
* link is per org, and the org is what the workspace names.
*/
resolveWorkspace: (connectorId, workspaceId, externalUserId) =>
connectorId: ChatConnectorId,
workspaceId: string,
externalUserId: string | undefined,
) =>
Effect.flatMap(credentialKey(host.env), (key) =>
withDatabase(
host.env,
Effect.gen(function* () {
const database = yield* Database
const workspace = yield* resolveChatWorkspace(database, connectorId, workspaceId)
const workspace = yield* resolveChatWorkspace(database, connectorId, workspaceId, key)
if (Option.isNone(workspace)) return Option.none<ResolvedWorkspace>()
const credentials = workspace.value.credentials
// Nothing to link with, or nobody asked: one query.
if (
Option.isNone(workspace) ||
externalUserId === undefined ||
connector.identity === undefined
) {
return Option.map(workspace, (found) => ({ orgId: found.orgId }))
if (externalUserId === undefined || connector.identity === undefined) {
return Option.some<ResolvedWorkspace>({
relay: { orgId: workspace.value.orgId },
credentials,
})
}
const identity = yield* resolveChatIdentity(
database,
workspace.value.orgId,
connectorId,
externalUserId,
)
return Option.some<RelayWorkspace>({
orgId: workspace.value.orgId,
...(Option.isNone(identity) ? undefined : { linkedUserId: identity.value.userId }),
return Option.some<ResolvedWorkspace>({
relay: {
orgId: workspace.value.orgId,
...(Option.isNone(identity) ? undefined : { linkedUserId: identity.value.userId }),
},
credentials,
})
}),
).pipe(lookupFailed(connectorId, "The chat workspace could not be read")),
)

/** What the one lookup answers: the relay's half, and the transport's. */
interface ResolvedWorkspace {
readonly relay: RelayWorkspace
readonly credentials: string | undefined
}

/**
* The config the connector's transport is built from: what the deployment set, plus this
* workspace's own credential where it stored one.
*/
const connectorCredentials = (
config: ConnectorConfig,
resolved: Option.Option<ResolvedWorkspace>,
): ConnectorConfig => {
if (Option.isNone(resolved) || resolved.value.credentials === undefined) return config
return new Map(config).set(WORKSPACE_CREDENTIALS, resolved.value.credentials)
}

const ports = (
host: RelayHost,
connector: ChatConnector<HttpClient.HttpClient | ConnectorCredentials>,
lookup: (
connectorId: ChatConnectorId,
workspaceId: string,
externalUserId: string | undefined,
) => Effect.Effect<Option.Option<ResolvedWorkspace>, WorkspaceLookupFailed>,
): RelayPorts<HttpClient.HttpClient | ConnectorCredentials> => ({
outbound: connector.outbound,
supportsIdentity: connector.identity !== undefined,
/**
* The workspace, and — when a caller names a chat account — the Maple user it is linked to, in
* ONE connection.
*
* Sequential rather than parallel because the second question needs the first one's answer: a
* link is per org, and the org is what the workspace names.
*/
resolveWorkspace: (connectorId, workspaceId, externalUserId) =>
Effect.map(lookup(connectorId, workspaceId, externalUserId), (found) =>
Option.map(found, (workspace) => workspace.relay),
),
forgetWorkspace: (connectorId, workspaceId) =>
withDatabase(
host.env,
Expand Down Expand Up @@ -182,21 +273,33 @@ export const runInboundEvent = async (host: RelayHost, event: InboundEvent): Pro
const config = resolveConnectorConfig(host.env, connector)
if (config._tag === "missing") return

try {
await Effect.runPromise(
relayInboundEvent(event, ports(host, connector)).pipe(
// oxlint-disable-next-line effecttsgo/strict-effect-provide
Effect.provide(
Layer.mergeAll(
FetchHttpClient.layer,
Layer.succeed(ConnectorCredentials)(config.config),
workerEnvLayer(host.env),
telemetry.layer,
),
// One read for the whole event, memoized: the relay asks which org this is, and the transport is
// built from the credential the same row carries. `Effect.cached` is what keeps that one
// Postgres connection rather than two, without hoisting the read ahead of the relay's own
// decision about whether this message is a turn at all.
await Effect.runPromise(
Effect.gen(function* () {
const lookup = yield* Effect.cached(
lookupWorkspace(
host,
connector,
event.connector,
event.workspaceId,
// Only a click names a person; a message is answered for the workspace.
event.type === "action" ? event.actor.id : undefined,
),
),
)
} finally {
await telemetry.flush(host.env).catch(() => undefined)
}
)
const resolved = yield* Effect.orElseSucceed(lookup, () => Option.none<ResolvedWorkspace>())
yield* relayInboundEvent(
event,
ports(host, connector, () => lookup),
).pipe(Effect.provideService(ConnectorCredentials, connectorCredentials(config.config, resolved)))
}).pipe(
// oxlint-disable-next-line effecttsgo/strict-effect-provide
Effect.provide(Layer.mergeAll(FetchHttpClient.layer, workerEnvLayer(host.env), telemetry.layer)),
// On the fiber rather than in a `finally`: the flush is what exports this event's spans,
// so it belongs to the same interruption and failure handling they do.
Effect.ensuring(Effect.promise(() => telemetry.flush(host.env).catch(() => undefined))),
),
)
}
19 changes: 13 additions & 6 deletions apps/chat-bot/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@
* the agent's chat session and the answer is streamed back. The ingress half
* never waits for any of that.
*
* **No public hostname.** The socket half dials out and needs none, and no
* webhook connector is registered yet — so a custom domain would be DNS, a
* certificate and a `MapleDomains` entry bought for a route nothing calls. Under
* `bun dev` the portless route reaches it; the first webhook connector is what
* should buy the hostname.
* **One public hostname, on production instances only** (`domains.chat`). A webhook connector's
* platform is configured with a request URL inside the vendor's own application, and that URL has
* to keep working across deploys — which is what buys the custom domain the socket half never
* needed. A dev stage reaches the same route through portless, and a PR preview gets no hostname:
* a connector there would have no credentials and no database to resolve a workspace in.
*/
import { connectors } from "@maple/chat-platform/connectors"
import {
Expand Down Expand Up @@ -68,6 +68,10 @@ const configuredEnv = (stage: MapleStage, region: MapleRegion, domains: MapleDom
// reply carries its charts as text, which is what `chatChartImageUrl` answers `null` for.
plainWithDefault("MAPLE_APP_BASE_URL", `https://${domains.web ?? "app.maple.dev"}`),
optionalSecret("MAPLE_SHARE_TOKEN_HMAC_KEY"),
// Opens the credential a connector's install sealed against a workspace row. Optional, and
// only a connector that stores one needs it: without the key such a workspace fails its
// lookup, and every other connector runs normally.
optionalSecret("MAPLE_INGEST_KEY_ENCRYPTION_KEY"),
)

/**
Expand All @@ -87,8 +91,11 @@ const props = Effect.gen(function* () {
placement: resolveWorkerPlacement(region),
// Under `bun dev`: a sticky port the app's route follows.
dev: workerDev("chat-bot"),
// See the module comment: nothing calls in from the public internet yet.
// A webhook connector's platform calls this hostname; see the module comment. A custom
// domain rather than a route, so DNS and the edge certificate come with it, and rather than
// `workers.dev`, whose URL carries the account subdomain and moves with the account.
workersDev: false,
domain: domains.chat,
// `devEnv` last, so `.env.local` cannot override the inter-app URLs.
env: {
// Cross-script reference to the chat Durable Object the AI Worker hosts: a mention
Expand Down
105 changes: 105 additions & 0 deletions docs/chat-connector-cutover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Cutting over from the standalone chat agent

`apps/slack-agent` is the bot Maple has been running: its own Railway service, its own app, its own
event URL. The chat-platform connector in `packages/chat-platform/src/connectors/slack/` replaces
it, on Cloudflare, behind the same `ChatConnector` contract every other platform uses.

This is the ops runbook for running both at once and then retiring the old one. It is not a
migration: there is nothing to move. The two have separate applications, separate tables and
separate credentials, so the cutover is "stop pointing at one, start pointing at the other", and the
rollback is the same sentence backwards.

**Deleting `apps/slack-agent` is not part of the connector's own change.** It is step 7 here, after
step 6 has held.

## Why they can run side by side

| | the standalone agent | the connector |
| --- | --- | --- |
| Where it runs | Railway | `apps/chat-bot` (Cloudflare) |
| Which app | its own | a new one — see the connector's README |
| Event URL | its own host | `https://chat.maple.dev/connectors/slack/webhook` |
| Workspace ↔ org | its own store | `chat_workspaces`, one row per linked workspace |
| Credentials | its own | `chat_workspaces.credentials_*`, sealed per workspace |

Nothing is shared, so both can be installed in the same workspace at the same time. They will both
answer a mention — which is the point of step 3, and the reason the cutover itself is one switch.

Note the third, unrelated thing also called Slack here: the alert-delivery integration
(`slack_workspaces`, `SlackIntegrationService`) is a fourth application again, is not part of this
cutover, and keeps running throughout.

## The cutover

1. **Create the new application and put its secrets on the deployments.** The connector's README has
the app manifest and the three secrets — client id and secret on api, signing secret on chat-bot
— plus `MAPLE_INGEST_KEY_ENCRYPTION_KEY` on chat-bot, which is what opens the per-workspace token.

2. **Deploy.** `apps/chat-bot` takes `chat.maple.dev` on production instances; a dev stage reaches
the same route through portless and a PR preview gets no hostname. Check
`https://chat.maple.dev/connectors/slack/webhook` answers **503** before the secrets land and
**400** after (an unsigned request is a rejected request — a 404 means the connector is not in
this build, and a 200 means something is very wrong).

3. **Install it into one workspace and run the smoke checklist**, with the old agent still running.
This is where the facts that only a live install can confirm get confirmed:

- saving the request URL succeeds (that is the `url_verification` handshake);
- **a top-level mention** in a public channel is answered in a thread on that message;
- **a mention inside an existing thread** is answered in that same thread — and lands on the
same session as the first question, not a new one;
- **a reply in the bot's thread without a mention** is answered, and the answer shows the bot
read the messages above it — the thread is one the bot opened, which is what makes an
un-addressed message in it still a turn;
- **an un-addressed message in a channel the bot was merely invited to** is NOT answered;
- **an approval card** renders with both buttons, pressing one applies the change, and the
card then keeps its line and loses its buttons;
- **the identity check**, which is what would let Slack approvals run as the person rather than
as the org: sign the same user in through Slack's OpenID Connect flow and compare the `sub`
it returns against the `user.id` on their `block_actions` payload. If they match, the
connector's `identity` can be implemented (see its README); nothing depends on the answer
today, so this is evidence-gathering rather than a gate;
- **a private channel, a DM and a group DM** each behave like the public channel;
- **formatting**: a turn with bold, italics, a link, a bullet list, a blockquote, a table and a
code block is readable — the table arrives as a fenced block, and nothing renders as raw
markdown;
- **nothing pages the workspace**: ask the bot to repeat `<!channel>` and `[urgent](!channel)`
back, and confirm neither notifies anybody;
- **removing the app** from the workspace unlinks its row (`app_uninstalled` →
`workspace-removed`);
- **no token anywhere**: grep the deploy's logs and spans for `xoxb-` and find nothing.

4. **Install and verify the connector in EVERY workspace the old app is in**, not just the one from
step 3. Step 5 is app-wide and cannot be done per workspace, so a workspace still on the old
agent when it runs simply loses its bot. `chat_workspaces` is the list of what has been linked;
the old application's own install list is what it has to be checked against.

5. **Turn the old app's event subscriptions OFF.** In the OLD application's configuration, disable
Event Subscriptions — do not point the request URL at something that fails. Slack retries a
delivery its endpoint rejects and **disables an app's subscriptions after enough of them**, so
an intentionally broken URL ends in the same place as switching them off, except that it gets
there by itself, at a time nobody chose, and re-entering the URL afterwards does not bring
delivery back until an operator re-enables the subscriptions by hand.

This is **app-wide**: Slack applies a subscription change across every team the app is installed
in, which is why step 4 comes first. The Railway agent keeps running and keeps its own
configuration; it simply stops being sent anything, everywhere, at once.

6. **Let it sit.** A week of real use is enough to find what a checklist does not. If the connector
has to be backed out, re-enable the old app's event subscriptions — the old agent has been
running untouched the whole time, and its request URL never moved.

7. **Retire the old agent**, in this order: uninstall the old application from every workspace, stop
the Railway service, then delete `apps/slack-agent` and its deploy configuration in a change of
its own.

## What each rollback costs

| Step reached | To undo |
| --- | --- |
| 2 | Nothing is live; remove the secrets or leave them, the connector is skipped without them. |
| 3 | Uninstall the new app from the workspace; its `chat_workspaces` row goes with it. |
| 4 | Same, for each workspace. The old app is still serving all of them. |
| 5 | Re-enable the old app's event subscriptions — app-wide, so every workspace comes back together. |
| 6 | Same. |
| 7 | The old agent is gone — from here, forward only. |
Loading
Loading