diff --git a/.env.example b/.env.example index 02996b2..7cc26c9 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,10 @@ # ── Slack credentials (from api.slack.com/apps → your app) ────────────── # Set both to run on Slack; leave blank to skip Slack. # Bot Token: OAuth & Permissions page, "Bot User OAuth Token" (xoxb-...) -SLACK_BOT_TOKEN=xoxb-... +# SLACK_BOT_TOKEN=xoxb-... # App-Level Token: Basic Information → App-Level Tokens → generate with the # "connections:write" scope (xapp-...) -SLACK_APP_TOKEN=xapp-... +# SLACK_APP_TOKEN=xapp-... # ── Discord credentials (from discord.com/developers/applications → your app) ── # Set both to run on Discord; leave blank to skip Discord. Bot page → "Reset @@ -27,30 +27,62 @@ SLACK_APP_TOKEN=xapp-... # ── Agent backend (runtime.ts) ────────────────────────────────────────── # The AG-UI endpoint the bridge POSTs to. REQUIRED — the bridge exits at -# startup if this is unset; there is no code-level fallback. The value below -# is a template pointing at the local CopilotKit runtime started by -# `pnpm runtime`; change it if you deploy the runtime elsewhere. +# startup if this is unset; there is no code-level fallback. Must include an +# explicit http:// or https:// scheme — a bare "host:port" (e.g. the Railway +# private-hostname shape `agent:8123`) fails boot, since it parses as a URL +# with the host folded into the protocol instead of a valid http(s) URL. Use +# e.g. `http://agent.railway.internal:8123/` on Railway. The value below is a +# template pointing at the local CopilotKit runtime started by `pnpm runtime`; +# change it if you deploy the runtime elsewhere. AGENT_URL=http://localhost:8200/api/copilotkit/agent/triage/run # Optional auth header forwarded to the agent (for a deployed runtime). # AGENT_AUTH_HEADER=Bearer ... # ── Intelligence channel mode — app/managed.ts (`pnpm channel`) ────────── -# Runs the SAME bot over the CopilotKit Intelligence Realtime Gateway instead -# of a native adapter — this process holds NO Slack tokens (Intelligence owns -# the Slack edge). The agent brain reuses AGENT_URL / AGENT_AUTH_HEADER above. -# All values come from your CopilotKit Intelligence project + channel setup. +# Runs the SAME KiteBot over the CopilotKit Intelligence Realtime Gateway +# instead of a native adapter — this process holds NO Slack tokens +# (Intelligence owns the Slack edge). Wired up via `createChannel` + +# `CopilotRuntime({ channels })` + `createCopilotNodeListener` +# (@copilotkit/runtime/v2) — there are NO org/project/channel IDs; the +# runtime derives its infra ids from the API creds below plus the channel +# name. AGENT_URL / AGENT_AUTH_HEADER (above) still point the channel at +# the agent brain. +# NOTE: the example values below point at CopilotKit's own dev Intelligence +# host (dev.intelligence.copilotkit.ai) — replace both with YOUR project's +# Intelligence endpoint before deploying. +# Base URL of your CopilotKit Intelligence deployment's app API. +# INTELLIGENCE_API_URL=https://dev.intelligence.copilotkit.ai # Gateway runner WebSocket URL (the /runner endpoint). # INTELLIGENCE_GATEWAY_WS_URL=wss://dev.intelligence.copilotkit.ai/runner -# Project runtime API key (cpk-{projectId}_...), presented as the socket auth token. +# Project runtime API key (cpk-{projectId}_...), presented as the socket auth +# token; secret — set in your secret store, not committed here. # INTELLIGENCE_API_KEY=cpk-... -# Authoritative org/project/channel scope (from the Intelligence dashboard): -# INTELLIGENCE_ORG_ID=org_... -# INTELLIGENCE_PROJECT_ID=123 -# INTELLIGENCE_CHANNEL_ID=channel_... -# The registered channel name (lowercase kebab). Defaults to "kitebot". -# INTELLIGENCE_CHANNEL_NAME=kitebot -# Optional stable runtime instance id; auto-generated (rti_...) if unset. -# INTELLIGENCE_RUNTIME_INSTANCE_ID=rti_... +# Registered channel name (lowercase kebab); defaults to kite-opentag. +# INTELLIGENCE_CHANNEL_NAME=kite-opentag +# PORT is read from this SAME .env by every entry point in this repo, each +# with its own default if unset — setting it here affects ALL of them, not +# just the channel host: +# app/managed.ts (`pnpm channel`) → 8300 +# app/index.ts (`pnpm dev`) → 3000 (also used by the WhatsApp webhook, below) +# runtime.ts (`pnpm runtime`)→ 8200 +# agent/main.py (`pnpm agent`) → 8123 (reads PORT before SERVER_PORT — see setup.md) +# You normally run only ONE of these processes per terminal, each reading its +# own default, so leave PORT unset unless you need to override one of them — +# uncommenting it here moves that process off its default (e.g. it would move +# `runtime.ts` off :8200 while AGENT_URL above still points at :8200, breaking +# the two-terminal workflow with no obvious error). On Railway each service +# supplies its own injected $PORT, which always wins over this file. +# PORT=8300 +# The channel host's HTTP routes (agent/run, threads/*, memories/*) are CLOSED +# by default — the managed channel activates over the gateway WebSocket, not +# over HTTP, so nothing needs them and an open port would be an unauthenticated +# proxy to AGENT_URL. Set this to open them behind `Authorization: Bearer ` +# for server-to-server or curl use (e.g. scripting agent/run from another +# backend or hitting it manually). This is NOT a browser-facing surface: no +# `cors` option is passed to the listener, so preflight `OPTIONS` requests get +# no `Access-Control-Allow-Origin` and a browser can't call it — pointing a +# CopilotKit frontend directly at this runtime will not work. Secret. +# CHANNEL_HTTP_TOKEN= # Model for the BuiltInAgent. The runtime is OpenAI-only (it uses OpenAI's # Responses API via TanStack AI's `openaiText` adapter, needed for the @@ -58,7 +90,10 @@ AGENT_URL=http://localhost:8200/api/copilotkit/agent/triage/run # AGENT_MODEL only accepts an OpenAI model id, optionally prefixed with # "openai/" (the prefix is stripped); defaults to openai/gpt-5.5. # AGENT_MODEL=openai/gpt-5.5 -OPENAI_API_KEY=sk-... +# Required — the OpenAI adapter throws if this is not found in the +# environment (fails loudly, not silently). Set your own key; the example +# shape is sk-... +OPENAI_API_KEY= # ANTHROPIC_API_KEY / GOOGLE_API_KEY are NOT supported by this runtime today # (runtime.ts never reads them) — do not set AGENT_MODEL to an anthropic/... or # google/... value. @@ -83,7 +118,7 @@ OPENAI_API_KEY=sk-... # The hosted Linear MCP accepts a raw API key as a bearer token (no OAuth # dance). Create one at linear.app → Settings → API → Personal API keys. # Leave LINEAR_API_KEY blank to run without Linear. -LINEAR_API_KEY=lin_api_... +# LINEAR_API_KEY=lin_api_... # Override only if you front the MCP yourself; default is the hosted server. # LINEAR_MCP_URL=https://mcp.linear.app/mcp # Default Linear team the bot files/queries against. @@ -94,11 +129,13 @@ LINEAR_TEAM_KEY=CPK # NOTION_TOKEN is the Notion integration secret the sidecar uses to call the # Notion API (notion.so → Settings → Connections → develop integrations). # NOTION_MCP_AUTH_TOKEN is the bearer the sidecar requires on its HTTP -# transport; the agent sends it. Pick any strong string and use it in both -# `pnpm notion-mcp` and here. Leave NOTION_MCP_AUTH_TOKEN blank to run -# without Notion. -NOTION_TOKEN=ntn_... -NOTION_MCP_AUTH_TOKEN=choose-a-strong-shared-secret +# transport; the agent sends it. Pick your own strong string and use the SAME +# value in both `pnpm notion-mcp` and here — do not use a well-known or +# example value, since this token is the only thing protecting the sidecar +# (and the real NOTION_TOKEN behind it). Leave NOTION_MCP_AUTH_TOKEN blank to +# run without Notion. +# NOTION_TOKEN=ntn_... +NOTION_MCP_AUTH_TOKEN= # Override only if the sidecar runs elsewhere; default is the local sidecar. # NOTION_MCP_URL=http://127.0.0.1:3001/mcp # Port the `pnpm notion-mcp` sidecar listens on (scripts/start-notion-mcp.ts). @@ -107,6 +144,21 @@ NOTION_MCP_AUTH_TOKEN=choose-a-strong-shared-secret # Notion-Version header the sidecar sends to the Notion API. Defaults to # 2022-06-28; override only if you need a newer Notion API version. # NOTION_VERSION=2022-06-28 +# Host the sidecar binds to (scripts/start-notion-mcp.ts). Defaults to +# 127.0.0.1 (loopback), matching the upstream server's own default — fine for +# local dev. Railway's IaC (.railway/railway.ts) sets this to "::" so the +# sidecar listens on all interfaces and is reachable over the (IPv6) private +# network from the agent service; you shouldn't need to touch this locally. +# NOTION_MCP_HOST=127.0.0.1 + +# ── Chart & diagram rendering (app/render/) ────────────────────────────── +# Chart.js and Mermaid are loaded from a CDN into a LOCAL headless browser +# (app/render/chart.ts, app/render/diagram.ts) to draw charts/diagrams — your +# chart/diagram DATA is rendered locally and never sent anywhere; only the +# rendering library itself is fetched from the CDN. Override either to pin a +# different version or point at a self-hosted mirror. +# CHART_JS_URL=https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.js +# MERMAID_URL=https://cdn.jsdelivr.net/npm/mermaid@11.16.0/dist/mermaid.min.js # ── WhatsApp Cloud API (optional — omit to run Slack-only) ────────────── # From your Meta App → WhatsApp → API Setup. Leave WHATSAPP_ACCESS_TOKEN blank @@ -116,6 +168,10 @@ WHATSAPP_ACCESS_TOKEN= WHATSAPP_PHONE_NUMBER_ID= WHATSAPP_APP_SECRET= WHATSAPP_VERIFY_TOKEN=choose-any-string-and-paste-it-in-the-webhook-config -# Webhook path (default /webhook). The server listens on $PORT — Railway injects -# it and routes the service's public domain there; locally it defaults to 3000. +# Webhook path (default /webhook). WhatsApp runs inside app/index.ts +# (`pnpm dev`), which listens on $PORT — Railway injects it and routes the +# service's public domain there; locally it defaults to 3000 if PORT is unset. +# See the shared PORT note above (Intelligence channel mode block) — PORT is +# one variable read by every entry point in this repo with a different +# default each. # WHATSAPP_PATH=/webhook diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..99b86cf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # main is exempt: it's the deploy source, so a superseding push must never + # cancel an in-flight run and leave an earlier commit without a green check. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + # pnpm version comes from the `packageManager` field in package.json — + # no `version` input needed here; that's the single source of truth. + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + # No `npx playwright install` here on purpose: every test that would need + # a headless browser is mocked at one of two layers — the renderer mocks + # in app/tools/__tests__/render-tools.test.ts (mocks ../../render/chart.js + # and ../../render/diagram.js) and the playwright-module mock in + # app/render/__tests__/browser.test.ts (mocks the playwright module + # directly) — so CI needs no Chromium. + - run: pnpm install --frozen-lockfile + + - run: pnpm check-types + + - run: pnpm test diff --git a/.railway/railway.ts b/.railway/railway.ts index de623ed..adeed64 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -8,9 +8,11 @@ import { defineRailway, github, preserve, project, service } from "railway/iac"; // (see README "Deploy to Railway"). // // Two topology invariants this file encodes: -// 1. Ports are pinned as explicit service variables (NOT left to Railway's -// auto-injected $PORT) so each service's listen port and the ${{svc.PORT}} -// its peers dial always agree. +// 1. Ports for peer-dialed services (agent, notion-mcp) are pinned as explicit +// service variables (NOT left to Railway's auto-injected $PORT) so each +// service's listen port and the ${{svc.PORT}} its peers dial always agree. +// The outbound-only channel host has no peer dialing it, so it uses +// Railway's injected $PORT (app/managed.ts defaults 8300). // 2. Services reached over Railway private networking must bind :: (all // interfaces) — private DNS (RAILWAY_PRIVATE_DOMAIN) resolves to IPv6, and // legacy environments are IPv6-only. A service bound to 127.0.0.1/0.0.0.0 @@ -37,6 +39,19 @@ export default defineRailway(() => { deploy: { restartPolicyType: "ON_FAILURE", restartPolicyMaxRetries: 5, + // Railway's SIGTERM->SIGKILL grace period defaults to 0. This launcher + // (scripts/start-notion-mcp.ts) DOES have a shutdown routine: its + // `shutdown()` forwards the signal to the whole detached process group + // via `killTree` (reaching npx and the underlying notion-mcp-server + // process), then schedules `setTimeout(() => killTree("SIGKILL"), 5000)` + // — a 5s escalation. At 0s draining, that group is never signalled and + // the escalation timer never gets a chance to fire before the platform + // SIGKILLs the container outright, so every redeploy hard-kills the + // `npx`-spawned tree instead of letting it exit cleanly. 10s gives 2x + // headroom over that 5s escalation (process-group signal delivery + + // npx/node exit overhead) without the drain window itself becoming the + // bottleneck. + drainingSeconds: 10, }, env: { NOTION_MCP_PORT: "3001", @@ -66,6 +81,22 @@ export default defineRailway(() => { healthcheckTimeout: 300, restartPolicyType: "ON_FAILURE", restartPolicyMaxRetries: 5, + // Railway's SIGTERM->SIGKILL grace period defaults to 0. uvicorn DOES + // have a shutdown routine to protect: on SIGTERM it stops accepting new + // connections and waits for in-flight ones to finish + // (`Server.shutdown()` in uvicorn's own server.py), and since main.py's + // `uvicorn.run(...)` call above does not set `timeout_graceful_shutdown`, + // that wait is UNBOUNDED on uvicorn's side — Railway's drainingSeconds is + // the sole ceiling on how long an in-flight AG-UI research stream gets + // before SIGKILL truncates it mid-turn. The channel host this agent + // streams to over private networking is itself only guaranteed a 15s + // drain window (see channel's drainingSeconds below), so giving the + // producer less than that would undercut the very guarantee the + // consumer's window exists to provide. 30s — roughly double the + // channel's 15s — gives an in-flight tool call/model round trip real + // room to finish and flush its next SSE chunk before a redeploy tears + // the connection down. + drainingSeconds: 30, }, env: { // Pin PORT so uvicorn's bind and ${{agent.PORT}} (dialed by channel) agree. @@ -89,31 +120,132 @@ export default defineRailway(() => { }, }); - // KiteBot channel host — runs the bot over the Intelligence Realtime Gateway. + // KiteBot channel host — runs the bot via CopilotKit Intelligence v2: + // createChannel() feeds a CopilotRuntime({ channels }) into + // createCopilotNodeListener(). There are no org/project/channel IDs to wire + // up: the runtime derives that identity from the API credentials plus the + // channel name below. const channel = service("channel", { source: github(REPO), start: "pnpm channel", + // The channel host renders inline charts/diagrams via Playwright/Chromium + // (app/render/browser.ts → chromium.launch). Install the browser at build + // time; PLAYWRIGHT_BROWSERS_PATH=0 (env below) puts it in node_modules so it + // persists into the deploy image, and RAILPACK_DEPLOY_APT_PACKAGES supplies + // the shared libs Chromium needs at runtime. Without this, render_chart / + // render_diagram fail on the deployed channel. Mirrors the kite reference. + build: { + // Pin the builder: RAILPACK_DEPLOY_APT_PACKAGES below is honored ONLY by + // Railpack, and (since --with-deps was dropped) it is now the SOLE source + // of the 31 packages (28 shared libs + 3 fonts) Chromium needs at runtime + // — see the derivation notes on that env var below. Leaving `builder` + // unset defaults to Railway's own choice, which could silently be a + // non-Railpack builder — RAILPACK_DEPLOY_APT_PACKAGES would then do + // nothing, and render_chart/render_diagram would die at runtime on a + // missing .so with no build-time signal. + builder: "RAILPACK", + // `--frozen-lockfile` so a drifted pnpm-lock.yaml fails the build instead + // of silently resolving different versions than the repo was tested with + // (overriding buildCommand replaces Railpack's own install, which sets it). + // Chromium is installed explicitly because pnpm 10 does not run the + // playwright package's postinstall by default. NOT `--with-deps`: that + // apt-installs into the BUILD layer, which Railpack strips — the runtime + // libs come from RAILPACK_DEPLOY_APT_PACKAGES below, which is the layer + // that actually launches the browser. + buildCommand: + "pnpm install --frozen-lockfile && npx playwright install chromium", + }, deploy: { // Parity with agent/notion-mcp: restart the long-running channel host on // crash instead of leaving KiteBot silently offline. restartPolicyType: "ON_FAILURE", - restartPolicyMaxRetries: 5, + // Higher than agent/notion-mcp's 5: app/managed.ts runs a 60s watchdog + // that deliberately calls process.exit(1) when the managed session hits + // the terminal `error` state, exiting so the platform restarts the host. + // That design assumes the platform keeps restarting through a transient + // outage (e.g. the Intelligence gateway), so this cap is an + // outage-tolerance budget, not a crash-loop guard — it's raised to + // survive a realistic outage lasting more than a few restart cycles. + // Deliberately NOT ALWAYS: a genuine boot-time misconfiguration should + // still crash-loop visibly rather than being masked. + restartPolicyMaxRetries: 10, + // Railway's SIGTERM->SIGKILL grace period (RAILWAY_DEPLOYMENT_DRAINING_SECONDS) + // defaults to 0, which would give app/managed.ts's shutdown routine zero + // time to run: it budgets ~10s of graceful teardown (channels.stop(), + // bounded 5s; then closeServer()/closeBrowser() raced against a 5s + // timer) before calling process.exit(exitCode), and four docstrings in + // that file justify their bounds by reference to "the platform's grace + // period." 15s covers that ~10s worst case with headroom. + drainingSeconds: 15, }, env: { // brain: points at the agent service over private networking. The agent // pins PORT=8123, so ${{agent.PORT}} resolves to the port uvicorn binds. AGENT_URL: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", + // Chromium for inline chart/diagram rendering: install the browser into + // node_modules (0) so it ships with the app, and provide the shared libs + // in the deploy image (Railpack strips build-layer apt packages). + PLAYWRIGHT_BROWSERS_PATH: "0", + // Derived from playwright-core's OWN native-dependency table — the + // authoritative source, not this comment — at + // node_modules/playwright-core/lib/coreBundle.js (that version bundles + // registry/nativeDeps.js into this single minified file; search for + // "ubuntu22.04-x64" / "ubuntu24.04-x64" to find the `chromium: [...]` + // and `tools: [...]` arrays). Re-derive from there when bumping + // playwright-core, rather than hand-editing this string blind — that's + // exactly how it drifted before (see below). + // + // Naming — t64 vs. legacy: Ubuntu 24.04/Noble's 64-bit time_t transition + // renamed six of these packages with a "t64" suffix (libasound2, + // libatk-bridge2.0-0, libatk1.0-0, libatspi2.0-0, libcups2, + // libglib2.0-0), and Playwright's ubuntu24.04-x64 chromium list uses the + // new names while ubuntu22.04-x64 uses the old ones. We list ONLY the + // t64 names below, NOT both: apt-get install hard-fails the WHOLE + // command (nothing gets installed, not just that one package) if any + // name it's given can't be resolved, and the "t64" names don't exist at + // all on Jammy/22.04 — so listing both is only safe-on-Noble, not + // safe-on-either. We assume Railpack's deploy runtime is Ubuntu + // 24.04/Noble or newer (the current-generation base a build tool this + // recent would default to) — unverified from this repo. To check: exec + // into a deployed channel instance and run `cat /etc/os-release`, or + // `apt-cache policy ` for one of the six above to see whether the + // t64 or legacy name is the real package vs. a transitional/virtual one. + // If that shows Jammy/22.04 instead, swap these six back to the + // pre-t64 names (Playwright's ubuntu22.04-x64 list). + // + // Extras not in Playwright's chromium list for either distro: + // libfontconfig1/libfreetype6 ARE in Playwright's "tools" list for both + // distros (font rendering support for xvfb) — legitimate upstream deps, + // just filed under a different category, not actually extraneous. + // libx11-xcb1/libxrender1/libxshmfence1 were direct chromium deps on + // Playwright's now-dropped ubuntu20.04-x64 list — likely vestigial for + // the Chromium build this version of playwright-core bundles, but kept + // since an unnecessary package is harmless and a missing one breaks + // rendering at runtime. libexpat1 doesn't appear in the native-deps + // table for ANY distro there — origin unknown, kept for the same + // harmless-if-unused reason. + RAILPACK_DEPLOY_APT_PACKAGES: + "fonts-liberation fonts-noto-color-emoji fonts-unifont libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 libcairo2 libcups2t64 libdbus-1-3 libdrm2 libexpat1 libfontconfig1 libfreetype6 libgbm1 libglib2.0-0t64 libnspr4 libnss3 libpango-1.0-0 libwayland-client0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 libxrender1 libxshmfence1", // INTELLIGENCE_CHANNEL_NAME is intentionally NOT set here: app/managed.ts - // already defaults it to "kitebot", and leaving it unmanaged means a + // already defaults it to "kite-opentag", and leaving it unmanaged means a // deployer can set their own channel name in the Railway UI without a // later `config apply` clobbering it. Set it in the channel service's // Variables if your Intelligence channel is named something else. - // secrets (set in Railway UI): + // + // All three INTELLIGENCE_* connection vars below are deployer-supplied + // secrets/values (from your CopilotKit Intelligence project) — declared + // with preserve() so they're never hardcoded here, and `railway config + // apply` never clobbers what you set in the Railway UI (see README + // "Deploy to Railway"): + INTELLIGENCE_API_URL: preserve(), INTELLIGENCE_GATEWAY_WS_URL: preserve(), INTELLIGENCE_API_KEY: preserve(), - INTELLIGENCE_ORG_ID: preserve(), - INTELLIGENCE_PROJECT_ID: preserve(), - INTELLIGENCE_CHANNEL_ID: preserve(), + // CHANNEL_HTTP_TOKEN is intentionally NOT set: the channel host's HTTP + // routes are closed unless it is, and nothing here needs them (this + // service has no public domain and no healthcheck, and the managed + // channel activates over the gateway WebSocket). preserve() so a deployer + // who does open the surface isn't clobbered by a later `config apply`. + CHANNEL_HTTP_TOKEN: preserve(), }, }); diff --git a/README.md b/README.md index a817c8d..246469a 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,6 @@ OpenTag is a thin layer on top of a handful of CopilotKit packages. The `pnpm in | Package | When you need it | | --- | --- | | [`@copilotkit/channels-discord`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-discord) · [`-telegram`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-telegram) · [`-whatsapp`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-whatsapp) | Running on a platform other than Slack — one adapter per platform. | -| [`@copilotkit/channels-intelligence`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-intelligence) | Runs the bot over the CopilotKit Intelligence Realtime Gateway instead of holding platform tokens — see `app/managed.ts`. **Required for the recommended `pnpm channel` (Intelligence Gateway) mode**; omit it only if you run self-hosted mode exclusively. | **1. Create a Slack app.** At [api.slack.com/apps](https://api.slack.com/apps?new_app=1) → *From a manifest* → paste [`slack-app-manifest.yaml`](./slack-app-manifest.yaml). Install it, @@ -81,22 +80,25 @@ SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... # Intelligence Gateway mode — full list in .env.example: +INTELLIGENCE_API_URL=https://... INTELLIGENCE_GATEWAY_WS_URL=wss://... INTELLIGENCE_API_KEY=cpk-... -INTELLIGENCE_ORG_ID=org_... -INTELLIGENCE_PROJECT_ID=... -INTELLIGENCE_CHANNEL_ID=channel_... ``` **3. Run it:** ```bash pnpm install -pnpm runtime # the agent backend, on :8200 +``` + +```bash +pnpm runtime # terminal 1 — the agent backend, on :8200 +``` -pnpm channel # recommended — the bot over the Intelligence Gateway +```bash +pnpm channel # terminal 2 — recommended — the bot over the Intelligence Gateway # or -pnpm dev # alternative — the bot, self-hosted +pnpm dev # terminal 2 — alternative — the bot, self-hosted ``` **4. Talk to it.** @mention the bot in any channel thread: @@ -117,7 +119,7 @@ OpenTag is deliberately small and hackable: - **Copy `app/` to start your own bot.** It's the platform-agnostic bot (tools, components, the human-in-the-loop gate). `runtime.ts` is the agent backend: one CopilotKit `BuiltInAgent` (an LLM + optional MCP tools — no Python, no LangGraph), served over AG-UI. -- **One platform, or all of them.** `createBot` takes an array of adapters; set the secrets for +- **One platform, or all of them.** `createChannel` takes an array of adapters; set the secrets for whichever platform(s) you want and the bot starts an adapter for each. The full architecture, the file-by-file map, and every integration live in @@ -165,12 +167,18 @@ built from this one repo and wired together automatically over Railway's private | --- | --- | --- | | `agent` | the Python deep-research backend (`agent/`) | nixpacks, `uvicorn`, `/health` (root dir `agent/`) | | `notion-mcp` | the Notion MCP sidecar (`pnpm notion-mcp`) | Node | -| `channel` | the KiteBot channel host (`pnpm channel`) | Node | +| `channel` | the KiteBot channel host (`pnpm channel`) | `RAILPACK` (pinned), custom `buildCommand` — installs Chromium + the apt libs it needs for `render_chart`/`render_diagram` | `channel` reaches `agent` via `AGENT_URL`, and `agent` reaches `notion-mcp` via `NOTION_MCP_URL` — both wired with Railway reference variables in [`.railway/railway.ts`](./.railway/railway.ts), so you don't set them by hand. +> **Don't change the `channel` service's builder in the Railway UI.** The apt packages that give +> it a working Chromium (`RAILPACK_DEPLOY_APT_PACKAGES` in +> [`.railway/railway.ts`](./.railway/railway.ts)) are honored only by Railpack — switching to a +> different builder silently makes that a no-op and breaks `render_chart`/`render_diagram` at +> runtime with no build-time signal. + **Deploy via Infrastructure-as-Code (recommended):** ```bash @@ -193,20 +201,33 @@ railway config apply # from the repo root: provisions agent + notion-mcp service from `resources` in [`.railway/railway.ts`](./.railway/railway.ts) and delete the agent's `NOTION_MCP_URL` / `NOTION_MCP_AUTH_TOKEN` lines; the agent still runs (chat, UI, and — with `TAVILY_API_KEY` — web research). -- **`channel`** — `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, - `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID` (from your CopilotKit Intelligence - project + channel). +- **`channel`** — `INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY` + (from your CopilotKit Intelligence project). `CHANNEL_HTTP_TOKEN` is also declared here with + `preserve()`, but normally leave it **unset** — the channel host's HTTP routes are closed by + default and the managed channel activates over the gateway WebSocket, not HTTP, so it doesn't + need them. Only set it if you deliberately want to open those routes for server-to-server or + `curl` use. The inter-service URLs/ports are wired for you in [`.railway/railway.ts`](./.railway/railway.ts). Two values are left unmanaged by the IaC so a UI override survives `railway config apply`: the agent's `OPENAI_MODEL` (defaults to `gpt-5.5`) and the channel's `INTELLIGENCE_CHANNEL_NAME` -(defaults to `kitebot`). Set either in that service's *Variables* to change it — e.g. set -`INTELLIGENCE_CHANNEL_NAME` if your Intelligence channel isn't named `kitebot`. +(defaults to `kite-opentag`). Set either in that service's *Variables* to change it — e.g. set +`INTELLIGENCE_CHANNEL_NAME` if your Intelligence channel isn't named `kite-opentag`. Applying the config creates the services and their wiring; **KiteBot goes live only once the secrets are set and the `channel` service connects** — that's when your Intelligence dashboard flips *Waiting for runtime → live*. +> **Bind the connector before the first deploy — the `channel` host is fail-fast.** If +> activation settles with the channel not `online` — most commonly `setup_required`, meaning the +> channel name exists in your Intelligence project but no Slack connector is bound to it — the +> host logs the reason and **exits non-zero** rather than idling in a half-working state. +> Railway's `ON_FAILURE` policy then retries — see `restartPolicyMaxRetries` on the `channel` +> service in [`.railway/railway.ts`](./.railway/railway.ts) for the current budget (deliberately +> higher than the `agent`/`notion-mcp` services', since it's sized as an outage-tolerance budget +> rather than a crash-loop guard) — and once that's exhausted, leaves the service stopped. Finish +> the connector setup in the Intelligence dashboard, then **redeploy the `channel` service**. + > **Cold-start note (Notion):** the `agent` loads its Notion tools once, at startup. On a first > cold deploy the `agent` can finish booting before `notion-mcp` is accepting connections, in > which case Notion research is unavailable for that deployment — the agent logs a `WARNING` on diff --git a/app/__tests__/component-scan.test.ts b/app/__tests__/component-scan.test.ts new file mode 100644 index 0000000..1e9d31c --- /dev/null +++ b/app/__tests__/component-scan.test.ts @@ -0,0 +1,460 @@ +import { describe, it, expect } from "vitest"; +import { interactiveComponentNames } from "./component-scan.js"; + +describe("interactiveComponentNames — existing passing shapes", () => { + it("finds a component declared with `export function`", () => { + const src = ` +export function ConfirmWrite({ action }: Props) { + return ; +} +`; + expect(interactiveComponentNames(src)).toEqual(["ConfirmWrite"]); + }); + + it("finds a component declared with `export const` and an arrow", () => { + const src = ` +export const QuickReply = ({ label }: Props) => ( + +); +`; + expect(interactiveComponentNames(src)).toEqual(["QuickReply"]); + }); + + it("returns nothing for a file with no onClick", () => { + const src = ` +export function StatusCard({ heading }: Props) { + return
{heading}
; +} +`; + expect(interactiveComponentNames(src)).toEqual([]); + }); + + it("attributes an onClick to its own declaration, not a later sibling", () => { + const src = ` +export function IncidentCard({ id }: Props) { + return ; +} + +export function StatusCard({ heading }: Props) { + return
{heading}
; +} +`; + expect(interactiveComponentNames(src)).toEqual(["IncidentCard"]); + }); + + it("finds every interactive declaration in a file", () => { + const src = ` +export function CardA() { + return ; +} + +export const CardB = () => ; +`; + expect(interactiveComponentNames(src)).toEqual(["CardA", "CardB"]); + }); + + it("finds a component declared with `export default function`", () => { + const src = ` +export default function QuickReply({ label }: Props) { + return ; +} +`; + expect(interactiveComponentNames(src)).toEqual(["QuickReply"]); + }); + + it("finds a component declared with `export async function`", () => { + const src = ` +export async function QuickReply({ label }: Props) { + return ; +} +`; + expect(interactiveComponentNames(src)).toEqual(["QuickReply"]); + }); + + it("finds a component declared with `export default async function`", () => { + const src = ` +export default async function QuickReply({ label }: Props) { + return ; +} +`; + expect(interactiveComponentNames(src)).toEqual(["QuickReply"]); + }); + + it("attributes onClick to a bare declaration surfaced via `export { }`", () => { + const src = ` +function QuickReply({ label }: Props) { + return ; +} + +export { QuickReply }; +`; + expect(interactiveComponentNames(src)).toEqual(["QuickReply"]); + }); + + it("throws rather than silently dropping onClick in an un-exported module-scope helper (unexported, not merely positioned before the first export)", () => { + // The throw is driven by `helperRender` never being exported by any + // route (no `export` modifier, no `export { helperRender }`, no + // `export default helperRender;`) — its position in the file relative + // to `ComponentX`'s `export` is irrelevant to the implementation. + const src = ` +function helperRender() { + return ; +} + +export function ComponentX({ heading }: Props) { + return
{heading}
; +} +`; + expect(() => interactiveComponentNames(src, "helper-above-export.tsx")).toThrow( + /helper-above-export\.tsx/, + ); + }); + + it("does not flag a tool descriptor whose config happens to contain onClick=", () => { + const src = ` +export const someTool = defineChannelTool({ + name: "some_tool", + description: "posts a card", + async handler(props, { thread }) { + await thread.post( + + + + + , + ); + return "done"; + }, +}); +`; + expect(interactiveComponentNames(src)).toEqual([]); + }); + + it("ignores onClick= mentioned only inside a comment", () => { + const src = ` +export function StatusCard({ heading }: Props) { + // legacy version used onClick={...} here; now static + /* another note: onClick= isn't wired up anymore */ + return
{heading}
; +} +`; + expect(interactiveComponentNames(src)).toEqual([]); + }); + + it("does not blame a string constant sitting between two components", () => { + const src = ` +export function ComponentA({ label }: Props) { + return
{label}
; +} + +export const BTN_STYLE = "primary"; + +export default function ComponentB({ label }: Props) { + return ; +} +`; + expect(interactiveComponentNames(src)).toEqual(["ComponentB"]); + }); +}); + +// The nine distinct failure modes independently confirmed by a 7-agent +// review round against the old regex scanner. Each row below is the exact +// shape that used to break it — fails-open modes 1–6 used to return the +// wrong name (or nothing at all) instead of the real component; fails-closed +// modes 7–9 used to throw or demand registration when the correct answer was +// "this is fine" / "this isn't a component". The AST walk gets every one of +// these right by construction, not by another special case. +describe("interactiveComponentNames — the nine review-confirmed modes", () => { + it("mode 1: a type annotation on `export const` does not defeat recognition or leak the onClick backward", () => { + const src = ` +export function Prior() { + return
prior
; +} + +export const Card: FC = (props) => { + return ; +}; +`; + expect(interactiveComponentNames(src)).toEqual(["Card"]); + }); + + it("mode 2: a generic arrow component is recognized", () => { + const src = ` +export const Card = (p: P) => { + return ; +}; +`; + expect(interactiveComponentNames(src)).toEqual(["Card"]); + }); + + it("mode 3: a component wrapped in `memo(...)` is recognized", () => { + const src = ` +export const Card = memo(({ label }: Props) => { + return ; +}); +`; + expect(interactiveComponentNames(src)).toEqual(["Card"]); + }); + + it("mode 4: a preceding string-constant export does not swallow the next declaration", () => { + const src = ` +export const A = "x"; +export const Card = ({ label }: Props) => { + return ; +}; +`; + expect(interactiveComponentNames(src)).toEqual(["Card"]); + }); + + it("mode 5: a `//` inside a string literal does not blank a same-line onClick", () => { + const src = ` +export function Card({ label }: Props) { + return ( + + + + ); +} +`; + expect(interactiveComponentNames(src)).toEqual(["Card"]); + }); + + it("mode 6: `onClick = {...}` with spaces around `=` is still recognized", () => { + const src = ` +export function Card({ label }: Props) { + return ; +} +`; + expect(interactiveComponentNames(src)).toEqual(["Card"]); + }); + + it("mode 7: `export default Foo;` re-exporting an already-declared identifier does not throw", () => { + const src = ` +function Card({ label }: Props) { + return ; +} + +export default Card; +`; + expect(interactiveComponentNames(src)).toEqual(["Card"]); + }); + + it("mode 8: an exported lowercase factory function returning JSX is not demanded in the registry", () => { + const src = ` +export function makeCard() { + return ; +} +`; + expect(interactiveComponentNames(src)).toEqual([]); + }); + + it("mode 9: a template literal containing what looks like a function declaration does not trigger a bogus throw", () => { + const src = ` +export function Card({ id }: Props) { + const script = \` +function boot() { + console.log("boot"); +} +\`; + return ; +} +`; + expect(interactiveComponentNames(src)).toEqual(["Card"]); + }); +}); + +// Four review-confirmed attribution bugs found AFTER the regex->AST rewrite +// above (which fixed the nine modes in the previous describe block). Each of +// these was independently reproduced by execution. +describe("interactiveComponentNames — four post-rewrite attribution bugs", () => { + describe("finding 1: climbing must not stop at the nearest named declaration — it must climb to the nearest PLAUSIBLE COMPONENT ROOT", () => { + it("attributes onClick in a local `const` inside the component to the enclosing component, not the local", () => { + // THE BUG: `findEnclosingDecl` used to stop at the nearest named + // declaration of ANY kind, so this threw `onClick= found in + // un-exported declaration "actions"` and told the caller to + // `export function actions` — actively wrong remediation advice for + // perfectly ordinary code. + const src = ` +export function IncidentCard({ id }: Props) { + const actions = ; + return {actions}; +} +`; + expect(interactiveComponentNames(src)).toEqual(["IncidentCard"]); + }); + + it("attributes onClick inside `items.map(...)` to the enclosing component, not the local `rows`", () => { + // THE BUG: same stop-at-nearest failure, hitting the standard shape + // this codebase uses to render issue/page lists (see + // app/components/issue-list.tsx, app/components/page-list.tsx) — the + // next contributor to write this shape got a red CI with wrong advice. + const src = ` +export function IssueList({ items }: Props) { + const rows = items.map((i) => ); + return
{rows}
; +} +`; + expect(interactiveComponentNames(src)).toEqual(["IssueList"]); + }); + + it("attributes onClick inside a nested helper function to the enclosing component, not the helper", () => { + const src = ` +export function PageList({ pages }: Props) { + function renderRow(p) { + return ; + } + return
{pages.map(renderRow)}
; +} +`; + expect(interactiveComponentNames(src)).toEqual(["PageList"]); + }); + + it("attributes onClick inside a class component's render() to the class, not the un-attributable method", () => { + // THE BUG: `render()` has no matching ancestor in the old kind list + // (function declaration / variable declaration only), so this threw + // "could not be attributed to any named declaration" for an ordinary + // class component. + const src = ` +export class LegacyCard extends React.Component { + render() { + return ; + } +} +`; + expect(interactiveComponentNames(src)).toEqual(["LegacyCard"]); + }); + + it("still throws when there truly is no enclosing declaration at all (a bare JSX expression statement)", () => { + // The fix widens the stop condition; it must not widen it into never + // throwing. A bare `; +`; + expect(() => interactiveComponentNames(src, "bare-jsx.tsx")).toThrow( + /could not be attributed to any named declaration/, + ); + }); + }); + + it("finding 2: an exported object literal (not function-shaped) is not demanded in the registry, even though its name is capitalized", () => { + // THE BUG: `Cards` passes the capitalization filter (it's a plain + // object, not a component), so this used to return ["Cards"] and the + // guard demanded a non-component object be added to MANAGED_COMPONENTS. + const src = ` +export const Cards = { + Incident: () => , +}; +`; + expect(interactiveComponentNames(src)).toEqual([]); + }); + + it("finding 2b: a component wrapped in memo(...) still counts as function-shaped (does not regress mode 3)", () => { + const src = ` +export const Card = memo(({ label }: Props) => { + return ; +}); +`; + expect(interactiveComponentNames(src)).toEqual(["Card"]); + }); + + it("finding 3: `export { card as Card }` attributes to and emits the EXPORTED alias, not the local name", () => { + // THE BUG: capitalization was tested against the local name "card" + // (lowercase), so this component was silently dropped from the + // expectation set entirely — a genuinely exported interactive component + // vanishing is exactly the fail-open mode this guard exists to prevent. + // A second bug: even if capitalization passed, the emitted name was the + // local identifier ("card"), which can't reconcile against + // MANAGED_COMPONENTS (keyed by the exported name "Card"). + const src = ` +function card({ label }: Props) { + return ; +} + +export { card as Card }; +`; + expect(interactiveComponentNames(src)).toEqual(["Card"]); + }); + + it("the `seen` dedup guard collapses two onClicks in the same component to a single name", () => { + // Both real components in this app (ConfirmWrite, IncidentCard) have + // exactly this shape — two buttons, each with its own onClick. Without + // this test, removing the `seen` guard entirely still passes the whole + // suite while double-reporting against real source. + const src = ` +export function DoubleButtonCard({ label }: Props) { + return ( + + + + + ); +} +`; + expect(interactiveComponentNames(src)).toEqual(["DoubleButtonCard"]); + }); + + describe("script kind is chosen from the label's file extension, not hardcoded to TSX", () => { + it("does not misread a non-trailing-comma generic arrow as JSX when the label says .ts", () => { + // THE BUG THIS PROVES FIXED: with `ts.ScriptKind.TSX` hardcoded + // (ignoring `label`), the parser reads the non-trailing-comma generic + // arrow `(x: T): T => x` as an OPENING JSX TAG — and because + // `ts.createSourceFile` never throws on malformed syntax, it just + // keeps consuming: the entire rest of this fixture, including the + // real `; +} +`; + expect(interactiveComponentNames(src, "helpers.ts")).toEqual([]); + }); + + it("still parses ordinary JSX correctly when the label says .tsx", () => { + const src = ` +export function Card({ label }: Props) { + return ; +} +`; + expect(interactiveComponentNames(src, "card.tsx")).toEqual(["Card"]); + }); + }); + + it("does not detect an onClick hidden inside a spread attribute (documented limitation, not a silent miss)", () => { + // `