From f0e12c851971a21f7aadf0835b016bfaf2067314 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:34:02 -0500 Subject: [PATCH 01/15] Add managed Slack setup with an independent own-app path (#15679) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Slack connects a person to an agent through an installed app and a verified identity. > - Independent installations must remain usable without Paperclip Cloud or manager credentials. > - Approved Cloud deployments can use Slack manager grants to create and install each agent's app. > - Both paths should share credential storage, identity checks, recovery, and the existing runtime. > - This pull request adds gated managed setup and keeps independent app setup under Advanced. > - It also makes ongoing connection settings and personal test messages easier to use. ## Linked Issues or Issue Description **Subsystem affected** Slack setup, connection settings, tenant credential storage, and the Cloud connector handoff. **Problem or motivation** Approved deployments need an installation flow without pasted configuration tokens. Open-source users must retain their independent Slack app flow. A connection manager also needs a simple way to check a bot from Settings. **Proposed solution** Use the managed path when the instance is configured to support it. Choose agent and Connect are the two visible stops. Continue starts authorization or installation. Use your own app remains under Advanced before creation. Save personal manager grants by company, Paperclip user, Slack user, workspace, and manager app. Reuse only verified identities for test messages. Unknown identities must complete a private Slack connection challenge before linking and receiving the test DM. **Alternatives considered** A shared bot would change each agent's Slack identity. Requiring Slack CLI or a hosted service for self-hosted users would remove independent installation. **Roadmap alignment** This maintainer-requested change improves Connected Apps and experimental Agent Chat. It adds provisioning and management controls to the existing Slack runtime. **Additional context** The companion Cloud broker profile is a separate deployment prerequisite. Managed setup remains gated. Existing-app adoption is deferred. ## What Changed - Extract Slack registration, provider requests, completion, customer-owned setup, managed setup, and personal manager grants into focused services. Preserve the compatibility facade and independent removal cleanup. - Add company-scoped manager grants and a registration reference through an additive, idempotent migration after current master. Keep runtime credentials separate from app-registration secrets. - Add authenticated manager authorization, sealed handoff, serialized refresh and revocation, app creation, managed installation, and installer-bound OAuth fallback. - Preserve creation intent, block automatic uncertain recreation, save credentials before downstream checks, and validate app, workspace, bot, scopes, and installer identity. - Add the two-stop production wizard and ordered stories. Keep the own-app, manual, existing-app, and legacy-draft paths. - Simplify Settings. Open Slack configuration targets the saved app. The test-message action has its own row. Remove the old manual-avatar section, channel-message section, and broken permissions link. - Add paginated account discovery and a private connection challenge for unknown accounts. Expired invitations stop waiting and offer a new private invitation. Save send receipts before provider dispatch and preserve confirmed delivery when activity logging fails. - Reinstall the saved managed app on explicit reconnect, replace revoked credentials, and resume completion. Persist reconnect intent and duplicate-request receipts without creating another app. - Update provider browser tests, recovery tests, secret redaction, setup documentation, and the agent prompt. Preserve current master's durable verification scheduling. ## Verification - Full workspace typecheck and repository build passed on `56d389d47a`. The subsequent reconnect retry fix in `288364eada` also passed UI typecheck and UI build. - 114 focused Slack registration cases and 13 production managed-wizard cases passed, including fresh-token reconnect, retry after installation failure, and duplicate reconnect requests. - All nine affected Slack browser cases passed against the production UI and a disposable fixture server. They cover managed setup, automatic setup, own-app selection, refresh, recovery, mobile behavior, and connection settings. - Eight focused real-database test-message cases passed. They cover a private ownership challenge, saved grant reuse, account pagination, revoked and conflicting links, workspace mismatch, disabled DMs, revoked membership, audit failure, ambiguous dispatch, and secret canaries. - UI token gates and the production Storybook build passed. - Greptile reviewed exact head `288364eada5d9c114fad991ff634612639d06672` at 5/5 with no actionable findings or unresolved review threads. - The full repository test suite and current-head CI are running. Their final results will replace this line before merge. - Earlier live feature previews created two bots in the authorized Paperclip Slack workspace and reused one personal manager grant. Local managed Continue created and installed a bot without a configuration token or a separate Add to Slack action. Avatars, welcome DMs, signed callbacks, and direct replies were observed. A Settings test DM also arrived in the signed-in user's Slack account. - Earlier staging thread qualification required manual publication recovery for one bot. Current-head live qualification remains required before enabling the production default. Fixture results do not establish Slack approval behavior in other workspaces. ## Risks - Personal manager tokens can create apps. They stay in scoped vault entries. The manager client secret stays at the broker. Company, actor/session, origin, and app identity checks apply throughout setup. - The additive migration must also work on qualification instances that applied earlier branch migrations. Table, column, index, and constraint creation is idempotent. - Creation and message timeouts can leave uncertain outcomes. Saved request IDs prevent automatic duplicate dispatch. - Unknown Slack identities must receive and complete their private challenge while signed in. Account selection alone does not grant Paperclip permissions. Existing revoked or conflicting links are not overwritten. - A development-only manager transport uses an explicitly configured private credential file for local qualification. Production ignores this transport; no manager credentials are bundled with open source. - Managed rollout requires Slack approval, the companion broker deployment, configured secrets, and final live qualification. Removing a connection does not delete its Slack app. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, code execution, and browser tools. The session does not expose the exact serving model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/DATABASE.md | 12 + doc/DEVELOPING.md | 9 + doc/connections/slack-setup.md | 162 ++++ .../src/migrations/0335_absurd_bulldozer.sql | 39 + ...{0330_snapshot.json => 0335_snapshot.json} | 821 +++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 7 + .../src/schema/chat_slack_manager_grants.ts | 26 + .../db/src/schema/chat_slack_registrations.ts | 3 + packages/db/src/schema/index.ts | 2 + packages/shared/src/slack-app-manifest.ts | 45 +- packages/shared/src/types/chat-channels.ts | 6 +- .../shared/src/validators/chat-channels.ts | 5 +- .../chat-channels.integration.test.ts | 428 ++++++++- server/src/__tests__/http-log-policy.test.ts | 2 +- server/src/__tests__/redact-sensitive.test.ts | 5 + .../slack-development-manager.test.ts | 73 ++ server/src/middleware/http-log-policy.ts | 2 +- server/src/middleware/redact-sensitive.ts | 7 + server/src/routes/chat-channels.ts | 50 +- server/src/routes/openapi.ts | 40 +- server/src/services/chat-channels.ts | 94 +- .../chat-slack-registration-cleanup.ts | 5 +- .../src/services/chat-slack-registration.ts | 534 +----------- .../connectors/slack/setup/completion.ts | 164 ++++ .../connectors/slack/setup/customer-owned.ts | 139 +++ .../slack/setup/development-manager.ts | 73 ++ .../connectors/slack/setup/managed.ts | 81 ++ .../connectors/slack/setup/manager-grants.ts | 269 ++++++ .../connectors/slack/setup/provider-client.ts | 58 ++ .../connectors/slack/setup/registration.ts | 136 +++ .../connectors/slack/setup/service.ts | 151 ++++ .../services/connectors/slack/test-message.ts | 134 +++ .../paperclip-cloud-connector.test.ts | 17 +- .../src/services/paperclip-cloud-connector.ts | 19 +- tests/e2e/chat-adapters-ui-providers.spec.ts | 128 ++- tests/e2e/chat-adapters-ui.shared.ts | 20 +- ui/src/api/chatEndpoints.ts | 11 +- .../apps/chat/ChatEndpoint.clipboard.test.tsx | 22 +- ui/src/pages/apps/chat/ChatEndpointDetail.tsx | 52 +- ui/src/pages/apps/chat/ChatEndpointSetup.tsx | 107 ++- .../pages/apps/chat/SlackAutomaticSetup.tsx | 184 +--- ui/src/pages/apps/chat/SlackSetupPrompt.tsx | 8 +- .../apps/chat/SlackTestMessageButton.tsx | 88 ++ ui/src/pages/apps/chat/SlackToolSettings.tsx | 12 +- .../pages/apps/chat/chat-ui-contract.test.ts | 3 +- .../chat/slack/ManagedSlackSetup.test.tsx | 88 ++ .../apps/chat/slack/ManagedSlackSetup.tsx | 92 ++ .../apps/chat/slack/OwnSlackAppSetup.tsx | 185 ++++ ui/src/pages/apps/chat/slack/SlackSetup.tsx | 11 + .../pages/apps/chat/slack/setup-state.test.ts | 30 + ui/src/pages/apps/chat/slack/setup-state.ts | 30 + ui/storybook/fixtures/SlackSetupFixture.tsx | 33 +- .../chat-management.stories.tsx | 54 +- .../stories/slack-managed-setup.stories.tsx | 104 +++ 54 files changed, 3966 insertions(+), 914 deletions(-) create mode 100644 doc/connections/slack-setup.md create mode 100644 packages/db/src/migrations/0335_absurd_bulldozer.sql rename packages/db/src/migrations/meta/{0330_snapshot.json => 0335_snapshot.json} (98%) create mode 100644 packages/db/src/schema/chat_slack_manager_grants.ts create mode 100644 server/src/__tests__/slack-development-manager.test.ts create mode 100644 server/src/services/connectors/slack/setup/completion.ts create mode 100644 server/src/services/connectors/slack/setup/customer-owned.ts create mode 100644 server/src/services/connectors/slack/setup/development-manager.ts create mode 100644 server/src/services/connectors/slack/setup/managed.ts create mode 100644 server/src/services/connectors/slack/setup/manager-grants.ts create mode 100644 server/src/services/connectors/slack/setup/provider-client.ts create mode 100644 server/src/services/connectors/slack/setup/registration.ts create mode 100644 server/src/services/connectors/slack/setup/service.ts create mode 100644 server/src/services/connectors/slack/test-message.ts create mode 100644 ui/src/pages/apps/chat/SlackTestMessageButton.tsx create mode 100644 ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx create mode 100644 ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx create mode 100644 ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx create mode 100644 ui/src/pages/apps/chat/slack/SlackSetup.tsx create mode 100644 ui/src/pages/apps/chat/slack/setup-state.test.ts create mode 100644 ui/src/pages/apps/chat/slack/setup-state.ts create mode 100644 ui/storybook/stories/slack-managed-setup.stories.tsx diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 2177689e1b9..5862fe40a55 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -701,6 +701,18 @@ this non-secret binding for this namespace; Slack bot installation does not use PKCE. Removal and manual recovery invalidate outstanding attempts under the same credential-mutation lease used by configuration. +### Slack manager workspace grants + +`chat_slack_manager_grants` stores a personal workspace authorization, scoped by +company, Paperclip user, Slack user, workspace and manager app. Access/refresh +credentials are references to user-scoped vault entries, never plaintext columns. +Revision fencing protects reauthorization from concurrent revocation. Per-method +rate-limit deadlines retain Slack's Retry-After across setup retries. + +`chat_slack_registrations.manager_grant_id` is optional, with a composite company +foreign key. Existing registrations remain customer owned with a null reference. +Deleting one endpoint removes its registration credentials and attempts, but does +not revoke grants used by other bots. See [Slack setup](connections/slack-setup.md). ## Transaction-aware delivery work signals diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 233c2e5cf66..575b1430fa8 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2239,3 +2239,12 @@ Chromium. The test starts its own loopback Vite server and mocks API responses; it needs no running Paperclip instance or provider credentials. The same spec lives in the default `test:e2e` discovery tree, so the existing Chrome CI shards run it on pull requests. + +### Slack provisioning paths + +Self-hosted development continues to use independent Slack app setup. Managed +Slack installation is a disabled-by-default Cloud feature that also requires the +companion broker profile and Slack approval. Do not add manager client credentials +to a local `.env` or an open-source deployment. See +[Slack setup and Cloud qualification](connections/slack-setup.md) for the gates, +recovery behavior, and production-component stories. diff --git a/doc/connections/slack-setup.md b/doc/connections/slack-setup.md new file mode 100644 index 00000000000..78abb8ef67f --- /dev/null +++ b/doc/connections/slack-setup.md @@ -0,0 +1,162 @@ +# Slack app ownership and setup + +Slack has one chat runtime and two provisioning paths. A saved connection's +`slackSetupMethod` selects provisioning; it does not change tools, scopes, +conversation routing, people permissions, or channel access. + +| Method | App creation and installation | Default | +| --- | --- | --- | +| `managed` | A personal Slack manager grant creates a child app; managed installation supplies its bot token. Slack consent/admin approval remains a fallback. | New drafts on qualified Cloud deployments only | +| `automatic` | A temporary app configuration access token creates a customer-owned app; the user approves its OAuth installation. | New self-hosted drafts and Cloud without managed availability | +| `manual` | User creates an app from the canonical manifest and enters credentials. | Legacy drafts with no saved method | +| `existing` | User supplies credentials for an existing app. | Explicit selection | + +Existing drafts and active bots retain their method. Enabling managed setup never +adopts existing apps. Choose agent offers **Use your own app** under **Advanced**, below Slash command, +when the instance supports managed setup and creation has not started. The own-app flow retains **Create manually** and **Use an existing +app** under Advanced. After creation dispatch, a managed draft cannot switch paths. + +Managed setup has two visible stops: Choose agent and Connect. **Continue** starts +manager authorization or installation in the saved workspace automatically. Multiple +workspaces still require a selection. Recovery actions remain explicit; opening +a failed or uncertain registration never retries creation automatically. +Creation, installation, message waiting, and success use the same production +components and server evidence as the own-app flow. A click or OAuth return does +not constitute successful configuration. + +## Cloud deployment prerequisites + +Managed setup is disabled by default. Qualification requires all of: + +- The instance chat-connector rollout gate and connection-management permission. +- Verified Cloud runtime identity, connector broker signing/sealing configuration, + and valid independently configured public board and webhook HTTPS origins. +- `PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED=true` on the approved tenant deployment. +- The companion Cloud broker deployment with the explicitly enabled `slack.manager` + profile, approved Slack manager app, token rotation, and its client secret in + the broker's secret store. No manager client secret belongs in the tenant. + +The broker must advertise its manager app identity as well as the enabled profile. +Previously saved grants from a different manager app are not reused. A broker +without this capability leaves managed setup unavailable. Do not enable the tenant +gate until real Slack qualification has passed; fixture coverage does not prove +Marketplace eligibility or a workspace's approval policy. + +Self-hosted instances do not need a Paperclip account, broker enrollment, manager +credentials, or hosted-service calls. They continue using the own-app flow. + +## Personal workspace authorization + +Manager grants belong to a company **and one Paperclip user**, and bind a verified +Slack user, workspace, and manager app. Another company member must authorize their +own account, even for the same workspace. Access and rotating refresh tokens are +user-scoped vault entries. Refreshes serialize across connections, persist the new +refresh token, and require fresh authorization after an ambiguous exchange. + +Revoking a grant requests provider revocation, removes its local credentials, invalidates pending manager +authorization, and prevents future provisioning with it. The local activity records whether provider revocation succeeded. Provider errors never restore local access. It does not uninstall +apps or disable already-installed bots. Removing a bot deletes endpoint-specific +secrets and pending setup attempts; a reusable personal manager grant survives. +Provider-side app removal remains explicit through the saved Slack management URL. + +## Recovery + +- A dispatched creation with an unknown result is **uncertain**. Never automatically + create again. The user must inspect Slack and explicitly confirm that no app was + created before a new request identity is accepted. +- Denied or pending installation retains the app. Resume with the same grant or + complete the child app's state-bound OAuth approval; the installer must match the + grant's Slack identity before personal linking. +- Returned bot credentials are vaulted before inventory or runtime configuration. + Retry connecting reuses those credentials after network or inventory failures. + Rejected or under-scoped bot credentials instead reinstall the saved app; they + never trigger another app creation. +- Manifest updates finish before managed installation. Changes requiring updated + permissions therefore pass through installation again before activation. +- Expired or revoked manager grants require reconnection. Losing managed + availability pauses setup; it never changes the saved ownership method. +- Slack rate limits are grouped by manager/workspace/method. Provider `Retry-After` + is persisted across matching grants in the company, survives restarts, and blocks + early retries. Other tenant instances must observe Slack's limits independently; + no background loop retries app creation. + +All OAuth attempts bind company, actor/session, endpoint, registration revision, +and configured origins, expire after ten minutes, and are claimed once before +exchange. HTTP diagnostics redact manager tokens, refresh tokens, codes, and sealed +handoffs. Local activity records contain only IDs and safe outcome codes. This adds +no first-party telemetry. + +## Code and verification + +Server provisioning lives in `server/src/services/connectors/slack/setup/`; +`chat-slack-registration.ts` remains the compatibility facade. Shared completion +owns credential binding, personal linking, welcome messages, and verification. +Removal cleanup stays independently callable. Slack wizard components and stage +selection live in `ui/src/pages/apps/chat/slack/`. + +Production Storybook journeys: **Connections / Slack / Managed setup** and +**Connections / Slack / Automatic setup** (the persisted own-app automatic path). +Server integration cases cover personal grants, duplicate requests, uncertain +creation, approval fallback, refresh, revocation, removal, saved-credential recovery, +rate limits, and secret canaries. Browser cases are in +`tests/e2e/chat-adapters-ui-providers.spec.ts`; companion broker tests cover manager +user OAuth and the authenticated tenant handoff. + +Before enabling a deployment, create two test agents in the authorized workspace. +The second must reuse the personal grant without another configuration token or +manager authorization. Verify avatars, identity linking, signed inbound events, +and same-thread replies. Record real approval behavior separately from fixtures. + +### Managed setup during broker outages + +Cloud deployments configured for managed Slack keep the managed default when the broker profile is temporarily unavailable. New drafts remain on **Connect** and offer **Retry**. **Use your own app** remains available under **Advanced** on Choose agent before creation. Discovery loading or failure never selects the configuration-token page. Self-hosted instances without managed configuration still use their independent own-app flow; saved draft methods do not change automatically. Provisioning continues to require current broker availability. + +### Local managed-bot qualification + +Maintainers with an approved Slack manager app can exercise the real managed +journey from an authenticated local test drive exposed through HTTPS (including +TryCloudflare). This development transport talks directly to Slack and reuses +the production manager-grant vault, state claims, provisioning, completion, and +runtime. It does not exercise the Cloud broker's sealed handoff; qualify that +separately on staging afterward. + +Set `NODE_ENV=development`, `PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED=true`, and +`PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG` to an absolute path outside the +checkout containing a mode-0600 JSON file with `appId`, `clientId`, and +`clientSecret`. Use only an authorized qualification manager app. No credentials +are shipped with Paperclip. Production ignores this development configuration, +and Cloud runtime identity continues to select the Cloud broker. + +Configure the local instance for authenticated public access, with its public +URL and chat webhook URL pointing to the HTTPS tunnel. Add the exact public URL +plus `/api/chat-slack/managed/oauth/callback` to the manager app's OAuth redirect +URLs, preserving any existing redirects. Start a **new** Slack setup: saved +own-app drafts retain their selected method. Authorize the manager app normally; +do not copy user grants from another instance. The second bot should reuse the +same local user's vaulted grant. A changed tunnel requires updating public +origins and the manager redirect before starting fresh authorization. + +Remove the temporary redirect when retiring the test drive and securely remove +its development credential file. Removing a bot does not revoke a manager grant +used by other bots. + +### Test messages from Settings + +**Send me a test message** sends a DM to the signed-in connection manager, +using their linked identity in this workspace. If this bot is not linked yet, +Paperclip can reuse that user's verified manager grant or another bot's linked +identity in the same company and workspace. Otherwise, the user selects their +Slack account and receives a private connection invitation in Slack. They must +open that invitation, sign in, and confirm before Paperclip links the account and +sends the test DM. The account picker loads further workspace pages on request. Existing revoked or conflicting identity links are not overwritten. +Direct messages must be enabled, and the user must be an active company operator. + +The action records a durable request before sending. Repeating the same request +returns its saved result; an uncertain delivery is never automatically resent. +**Open Slack configuration** opens the saved app's OAuth & Permissions page. +Legacy connections without an app ID open Slack's app list instead. + +An explicit managed reconnect reinstalls the saved app and verifies the replacement +bot credentials. Its request ID is saved before dispatch, so a duplicate completed +request does not reinstall again. Credential-configuration recovery still resumes +the saved token after a downstream failure. No reconnect creates another Slack app. diff --git a/packages/db/src/migrations/0335_absurd_bulldozer.sql b/packages/db/src/migrations/0335_absurd_bulldozer.sql new file mode 100644 index 00000000000..56224dbe057 --- /dev/null +++ b/packages/db/src/migrations/0335_absurd_bulldozer.sql @@ -0,0 +1,39 @@ +CREATE TABLE IF NOT EXISTS "chat_slack_manager_grants" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "user_id" text NOT NULL, + "slack_user_id" text NOT NULL, + "workspace_id" text NOT NULL, + "workspace_name" text NOT NULL, + "manager_app_id" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "access_secret_id" uuid, + "refresh_secret_id" uuid, + "expires_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "chat_slack_manager_grants_status_check" CHECK ("chat_slack_manager_grants"."status" in ('active', 'revoked', 'reauthorize')) +); + +--> statement-breakpoint + +ALTER TABLE "chat_slack_registrations" ADD COLUMN IF NOT EXISTS "manager_grant_id" uuid; +--> statement-breakpoint +DO $$ BEGIN +ALTER TABLE "chat_slack_manager_grants" ADD CONSTRAINT "chat_slack_manager_grants_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; +--> statement-breakpoint + +CREATE UNIQUE INDEX IF NOT EXISTS "chat_slack_manager_grants_company_id_idx" ON "chat_slack_manager_grants" USING btree ("company_id","id"); +--> statement-breakpoint + +CREATE UNIQUE INDEX IF NOT EXISTS "chat_slack_manager_grants_owner_idx" ON "chat_slack_manager_grants" USING btree ("company_id","user_id","manager_app_id","workspace_id"); +--> statement-breakpoint +DO $$ BEGIN +ALTER TABLE "chat_slack_registrations" ADD CONSTRAINT "chat_slack_registrations_company_id_manager_grant_id_chat_slack_manager_grants_company_id_id_fk" FOREIGN KEY ("company_id","manager_grant_id") REFERENCES "public"."chat_slack_manager_grants"("company_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; +--> statement-breakpoint +ALTER TABLE "chat_slack_manager_grants" ADD COLUMN IF NOT EXISTS "revision" integer DEFAULT 0 NOT NULL; +--> statement-breakpoint + +ALTER TABLE "chat_slack_manager_grants" ADD COLUMN IF NOT EXISTS "rate_limits" jsonb DEFAULT '{}'::jsonb NOT NULL; diff --git a/packages/db/src/migrations/meta/0330_snapshot.json b/packages/db/src/migrations/meta/0335_snapshot.json similarity index 98% rename from packages/db/src/migrations/meta/0330_snapshot.json rename to packages/db/src/migrations/meta/0335_snapshot.json index 460bb97a1cf..b348334bbd3 100644 --- a/packages/db/src/migrations/meta/0330_snapshot.json +++ b/packages/db/src/migrations/meta/0335_snapshot.json @@ -1,6 +1,6 @@ { - "id": "0a93bc99-bf0c-4ea2-ab27-6043b6746cd5", - "prevId": "76861d12-d9a9-4f5f-bcbc-0fb06c0addbc", + "id": "18c156f2-8344-4282-98b7-f702f0035c81", + "prevId": "39d82f2c-8fd5-4606-84df-f51880dbd6a7", "version": "7", "dialect": "postgresql", "tables": { @@ -416,6 +416,12 @@ "primaryKey": false, "notNull": false }, + "fast_response_request_id": { + "name": "fast_response_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "agent_id": { "name": "agent_id", "type": "uuid", @@ -462,6 +468,27 @@ } }, "indexes": { + "budget_reservations_fast_response_idx": { + "name": "budget_reservations_fast_response_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fast_response_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, "budget_reservations_decision_idx": { "name": "budget_reservations_decision_idx", "columns": [ @@ -580,7 +607,7 @@ "checkConstraints": { "budget_reservations_source_check": { "name": "budget_reservations_source_check", - "value": "(\"budget_reservations\".\"run_id\" is not null)::int + (\"budget_reservations\".\"decision_invocation_id\" is not null)::int = 1" + "value": "(\"budget_reservations\".\"run_id\" is not null)::int + (\"budget_reservations\".\"decision_invocation_id\" is not null)::int + (\"budget_reservations\".\"fast_response_request_id\" is not null)::int = 1" }, "budget_reservations_state_check": { "name": "budget_reservations_state_check", @@ -3737,6 +3764,28 @@ "method": "btree", "with": {} }, + "agent_wakeup_requests_question_response_history_idx": { + "name": "agent_wakeup_requests_question_response_history_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'question-response:%'", + "concurrently": true, + "method": "btree", + "with": {} + }, "agent_wakeup_requests_connection_intent_delivery_idempotency_uq": { "name": "agent_wakeup_requests_connection_intent_delivery_idempotency_uq", "columns": [ @@ -12384,6 +12433,189 @@ }, "isRLSEnabled": false }, + "public.chat_slack_manager_grants": { + "name": "chat_slack_manager_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_app_id": { + "name": "manager_app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "access_secret_id": { + "name": "access_secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "refresh_secret_id": { + "name": "refresh_secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rate_limits": { + "name": "rate_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_slack_manager_grants_company_id_idx": { + "name": "chat_slack_manager_grants_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_slack_manager_grants_owner_idx": { + "name": "chat_slack_manager_grants_owner_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "manager_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_slack_manager_grants_company_id_companies_id_fk": { + "name": "chat_slack_manager_grants_company_id_companies_id_fk", + "tableFrom": "chat_slack_manager_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chat_slack_manager_grants_status_check": { + "name": "chat_slack_manager_grants_status_check", + "value": "\"chat_slack_manager_grants\".\"status\" in ('active', 'revoked', 'reauthorize')" + } + }, + "isRLSEnabled": false + }, "public.chat_slack_registrations": { "name": "chat_slack_registrations", "schema": "", @@ -12400,6 +12632,12 @@ "primaryKey": false, "notNull": true }, + "manager_grant_id": { + "name": "manager_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "request_id": { "name": "request_id", "type": "uuid", @@ -12490,6 +12728,21 @@ }, "indexes": {}, "foreignKeys": { + "chat_slack_registrations_company_id_manager_grant_id_chat_slack_manager_grants_company_id_id_fk": { + "name": "chat_slack_registrations_company_id_manager_grant_id_chat_slack_manager_grants_company_id_id_fk", + "tableFrom": "chat_slack_registrations", + "tableTo": "chat_slack_manager_grants", + "columnsFrom": [ + "company_id", + "manager_grant_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, "chat_slack_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "chat_slack_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "chat_slack_registrations", @@ -16725,6 +16978,19 @@ "primaryKey": false, "notNull": false }, + "package_mode": { + "name": "package_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skills'" + }, + "repository_package": { + "name": "repository_package", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "excluded_folders": { "name": "excluded_folders", "type": "jsonb", @@ -17052,6 +17318,87 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_skill_repository_snapshots": { + "name": "company_skill_repository_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_inventory": { + "name": "file_inventory", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_skill_repository_snapshots_content_idx": { + "name": "company_skill_repository_snapshots_content_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_repository_snapshots_company_id_companies_id_fk": { + "name": "company_skill_repository_snapshots_company_id_companies_id_fk", + "tableFrom": "company_skill_repository_snapshots", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.company_skill_stars": { "name": "company_skill_stars", "schema": "", @@ -17906,6 +18253,18 @@ "primaryKey": false, "notNull": true }, + "repository_snapshot_id": { + "name": "repository_snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_skill_path": { + "name": "repository_skill_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, "revision_number": { "name": "revision_number", "type": "integer", @@ -18062,6 +18421,19 @@ "onDelete": "cascade", "onUpdate": "no action" }, + "company_skill_versions_repository_snapshot_id_company_skill_repository_snapshots_id_fk": { + "name": "company_skill_versions_repository_snapshot_id_company_skill_repository_snapshots_id_fk", + "tableFrom": "company_skill_versions", + "tableTo": "company_skill_repository_snapshots", + "columnsFrom": [ + "repository_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, "company_skill_versions_author_agent_id_agents_id_fk": { "name": "company_skill_versions_author_agent_id_agents_id_fk", "tableFrom": "company_skill_versions", @@ -19576,6 +19948,34 @@ "method": "btree", "with": {} }, + "cost_events_data_quality_idx": { + "name": "cost_events_data_quality_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cost_events\".\"billing_type\" = 'unknown' or (\"cost_events\".\"cost_status\" = 'unpriced' and \"cost_events\".\"billing_type\" <> 'subscription_included')", + "concurrently": false, + "method": "btree", + "with": {} + }, "cost_events_company_occurred_idx": { "name": "cost_events_company_occurred_idx", "columns": [ @@ -19826,7 +20226,7 @@ "checkConstraints": { "cost_events_usage_kind_check": { "name": "cost_events_usage_kind_check", - "value": "\"cost_events\".\"usage_kind\" = 'decision' or (\"cost_events\".\"usage_kind\" = 'agent' and \"cost_events\".\"agent_id\" is not null)" + "value": "\"cost_events\".\"usage_kind\" in ('decision', 'fast_response') or (\"cost_events\".\"usage_kind\" = 'agent' and \"cost_events\".\"agent_id\" is not null)" }, "cost_events_nonnegative_amounts": { "name": "cost_events_nonnegative_amounts", @@ -27491,6 +27891,378 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_fast_responses": { + "name": "company_fast_responses", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_sponsored": { + "name": "allow_sponsored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "company_fast_responses_company_id_companies_id_fk": { + "name": "company_fast_responses_company_id_companies_id_fk", + "tableFrom": "company_fast_responses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "company_fast_responses_configured": { + "name": "company_fast_responses_configured", + "value": "(\"company_fast_responses\".\"connection_id\" is null) = (\"company_fast_responses\".\"grant_id\" is null) and (not \"company_fast_responses\".\"enabled\" or (\"company_fast_responses\".\"connection_id\" is not null and \"company_fast_responses\".\"model\" is not null))" + } + }, + "isRLSEnabled": false + }, + "public.fast_response_requests": { + "name": "fast_response_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_comment_id": { + "name": "source_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sponsored": { + "name": "sponsored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_generation": { + "name": "session_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "publication_status": { + "name": "publication_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment_id": { + "name": "comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "fast_response_requests_source_idx": { + "name": "fast_response_requests_source_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_response_requests_history_idx": { + "name": "fast_response_requests_history_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "accepted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_response_requests_work_idx": { + "name": "fast_response_requests_work_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"fast_response_requests\".\"status\" in ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_response_requests_company_id_companies_id_fk": { + "name": "fast_response_requests_company_id_companies_id_fk", + "tableFrom": "fast_response_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fast_response_requests_cost_event_id_cost_events_id_fk": { + "name": "fast_response_requests_cost_event_id_cost_events_id_fk", + "tableFrom": "fast_response_requests", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "fast_response_requests_status_check": { + "name": "fast_response_requests_status_check", + "value": "\"fast_response_requests\".\"status\" in ('pending', 'running', 'succeeded', 'failed', 'unknown', 'skipped')" + } + }, + "isRLSEnabled": false + }, "public.feedback_exports": { "name": "feedback_exports", "schema": "", @@ -29938,6 +30710,34 @@ "method": "btree", "with": {} }, + "heartbeat_runs_cost_data_quality_idx": { + "name": "heartbeat_runs_cost_data_quality_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"finished_at\", \"created_at\") desc", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"heartbeat_runs\".\"cost_accounting_pending\" = true and \"heartbeat_runs\".\"status\" in ('succeeded','failed','timed_out','cancelled','interrupted')", + "concurrently": false, + "method": "btree", + "with": {} + }, "heartbeat_runs_execution_status_delivery_idx": { "name": "heartbeat_runs_execution_status_delivery_idx", "columns": [ @@ -32392,6 +33192,19 @@ "primaryKey": false, "notNull": false }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'comment'" + }, + "fast_response_request_id": { + "name": "fast_response_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "body": { "name": "body", "type": "text", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 4b1ef9079c1..22c832f0017 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2332,6 +2332,13 @@ "when": 1791807836288, "tag": "0334_rare_slipstream", "breakpoints": true + }, + { + "idx": 335, + "version": "7", + "when": 1791832982875, + "tag": "0335_absurd_bulldozer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/chat_slack_manager_grants.ts b/packages/db/src/schema/chat_slack_manager_grants.ts new file mode 100644 index 00000000000..fb4d5ef8861 --- /dev/null +++ b/packages/db/src/schema/chat_slack_manager_grants.ts @@ -0,0 +1,26 @@ +import { sql } from "drizzle-orm"; +import { check, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; + +/** Reusable personal management authorization; never a bot runtime credential. */ +export const chatSlackManagerGrants = pgTable("chat_slack_manager_grants", { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + userId: text("user_id").notNull(), + slackUserId: text("slack_user_id").notNull(), + workspaceId: text("workspace_id").notNull(), + workspaceName: text("workspace_name").notNull(), + managerAppId: text("manager_app_id").notNull(), + status: text("status").$type<"active" | "revoked" | "reauthorize">().notNull().default("active"), + accessSecretId: uuid("access_secret_id"), + refreshSecretId: uuid("refresh_secret_id"), + revision: integer("revision").notNull().default(0), + rateLimits: jsonb("rate_limits").$type>().notNull().default({}), + expiresAt: timestamp("expires_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, t => [ + uniqueIndex("chat_slack_manager_grants_company_id_idx").on(t.companyId, t.id), + uniqueIndex("chat_slack_manager_grants_owner_idx").on(t.companyId, t.userId, t.managerAppId, t.workspaceId), + check("chat_slack_manager_grants_status_check", sql`${t.status} in ('active', 'revoked', 'reauthorize')`), +]); diff --git a/packages/db/src/schema/chat_slack_registrations.ts b/packages/db/src/schema/chat_slack_registrations.ts index 0edad4a8917..5d17b660bd0 100644 --- a/packages/db/src/schema/chat_slack_registrations.ts +++ b/packages/db/src/schema/chat_slack_registrations.ts @@ -1,12 +1,14 @@ import { sql } from "drizzle-orm"; import { check, foreignKey, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; import type { SlackRegistrationStatus } from "@paperclipai/shared"; +import { chatSlackManagerGrants } from "./chat_slack_manager_grants.js"; import { chatEndpoints } from "./chat_channels.js"; /** App provisioning is distinct from installation and runtime credentials. No plaintext secrets. */ export const chatSlackRegistrations = pgTable("chat_slack_registrations", { endpointId: uuid("endpoint_id").primaryKey(), companyId: uuid("company_id").notNull(), + managerGrantId: uuid("manager_grant_id"), requestId: uuid("request_id").notNull(), status: text("status").$type().notNull(), manifest: jsonb("manifest").$type>().notNull(), @@ -22,6 +24,7 @@ export const chatSlackRegistrations = pgTable("chat_slack_registrations", { createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, t => [ + foreignKey({ columns: [t.companyId, t.managerGrantId], foreignColumns: [chatSlackManagerGrants.companyId, chatSlackManagerGrants.id] }), foreignKey({ columns: [t.companyId, t.endpointId], foreignColumns: [chatEndpoints.companyId, chatEndpoints.id] }).onDelete("cascade"), check("chat_slack_registration_status_check", sql`${t.status} in ('creating', 'uncertain', 'failed', 'install', 'credentials_saved', 'configured', 'removed')`), ]); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 4c4bad38337..630a0869b55 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -243,6 +243,8 @@ export { chatVoiceReports } from "./chat_voice_reports.js"; export { chatVoicePhoneLines, chatVoiceInboundCalls } from "./chat_voice_inbound.js"; export * from "./decision_models.js"; export { userCompanyPreferences } from "./user_company_preferences.js"; + +export { chatSlackManagerGrants } from "./chat_slack_manager_grants.js"; export { aiSubscriptions, aiSubscriptionPrices, aiSubscriptionConnections } from "./ai_subscriptions.js"; export * from "./fast_responses.js"; diff --git a/packages/shared/src/slack-app-manifest.ts b/packages/shared/src/slack-app-manifest.ts index d8ec7d59ee0..e6d3f251424 100644 --- a/packages/shared/src/slack-app-manifest.ts +++ b/packages/shared/src/slack-app-manifest.ts @@ -22,7 +22,7 @@ export function buildSlackAppManifest(input: { redirectUri?: string; }) { return { - display_information: { name: input.app.appName }, + display_information: { name: input.app.appName, description: `Talk to ${input.agentName} and manage work in Paperclip.`.slice(0, 140) }, features: { app_home: { home_tab_enabled: false, messages_tab_enabled: true, messages_tab_read_only_enabled: false }, agent_view: { agent_description: "Work with a Paperclip agent in a task-backed conversation." }, @@ -60,6 +60,7 @@ export const slackRegistrationStateSchema = z.object({ status: slackRegistrationStatusSchema, appId: z.string().nullable().optional(), managementUrl: z.string().url().nullable().optional(), + managerGrantId: z.string().uuid().nullable().optional(), errorCode: z.string().nullable().optional(), }).strict(); export type SlackRegistrationState = z.infer; @@ -79,11 +80,33 @@ export const slackAccountStateSchema = z.object({ }).strict(); export type SlackAccountState = z.infer; export const slackInstallAuthorizationSchema = z.object({ authorizationUrl: z.string().url(), expiresAt: z.string().datetime() }); +export const slackManagerAuthorizationSchema = slackInstallAuthorizationSchema.extend({ handoff: z.object({ kind: z.literal("paperclip_cloud"), session: z.string().min(16).max(512) }).optional() }); +export type SlackManagerAuthorization = z.infer; export type SlackInstallAuthorization = z.infer; export const slackSetupActionSchema = z.object({}).strict(); +export const slackTestMessageSchema = z.object({ + requestId: z.string().uuid(), + slackUserId: z.string().regex(/^[UW][A-Z0-9]+$/).optional(), + accountCursor: z.string().min(1).max(512).optional(), +}).strict(); +export type SlackTestMessageInput = z.infer; +export const slackTestMessageResultSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("sent"), slackUrl: z.string().url() }).strict(), + z.object({ status: z.literal("connection_required"), accounts: z.array(z.object({ + id: z.string(), name: z.string(), + }).strict()), nextCursor: z.string().nullable() }).strict(), + z.object({ status: z.literal("connection_pending"), principalId: z.string().uuid(), expiresAt: z.string().datetime() }).strict(), +]); +export type SlackTestMessageResult = z.infer; + export function slackRegistrationErrorMessage(code: string): string { const messages: Record = { + slack_managed_unavailable: "Managed Slack setup is unavailable. Try again later or use your own app before creation starts.", + slack_manager_reauthorize: "Reconnect your Slack workspace to continue setting up this agent.", + slack_approval_pending: "Slack is waiting for your workspace administrator to approve this app.", + slack_approval_required: "Your Slack workspace requires approval. Continue in Slack to request it.", + slack_approval_denied: "Your Slack administrator declined this installation. Contact them to continue.", slack_configuration_token_invalid: "Slack rejected the app configuration access token. Generate a new access token and try again.", slack_manifest_invalid: "Slack rejected this app configuration. Check the app details or use manual setup.", slack_manifest_update_pending: "Your Slack app is saved, but its event settings still need to be applied. Enter an app configuration access token to retry configuring the same app.", @@ -96,9 +119,29 @@ export function slackRegistrationErrorMessage(code: string): string { slack_install_identity_mismatch: "Slack returned a different app, workspace, or bot. Install the app created for this connection.", slack_install_scopes_missing: "Slack did not grant all required bot permissions. Install the app again and approve its requested permissions.", slack_bot_already_connected: "This Slack bot is already connected to Paperclip. Resume its existing connection or use a different app.", + slack_install_token_invalid: "Slack rejected the saved bot credentials. Continue in Slack to authorize this app again.", slack_configuration_incomplete: "The app is installed, but Paperclip could not finish connecting it. Retry connecting the saved installation.", slack_install_account_missing: "Slack did not identify your account. Authorize the same app again to connect your Slack account.", slack_install_account_conflict: "This Slack account already has an existing link. Manage account links in connection settings, or authorize with your own Slack account.", }; return messages[code] ?? "Slack setup could not be completed. Resume setup and try again."; } + +/** Persisted legacy values keep their original meaning. Missing means manual. */ +export type SlackSetupMethod = "managed" | "automatic" | "manual" | "existing"; +export function isProvisionedSlackSetup(method: string | undefined): boolean { + return method === "automatic" || method === "managed"; +} +export const SLACK_MANAGER_SCOPES = ["app_configurations:read", "app_configurations:write", "managed_apps:install"] as const; +export const slackSetupOptionsSchema = z.object({ + managedAvailable: z.boolean(), + defaultMethod: z.enum(["managed", "automatic"]), + workspaces: z.array(z.object({ grantId: z.string().uuid(), workspaceId: z.string(), workspaceName: z.string(), userId: z.string() })), +}); +export type SlackSetupOptions = z.infer; +export const slackManagedProvisionSchema = z.object({ + requestId: z.string().uuid(), grantId: z.string().uuid(), confirmedNoAppCreated: z.boolean().optional(), reconnect: z.boolean().optional(), +}).strict(); +export type SlackManagedProvisionInput = z.infer; +export const slackManagedResultSchema = z.object({ authorization: slackInstallAuthorizationSchema.optional() }); +export type SlackManagedResult = z.infer; diff --git a/packages/shared/src/types/chat-channels.ts b/packages/shared/src/types/chat-channels.ts index af49abfb8da..5ebcda06a2b 100644 --- a/packages/shared/src/types/chat-channels.ts +++ b/packages/shared/src/types/chat-channels.ts @@ -190,7 +190,8 @@ export interface SlackAppConfiguration { } export interface ChatEndpointSetupState { - slackSetupMethod?: "automatic" | "manual" | "existing"; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; + slackManagerError?: string; slackRegistration?: import("../slack-app-manifest.js").SlackRegistrationState; /** Provider-confirmed app icon outcome; configuration tokens are never retained. */ slackAvatar?: import("../slack-app-manifest.js").SlackAvatarState; @@ -508,10 +509,11 @@ export interface CreateChatEndpointInput { applicationId?: string; name?: string; slackApp?: SlackAppConfiguration; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; } export interface UpdateChatEndpointInput { - slackSetupMethod?: "automatic" | "manual" | "existing"; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; slackApp?: SlackAppConfiguration; communicationInstructions?: string; allowDirectMessages?: boolean; diff --git a/packages/shared/src/validators/chat-channels.ts b/packages/shared/src/validators/chat-channels.ts index f2553588ef3..53b263462f0 100644 --- a/packages/shared/src/validators/chat-channels.ts +++ b/packages/shared/src/validators/chat-channels.ts @@ -80,9 +80,10 @@ export const createChatEndpointSchema = z applicationId: z.string().uuid().optional(), name: z.string().trim().min(1).max(160).optional(), slackApp: slackAppConfigurationSchema.optional(), + slackSetupMethod: z.enum(["managed", "automatic", "manual", "existing"]).optional(), }) .strict() - .refine(value => !value.slackApp || value.provider === "slack", { + .refine(value => (!value.slackApp && !value.slackSetupMethod) || value.provider === "slack", { message: "Slack app details only apply to Slack connections", path: ["slackApp"], }); @@ -90,7 +91,7 @@ export const updateChatEndpointSchema = z .object({ communicationInstructions: multilineTextSchema.pipe(z.string().trim().max(4000)).optional(), slackApp: slackAppConfigurationSchema.optional(), - slackSetupMethod: z.enum(["automatic", "manual", "existing"]).optional(), + slackSetupMethod: z.enum(["managed", "automatic", "manual", "existing"]).optional(), allowDirectMessages: z.boolean().optional(), requireAtMention: z.boolean().optional(), allowGroupChats: z.boolean().optional(), diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 8b8b0f29641..6893a548784 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -1,3 +1,7 @@ +import * as activityLogModule from "../services/activity-log.js"; +import { chatSlackManagerGrants, chatSlackRegistrations, toolOauthStates } from "@paperclipai/db"; +import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../services/paperclip-cloud-connector.js"; +import { buildSlackAppManifest, SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; import { registerChatActionWork, dueChatAction } from "../services/chat-action-work.js"; import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js"; import { registerChatDeliveryWork } from "../services/chat-delivery-work.js"; @@ -7,14 +11,11 @@ import { aiConnectionService } from "../services/ai-connections.js"; import { fastResponseService } from "../services/fast-responses.js"; import { fastResponseReceipt } from "../services/fast-response-provider.js"; import { fastResponseRequests } from "@paperclipai/db"; -import { chatSlackRegistrations, toolOauthStates } from "@paperclipai/db"; -import { buildSlackAppManifest } from "@paperclipai/shared"; import { toolAccessService } from "../services/tool-access.js"; import { toolActionRequests, toolInvocations } from "@paperclipai/db"; import { GitHubPublicationLeaseLost, withGitHubPublicationLease } from "../services/chat-github-publication-lease.js"; import { secretService } from "../services/secrets.js"; import { githubChatWizardService } from "../services/chat-github-wizard.js"; -import type { PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js"; import { dispatchGitHubBotCloudEvent } from "../services/chat-github-cloud-ingress.js"; import { githubChatManagementService } from "../services/chat-github-management.js"; import { githubChatReviewService } from "../services/chat-github-reviews.js"; @@ -6262,7 +6263,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const token = `xoxb-bot-token-canary-${id}`; const scopes = buildSlackAppManifest({ app: appDetails, agentName: "Maya", webhookUrl: "https://paperclip.example/hook" }).oauth_config.scopes.bot; const state = { - failCreate: false, failValidate: false, failInventory: false, failAuth: false, failExchange: false, + managedAccessToken: token, rejectOldBotToken: false, + userListCursor: null as string | null, userListNextCursor: "", + managedBotAppId: appId, grantedScopes: scopes, managedError: null as string | null, failCreate: false, failValidate: false, failInventory: false, failAuth: false, failExchange: false, failManifestUpdate: false, timeoutManifestUpdate: false, beforeManifestUpdate: null as null | (() => Promise), failIcon: false, timeoutIcon: false, failUser: false, failWelcome: false, timeoutWelcome: false, @@ -6270,20 +6273,23 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { user: { id: installerId, team_id: "TAUTO", name: "installer", real_name: "Installing Person", is_bot: false, deleted: false }, beforeIcon: null as null | (() => Promise), beforeAuth: null as null | (() => Promise), + beforeExchange: null as null | (() => Promise), oauth: { ok: true, app_id: appId, token_type: "bot", access_token: token, team: { id: "TAUTO" }, bot_user_id: botId, authed_user: { id: installerId }, scope: scopes.join(",") }, }; const fallback = fakeSlackFetch(botId); const provider = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); + if (url.endsWith("bots.info")) return Response.json({ ok: true, bot: { id: "BMANAGED", app_id: state.managedBotAppId, user_id: botId, deleted: false } }); + if (url.endsWith("apps.managedInstall")) return Response.json(state.managedError ? { ok: false, error: state.managedError } : { ok: true, app_id: appId, team_id: "TAUTO", api_access_tokens: { bot_access_token: state.managedAccessToken } }); if (url.endsWith("apps.manifest.validate")) return Response.json(state.failValidate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true }); if (url.endsWith("apps.manifest.create")) { if (state.failCreate) throw new Error(`Timeout ${configToken} ${clientSecret}`); - return Response.json({ ok: true, app_id: appId, credentials: { client_id: "123.456", client_secret: clientSecret, signing_secret: signingSecret } }); + return Response.json({ ok: true, app_id: appId, team_id: "TAUTO", credentials: { client_id: "123.456", client_secret: clientSecret, signing_secret: signingSecret } }); } if (url.endsWith("apps.manifest.update")) { await state.beforeManifestUpdate?.(); if (state.timeoutManifestUpdate) throw new Error(`Manifest timeout ${configToken} ${clientSecret}`); - return Response.json(state.failManifestUpdate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true, app_id: appId }); + return Response.json(state.failManifestUpdate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true, app_id: appId, permissions_updated: true }); } if (url.endsWith("apps.icon.set")) { await state.beforeIcon?.(); @@ -6291,13 +6297,19 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { return Response.json(state.failIcon ? { ok: false, error: "missing_scope", detail: `${configToken} ${signingSecret}` } : { ok: true }); } if (url.endsWith("oauth.v2.access")) { + await state.beforeExchange?.(); if (state.failExchange) throw new Error(`Timeout ${clientSecret} ${token}`); return Response.json(state.oauth); } if (url.endsWith("auth.test")) { await state.beforeAuth?.(); + if (state.rejectOldBotToken && new Headers(init?.headers).get("authorization") !== `Bearer ${state.managedAccessToken}`) return Response.json({ ok: false, error: "token_revoked" }); if (state.failAuth) throw new Error(`Temporary failure ${token}`); - return Response.json({ ok: true, team_id: "TAUTO", user_id: botId, user: `maya-${id}`, team: "Test workspace" }, { headers: { "x-oauth-scopes": scopes.join(",") } }); + return Response.json({ ok: true, team_id: "TAUTO", user_id: botId, bot_id: "BMANAGED", user: `maya-${id}`, team: "Test workspace" }, { headers: { "x-oauth-scopes": state.grantedScopes.join(",") } }); + } + if (url.endsWith("users.list")) { + state.userListCursor = new URLSearchParams(String(init?.body)).get("cursor"); + return Response.json({ ok: true, members: [state.user], response_metadata: { next_cursor: state.userListNextCursor } }); } if (url.endsWith("users.info")) { await state.beforeUser?.(); @@ -6323,6 +6335,408 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const install = async () => new URL((await service.slackRegistration.install(endpoint.id, actor)).authorizationUrl).searchParams.get("state")!; return { ...company, endpoint, service, sdk, app, state, provider, create, install, createInput, token, appId, botId, installerId, avatarBytes, renderAvatar }; } + async function managedFixture() { + const broker = { + getCapabilities: vi.fn(async () => ["slack.manager"]), + getSlackManagerAppId: vi.fn(async () => "AMANAGER"), + startAuthorization: vi.fn(async (_input: { returnState: string }) => ({ authorizationUrl: "https://slack.com/oauth/v2/authorize?state=broker", expiresAt: new Date(Date.now() + 600_000).toISOString() })), + claim: vi.fn(), refresh: vi.fn(), revoke: vi.fn(async () => undefined), + }; + const f = await fixture({ slackManaged: { enabled: true, broker: broker as unknown as PaperclipCloudConnector } }); + const credentials: SealedConnectorCredentials = { v: 1, provider: "slack", profile: "slack.manager", instanceId: "test", environment: "staging", subject: actor.userId, companyId: f.companyId, + accessToken: "xoxe.xoxp-manager-access-canary", refreshToken: "xoxe-manager-refresh-canary", tokenType: "user", accessTokenExpiresAt: new Date(Date.now() + 43_200_000).toISOString(), refreshTokenExpiresAt: null, + scopes: [...SLACK_MANAGER_SCOPES], slackManager: { appId: "AMANAGER", workspaceId: "TAUTO", workspaceName: "Test workspace", userId: f.installerId } }; + broker.claim.mockResolvedValue(credentials); + broker.refresh.mockResolvedValue({ ...credentials, accessToken: "xoxe.rotated-canary", refreshToken: "xoxe.rotated-refresh-canary" }); + await f.service.update(f.endpoint.id, { slackSetupMethod: "managed" }, actor.userId); + const grants = f.service.slackRegistration.managerGrants; + const authorize = async () => { await grants.authorize(f.endpoint.id, actor); return broker.startAuthorization.mock.calls.at(-1)![0].returnState; }; + const authorizeState = await authorize(); + await grants.complete(authorizeState, "claim-canary", null, actor); + const choices = await grants.choices(f.companyId, actor); + expect(choices.workspaces).toHaveLength(1); + const grantId = choices.workspaces[0]!.grantId; + const input = { requestId: randomUUID(), grantId }; + const provision = () => f.service.slackRegistration.managed.provision(f.endpoint.id, actor, input); + return { ...f, broker, credentials, grants, authorize, authorizeState, grantId, input, provision }; + } + describe("Slack test messages", () => { + const posts = (f: Awaited>) => f.provider.mock.calls.filter(([url]) => String(url).endsWith("chat.postMessage")); + it("sends to the current linked user and reuses a durable result on duplicate requests", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + const input = { requestId: randomUUID() }; + const result = await request(f.app).post(`/api/chat-endpoints/${f.endpoint.id}/slack/test-message`).send(input).expect(200); + expect(result.body).toEqual({ status: "sent", slackUrl: "https://app.slack.com/client/TAUTO/DWELCOME" }); + await f.service.sendSlackTestMessage(f.endpoint.id, actor, input); + expect(posts(f)).toHaveLength(1); + expect(new URLSearchParams(String(posts(f)[0]![1]!.body)).get("channel")).toBe(f.installerId); + }); + it("links the actor's own manager identity without requiring a connect command", async () => { + const f = await managedFixture(); await f.provision(); + await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).toMatchObject({ status: "sent" }); + expect(await db.select().from(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id))).toEqual([expect.objectContaining({ paperclipUserId: actor.userId, status: "linked" })]); + }); + it("requires a private Slack challenge for an unknown account before linking and sending the test", async () => { + const f = await managedFixture(); await f.provision(); + await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await db.update(chatSlackManagerGrants).set({ userId: "another-user" }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.provider.mockClear(); const input = { requestId: randomUUID() }; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toEqual({ status: "connection_required", accounts: [{ id: f.installerId, name: "Installing Person" }], nextCursor: null }); + expect(posts(f)).toHaveLength(0); + const pending = await f.service.sendSlackTestMessage(f.endpoint.id, actor, { ...input, slackUserId: f.installerId }); + expect(pending.status).toBe("connection_pending"); + const links = await db.select().from(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + expect(links).toEqual([expect.objectContaining({ status: "pending", paperclipUserId: null })]); + const fields = new URLSearchParams(String(posts(f)[0]![1]!.body)); + expect(fields.get("text")).not.toContain("This is your test message"); + const blocks = JSON.parse(fields.get("blocks")!); + const invitationUrl = /<([^|]+)\|/.exec(blocks[1].text.text)![1]!; + const token = new URL(invitationUrl).searchParams.get("token")!; + expect(JSON.stringify(pending)).not.toContain(token); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toEqual(pending); + expect(posts(f)).toHaveLength(1); + await f.service.confirmIdentityLink(token, actor.userId); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" }); + expect(new URLSearchParams(String(posts(f).at(-1)![1]!.body)).get("text")).toContain("This is your test message"); + }); + it("loads another workspace page instead of truncating account discovery", async () => { + const f = await managedFixture(); await f.provision(); + await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await db.update(chatSlackManagerGrants).set({ userId: "another-user" }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.provider.mockClear(); f.state.userListNextCursor = "next-page"; + const input = { requestId: randomUUID() }; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "connection_required", nextCursor: "next-page" }); + f.state.userListNextCursor = ""; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, { ...input, accountCursor: "next-page" })).toMatchObject({ status: "connection_required", nextCursor: null }); + expect(f.state.userListCursor).toBe("next-page"); + expect(posts(f)).toHaveLength(0); + }); + it("does not downgrade confirmed delivery when activity logging fails", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + const original = activityLogModule.logActivity; + const audit = vi.spyOn(activityLogModule, "logActivity").mockImplementation(async (database, entry) => { + if (entry.action === "chat_slack.test_message_sent") throw new Error("Synthetic activity failure"); + return original(database, entry); + }); + onTestFinished(() => audit.mockRestore()); + const input = { requestId: randomUUID() }; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" }); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" }); + expect(posts(f)).toHaveLength(1); + }); + it("does not restore revoked access or take another user's identity", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + await db.update(chatIdentityLinks).set({ status: "revoked" }).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("existing link"); + await db.update(chatIdentityLinks).set({ status: "linked", paperclipUserId: "another-user" }).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID(), slackUserId: f.installerId })).rejects.toThrow("existing link"); + expect(posts(f)).toHaveLength(0); + }); + it("rejects wrong-workspace users, disabled DMs, and membership revocation", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + f.state.user.team_id = "TOTHER"; + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("personal Slack account"); + f.state.user.team_id = "TAUTO"; + await db.update(chatEndpoints).set({ allowDirectMessages: false }).where(eq(chatEndpoints.id, f.endpoint.id)); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("Enable direct messages"); + await db.update(chatEndpoints).set({ allowDirectMessages: true }).where(eq(chatEndpoints.id, f.endpoint.id)); + await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, actor.userId))); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("active Paperclip account"); + expect(posts(f)).toHaveLength(0); + }); + it("redacts provider failures and never retries an ambiguous send", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); f.state.timeoutWelcome = true; + const input = { requestId: randomUUID() }; + const result = await request(f.app).post(`/api/chat-endpoints/${f.endpoint.id}/slack/test-message`).send(input).expect(422); + expect(JSON.stringify(result.body)).not.toContain("canary"); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).rejects.toThrow("may already have been sent"); + expect(posts(f)).toHaveLength(1); + const actions = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.kind, "slack_test_message"))); + expect(JSON.stringify(actions)).not.toContain("canary"); + expect(actions[0]!.status).toBe("uncertain"); + }); + }); + describe("managed Slack", () => { + it("keeps open-source setup independent of the broker", async () => { + const f = await fixture(); + expect(await f.service.slackRegistration.managerGrants.choices(f.companyId, actor)).toEqual({ managedAvailable: false, defaultMethod: "automatic", workspaces: [] }); + await f.create(); + expect((await f.service.get(f.endpoint.id)).setup.slackSetupMethod).toBe("automatic"); + await expect(f.service.update(f.endpoint.id, { slackSetupMethod: "managed" }, actor.userId)).rejects.toThrow(); + }); + it("keeps configured Cloud drafts managed when the broker profile disappears", async () => { + const f = await managedFixture(); + f.broker.getSlackManagerAppId.mockResolvedValue(null); + expect(f.grants.defaultMethod()).toBe("managed"); + expect(await f.grants.choices(f.companyId, actor)).toEqual({ managedAvailable: false, defaultMethod: "managed", workspaces: [] }); + const next = await f.service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId }, actor.userId); + expect(next.setup.slackSetupMethod).toBe("managed"); + await expect(f.provision()).rejects.toThrow(); + }); + it("creates and installs once, uploads the avatar and links only the manager grant owner", async () => { + const f = await managedFixture(); + await Promise.all([f.provision(), f.provision()]); + const saved = await f.service.get(f.endpoint.id); + expect(saved.setup).toMatchObject({ slackSetupMethod: "managed", slackRegistration: { status: "configured", appId: f.appId }, slackAvatar: { status: "uploaded" }, slackAccount: { status: "linked", paperclipUserId: actor.userId, externalUserId: f.installerId } }); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + const activities = await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId)); + for (const secret of [f.credentials.accessToken, f.credentials.refreshToken!, f.token, clientSecret, signingSecret, "claim-canary"]) expect(JSON.stringify({ saved, activities, choices: await f.grants.choices(f.companyId, actor) })).not.toContain(secret); + await expect(f.service.update(f.endpoint.id, { slackSetupMethod: "automatic" }, actor.userId)).rejects.toThrow(); + }); + it("reuses the same personal workspace grant for another draft without another authorization", async () => { + const f = await managedFixture(); + const draft = await f.service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId, slackSetupMethod: "managed" }, actor.userId); + expect((await f.grants.choices(f.companyId, actor)).workspaces[0]!.grantId).toBe(f.grantId); + const otherActor = { ...actor, userId: "another-user", bypassPermissionCheck: true }; + expect((await f.grants.choices(f.companyId, otherActor)).workspaces).toEqual([]); + await expect(f.service.slackRegistration.managed.provision(draft.id, otherActor, { ...f.input, requestId: randomUUID() })).rejects.toThrow(); + expect(f.broker.startAuthorization).toHaveBeenCalledTimes(1); + }); + it("installs a second bot using the same grant without new manager consent", async () => { + const f = await managedFixture(); await f.provision(); + const secondBot = `${f.botId}SECOND`; + const secondApp = `${f.appId}SECOND`; + const scopes = buildSlackAppManifest({ app: appDetails, agentName: "Maya", webhookUrl: "https://paperclip.example/hook" }).oauth_config.scopes.bot; + const provider: typeof fetch = async (input, init) => { + const url = String(input); + if (url.endsWith("apps.manifest.create")) return Response.json({ ok: true, app_id: secondApp, team_id: "TAUTO", credentials: { client_id: "222.333", client_secret: clientSecret, signing_secret: signingSecret } }); + if (url.endsWith("apps.managedInstall")) return Response.json({ ok: true, app_id: secondApp, team_id: "TAUTO", api_access_tokens: { bot_access_token: "xoxb-second-managed" } }); + if (url.endsWith("auth.test")) return Response.json({ ok: true, team_id: "TAUTO", user_id: secondBot, bot_id: "BSECOND", team: "Test workspace" }, { headers: { "x-oauth-scopes": scopes.join(",") } }); + if (url.endsWith("bots.info")) return Response.json({ ok: true, bot: { id: "BSECOND", app_id: secondApp, user_id: secondBot, deleted: false } }); + return f.provider(input, init); + }; + const { service } = createService(new FakeChatSdkRuntime(), provider, { renderSlackAvatar: f.renderAvatar, slackManaged: { enabled: true, broker: f.broker as unknown as PaperclipCloudConnector } }); + const second = await service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId, slackSetupMethod: "managed", slackApp: appDetails }, actor.userId); + await service.slackRegistration.managed.provision(second.id, actor, { grantId: f.grantId, requestId: randomUUID() }); + expect((await service.get(second.id)).setup.slackRegistration).toMatchObject({ status: "configured", appId: secondApp }); + expect(f.broker.startAuthorization).toHaveBeenCalledTimes(1); + expect(f.broker.claim).toHaveBeenCalledTimes(1); + }); + it("rejects missing tool scopes before activating a managed bot", async () => { + const f = await managedFixture(); f.state.grantedScopes = []; + await f.provision(); + const saved = await f.service.get(f.endpoint.id); + expect(saved.providerAccountId).toBeNull(); + expect(saved.setup.slackRegistration).toMatchObject({ status: "credentials_saved", errorCode: "slack_install_scopes_missing" }); + }); + it.each(["revoked", "attention", "active"] as const)("reinstalls the configured app on an explicit %s reconnect", async status => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + await db.update(chatEndpoints).set({ status }).where(eq(chatEndpoints.id, f.endpoint.id)); + f.state.managedAccessToken = `xoxb-reconnected-${f.botId}`; f.state.rejectOldBotToken = true; + const reconnectInput = { ...f.input, requestId: randomUUID(), reconnect: true }; + await f.service.slackRegistration.managed.provision(f.endpoint.id, actor, reconnectInput); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(0); + expect((await f.service.get(f.endpoint.id)).status).not.toBe("revoked"); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "configured", appId: f.appId }); + await f.service.slackRegistration.managed.provision(f.endpoint.id, actor, reconnectInput); + await f.provision(); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + }); + it("preserves configured-app reconnect intent when managed installation fails", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + await db.update(chatEndpoints).set({ status: "revoked" }).where(eq(chatEndpoints.id, f.endpoint.id)); + f.state.managedError = "temporary_error"; + await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "install", appId: f.appId }); + f.state.managedError = null; + await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(0); + }); + it("reinstalls the saved app after missing bot scopes without creating another app", async () => { + const f = await managedFixture(); const scopes = f.state.grantedScopes; + f.state.grantedScopes = []; await f.provision(); + f.state.grantedScopes = scopes; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(2); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("retains reinstall intent after a provider failure instead of retrying rejected credentials", async () => { + const f = await managedFixture(); const scopes = f.state.grantedScopes; + f.state.grantedScopes = []; await f.provision(); + f.state.managedError = "internal_error"; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "install", errorCode: "slack_provider_failure" }); + f.state.managedError = null; f.state.grantedScopes = scopes; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(3); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("clears a manager reconnect error after renewing the bound grant", async () => { + const f = await managedFixture(); f.state.managedError = "invalid_auth"; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_manager_reauthorize"); + await f.grants.complete(await f.authorize(), "new-claim", null, actor); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBeNull(); + f.state.managedError = null; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("reconnects a rejected manager token without losing pending manifest configuration", async () => { + const f = await managedFixture(); f.state.failManifestUpdate = true; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_manifest_update_pending"); + expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]); + expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("reauthorize"); + await f.grants.complete(await f.authorize(), "new-claim", null, actor); + f.state.failManifestUpdate = false; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect((await f.service.get(f.endpoint.id)).setup.slackAvatar?.status).toBe("uploaded"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.icon.set"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("does not restore a revoked grant when an in-flight refresh returns", async () => { + const f = await managedFixture(); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.broker.refresh.mockImplementation(async () => { + await f.grants.revoke(f.grantId, f.companyId, actor); + return f.credentials; + }); + await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow(); + expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("revoked"); + }); + it("claims concurrent manager callbacks once", async () => { + const f = await managedFixture(); const state = await f.authorize(); + const results = await Promise.allSettled([1, 2].map(() => f.grants.complete(state, "claim", null, actor))); + expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1); + expect(f.broker.claim).toHaveBeenCalledTimes(2); + }); + it("atomically consumes manager state and rejects wrong actors and expired attempts", async () => { + const f = await managedFixture(); + await expect(f.grants.complete(f.authorizeState, "replay", null, actor)).rejects.toThrow(); + const state = await f.authorize(); + await expect(f.grants.complete(state, "claim", null, { ...actor, userId: "another-user", bypassPermissionCheck: true })).rejects.toThrow(); + await db.update(toolOauthStates).set({ expiresAt: new Date(0) }).where(eq(toolOauthStates.state, state)); + await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow(); + expect(f.broker.claim).toHaveBeenCalledTimes(1); + }); + it("preserves declined manager authorization as a safe recoverable outcome", async () => { + const f = await managedFixture(); + await f.grants.complete(await f.authorize(), null, "access_denied", actor); + expect((await f.service.get(f.endpoint.id)).setup.slackManagerError).toBe("slack_install_declined"); + }); + it("resumes saved installation credentials after inventory failure without creating or reinstalling", async () => { + const f = await managedFixture(); f.state.failInventory = true; + await f.provision(); + expect((await f.service.slackRegistration.registration(f.endpoint.id))?.status).toBe("credentials_saved"); + f.state.failInventory = false; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("never recreates an uncertain app", async () => { + const f = await managedFixture(); f.state.failCreate = true; + await f.provision(); await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("uncertain"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("uses bound OAuth for approval fallback and rejects a different installer", async () => { + const f = await managedFixture(); f.state.managedError = "app_approval_request_eligible"; + const result = await f.provision(); + const state = new URL(result.authorization!.authorizationUrl).searchParams.get("state")!; + f.state.oauth.authed_user.id = "UOTHER"; + await f.service.slackRegistration.complete(state, "code-canary", null, actor); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_install_identity_mismatch"); + expect((await f.service.get(f.endpoint.id)).providerAccountId).toBeNull(); + }); + it("does not activate a bot when the manager grant is revoked during child OAuth", async () => { + const f = await managedFixture(); f.state.managedError = "app_approval_request_eligible"; + const result = await f.provision(); + const state = new URL(result.authorization!.authorizationUrl).searchParams.get("state")!; + f.state.beforeExchange = () => f.grants.revoke(f.grantId, f.companyId, actor); + await f.service.slackRegistration.complete(state, "code-canary", null, actor); + const saved = await f.service.get(f.endpoint.id); + expect(saved.providerAccountId).toBeNull(); + expect(saved.setup.slackRegistration?.errorCode).toBe("slack_install_identity_mismatch"); + }); + it("uses the held transaction for grant queries without acquiring another pool connection", async () => { + const f = await managedFixture(); + const transactionContext = new AsyncLocalStorage(); + const originalTransaction = db.transaction.bind(db); + const transactionSpy = vi.spyOn(db, "transaction").mockImplementation((work, ...args) => + originalTransaction(tx => transactionContext.run(true, () => work(tx)), ...args)); + const querySpies = (["select", "insert", "update", "delete", "execute"] as const).map(method => { + const original = db[method].bind(db) as (...args: unknown[]) => unknown; + return vi.spyOn(db, method).mockImplementation(((...args: unknown[]) => { + if (transactionContext.getStore()) throw new Error("Acquired outer pool connection from held transaction"); + return original(...args); + }) as never); + }); + try { + await f.grants.token(f.grantId, f.companyId, actor); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + await f.grants.token(f.grantId, f.companyId, actor); + expect(f.broker.refresh).toHaveBeenCalledTimes(1); + await f.grants.request(f.grantId, f.companyId, actor, "apps.manifest.validate", { manifest: "{}" }); + await f.grants.complete(await f.authorize(), "renewed-claim", null, actor); + expect((await f.service.get(f.endpoint.id)).setup.slackManagerError).toBeUndefined(); + await f.grants.revoke(f.grantId, f.companyId, actor); + expect(f.broker.revoke).toHaveBeenCalledTimes(2); + } finally { + transactionSpy.mockRestore(); + for (const spy of querySpies) spy.mockRestore(); + } + }); + it("serializes refreshes across endpoints and never replays an ambiguous refresh", async () => { + const f = await managedFixture(); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + const tokens = await Promise.all([1, 2].map(() => f.grants.token(f.grantId, f.companyId, actor))); + expect(tokens.every(result => result.token === "xoxe.rotated-canary")).toBe(true); + expect(f.broker.refresh).toHaveBeenCalledTimes(1); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.broker.refresh.mockRejectedValue(new Error("provider echoed manager-canary")); + await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow(); + await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow(); + expect(f.broker.refresh).toHaveBeenCalledTimes(2); + }); + it("rejects a bot token belonging to a different Slack app before activation", async () => { + const f = await managedFixture(); f.state.managedBotAppId = "ADIFFERENT"; + await f.provision(); + const saved = await f.service.get(f.endpoint.id); + expect(saved.providerAccountId).toBeNull(); + expect(saved.setup.slackRegistration).toMatchObject({ status: "credentials_saved", errorCode: "slack_install_identity_mismatch" }); + }); + it("rejects manager identity changes and both callback-origin and ingress-origin drift", async () => { + const f = await managedFixture(); const state = await f.authorize(); + for (const change of [{ publicBaseUrl: "https://changed.example" }, { webhookPublicBaseUrl: "https://changed.example" }]) { + const changed = createService(new FakeChatSdkRuntime(), f.provider, { ...change, slackManaged: { enabled: true, broker: f.broker as unknown as PaperclipCloudConnector } }).service; + await expect(changed.slackRegistration.managerGrants.complete(state, "claim", null, actor)).rejects.toThrow(); + } + f.broker.getSlackManagerAppId.mockResolvedValue("ANEWMANAGER"); + await expect(f.provision()).rejects.toThrow(); + expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]); + }); + it("preserves provider rate-limit backoff across subsequent attempts", async () => { + const f = await managedFixture(); f.state.managedError = "ratelimited"; + await f.provision(); + f.state.managedError = null; await f.provision(); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_setup_rate_limited"); + }); + it("routes the broker claim_id callback back to the saved wizard without exposing the handoff", async () => { + const f = await managedFixture(); const state = await f.authorize(); + const response = await request(f.app).get(`/api/chat-slack/managed/oauth/callback?state=${state}&claim_id=sealed-claim-canary`).set("accept", "application/json").expect(303); + expect(f.broker.claim.mock.calls.at(-1)![0].claimId).toBe("sealed-claim-canary"); + expect(response.headers.location).toContain(f.endpoint.id); + expect(response.text).not.toContain("sealed-claim-canary"); + expect(response.headers["cache-control"]).toBe("no-store"); + }); + it("does not accept another company's grant or keep authorizations after removal", async () => { + const f = await managedFixture(); const other = await seedCompany(); + await expect(f.grants.token(f.grantId, other.companyId, { ...actor, bypassPermissionCheck: true })).rejects.toThrow(); + const state = await f.authorize(); + await f.service.configure(f.endpoint.id, { action: "remove" }, actor.userId); + await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow(); + expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("active"); + }); + it("revocation invalidates pending authorization and prevents future provisioning", async () => { + const f = await managedFixture(); const state = await f.authorize(); + await f.grants.revoke(f.grantId, f.companyId, actor); + await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow(); + await expect(f.provision()).rejects.toThrow(); + expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]); + }); + }); function setupEvent(f: Awaited>, patch: Record = {}, url = f.endpoint.setup.webhookUrl!, secret = signingSecret, timestamp?: string) { return signedSlackWebhookRequest({ url, contentType: "application/json", signingSecret: secret, ...(timestamp ? { timestamp } : {}), body: JSON.stringify({ type: "event_callback", api_app_id: f.appId, team_id: "TAUTO", event_id: "EvSETUP", event: { diff --git a/server/src/__tests__/http-log-policy.test.ts b/server/src/__tests__/http-log-policy.test.ts index f6a92125283..0c57d5159b0 100644 --- a/server/src/__tests__/http-log-policy.test.ts +++ b/server/src/__tests__/http-log-policy.test.ts @@ -65,7 +65,7 @@ describe("isPrivateWebhookHttpRequest", () => { describe("isSecretSensitiveHttpRequest", () => { it("protects Slack registration, installation, recovery, and all callback methods", () => { for (const suffix of ["registration", "install", "resume"]) expect(isSecretSensitiveHttpRequest("POST", `/api/chat-endpoints/id/slack/${suffix}`)).toBe(true); - for (const method of ["GET", "POST", "DELETE"]) for (const path of ["/api/chat-slack/oauth/callback?code=canary", "https://paperclip.test/API/CHAT-SLACK/OAUTH/callback?code=canary"]) + for (const method of ["GET", "POST", "DELETE"]) for (const path of ["/api/chat-slack/oauth/callback?code=canary", "/api/chat-slack/managed/oauth/callback?claim=canary", "/api/chat-slack/managed/oauth/callback?code=canary", "/api/chat-endpoints/endpoint/slack/managed/authorize", "/api/chat-endpoints/endpoint/slack/managed/provision", "https://paperclip.test/API/CHAT-SLACK/OAUTH/callback?code=canary"]) expect(isSecretSensitiveHttpRequest(method, path)).toBe(true); }); it("identifies credential-bearing chat setup mutations", () => { diff --git a/server/src/__tests__/redact-sensitive.test.ts b/server/src/__tests__/redact-sensitive.test.ts index b0467a7fb96..151c9212a5d 100644 --- a/server/src/__tests__/redact-sensitive.test.ts +++ b/server/src/__tests__/redact-sensitive.test.ts @@ -6,6 +6,11 @@ import { stripSecretBearingUrlParts, } from "../middleware/redact-sensitive.js"; +it("redacts Slack manager grants and sealed OAuth handoffs", () => { + const result = redactSensitive({ managerToken: "manager-canary", refreshToken: "refresh-canary", sealed: { ct: "sealed-canary" }, handoff: { token: "handoff-canary" }, claim_id: "claim-canary" }); + expect(JSON.stringify(result)).not.toContain("canary"); +}); + describe("redactSensitive", () => { it("redacts a plaintext password field on a sign-in body", () => { const body = { diff --git a/server/src/__tests__/slack-development-manager.test.ts b/server/src/__tests__/slack-development-manager.test.ts new file mode 100644 index 00000000000..314e087843c --- /dev/null +++ b/server/src/__tests__/slack-development-manager.test.ts @@ -0,0 +1,73 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; +import { developmentSlackManager } from "../services/connectors/slack/setup/development-manager.js"; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach(path => rmSync(path, { recursive: true, force: true }))); +function fixture() { + const root = mkdtempSync(join(tmpdir(), "slack-development-")); roots.push(root); + const path = join(root, "manager.json"); + writeFileSync(path, JSON.stringify({ appId: "A123", clientId: "123.456", clientSecret: "client-secret-canary" }), { mode: 0o600 }); + return { path, env: { NODE_ENV: "development", PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED: "true", PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG: path } }; +} +const origin = () => "https://local-test.trycloudflare.com"; +const identity = { subject: "actor", companyId: "company", profile: "slack.manager" as const }; +const user = { id: "U123", token_type: "user", access_token: "access-canary", refresh_token: "refresh-canary", expires_in: 43200, scope: SLACK_MANAGER_SCOPES.join(",") }; +const response = (value: unknown) => new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } }); + +describe("local managed Slack qualification", () => { + it("requires explicit development opt-in and refuses production", () => { + const { env } = fixture(); + expect(developmentSlackManager(origin, {})).toBeNull(); + expect(developmentSlackManager(origin, { ...env, NODE_ENV: "production" })).toBeNull(); + expect(developmentSlackManager(origin, { ...env, PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED: "false" })).toBeNull(); + }); + it("rejects readable secrets and invalid HTTPS origins", async () => { + const { env, path } = fixture(); chmodSync(path, 0o644); + expect(() => developmentSlackManager(origin, env)).toThrow(); + chmodSync(path, 0o600); + await expect(developmentSlackManager(() => "http://local.test", env)!.getSlackManagerAppId()).rejects.toThrow(); + }); + it("builds a scoped authorization URL without exposing the client secret", async () => { + const { env } = fixture(); + const client = developmentSlackManager(origin, env)!; + const result = await client.startAuthorization({ ...identity, returnUri: `${origin()}/api/chat-slack/managed/oauth/callback`, returnState: "bound-state" }); + const url = new URL(result.authorizationUrl); + expect(url.origin).toBe("https://slack.com"); + expect(url.searchParams.get("state")).toBe("bound-state"); + expect(url.searchParams.get("user_scope")).toBe(SLACK_MANAGER_SCOPES.join(",")); + expect(JSON.stringify(result)).not.toContain("client-secret-canary"); + await expect(client.startAuthorization({ ...identity, returnUri: "https://other.test/callback", returnState: "bound-state" })).rejects.toThrow(); + }); + it("exchanges the code against the exact callback and preserves actor/workspace identity", async () => { + const { env } = fixture(); + const fetcher = vi.fn().mockResolvedValue(response({ ok: true, app_id: "A123", team: { id: "T123", name: "Paperclip" }, authed_user: user })); + const result = await developmentSlackManager(origin, env, fetcher)!.claim({ ...identity, claimId: "code-canary", redemptionId: "once" }); + expect(result.slackManager).toEqual({ appId: "A123", workspaceId: "T123", workspaceName: "Paperclip", userId: "U123" }); + expect(result.subject).toBe("actor"); expect(result.companyId).toBe("company"); + const [url, request] = fetcher.mock.calls[0]; + expect(url).toBe("https://slack.com/api/oauth.v2.access"); + expect(request.redirect).toBe("error"); + expect(request.body.get("redirect_uri")).toBe(`${origin()}/api/chat-slack/managed/oauth/callback`); + expect(request.body.get("code")).toBe("code-canary"); + }); + it.each(["wrong-app", "missing-scope", "provider-echo"])("rejects %s without echoing secrets", async reason => { + const { env } = fixture(); + const value = reason === "provider-echo" ? { ok: false, error: "client-secret-canary code-canary" } : { ok: true, app_id: reason === "wrong-app" ? "AOTHER" : "A123", team: { id: "T123", name: "Paperclip" }, authed_user: { ...user, scope: reason === "missing-scope" ? "" : user.scope } }; + const client = developmentSlackManager(origin, env, vi.fn().mockResolvedValue(response(value)))!; + const error = await client.claim({ ...identity, claimId: "code-canary", redemptionId: "once" }).catch(error => error); + expect(error).toBeInstanceOf(Error); + expect(String(error)).not.toMatch(/canary/); + }); + it("rotates refresh credentials and verifies revocation", async () => { + const { env } = fixture(); + const fetcher = vi.fn().mockResolvedValueOnce(response({ ok: true, ...user })).mockResolvedValueOnce(response({ ok: false, error: "invalid_auth" })); + const client = developmentSlackManager(origin, env, fetcher)!; + expect((await client.refresh({ ...identity, refreshToken: "old-refresh" })).refreshToken).toBe("refresh-canary"); + expect(fetcher.mock.calls[0][1].body.get("grant_type")).toBe("refresh_token"); + await expect(client.revoke({ ...identity, token: "access-canary" })).rejects.toThrow(); + }); +}); diff --git a/server/src/middleware/http-log-policy.ts b/server/src/middleware/http-log-policy.ts index 5c5731659d2..222f97d476c 100644 --- a/server/src/middleware/http-log-policy.ts +++ b/server/src/middleware/http-log-policy.ts @@ -93,7 +93,7 @@ export function isSecretSensitiveHttpRequest( // Avatar uploads and dedicated Dot MCP calls may contain private image bytes. if (url && /^(?:\/mcp\/runner(?:\/|$)|\/api\/companies\/[^/]+\/agents\/[^/]+\/avatar(?:\/|$))/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; if (!method || !url) return false; - if (/^\/api\/chat-slack\/oauth(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; + if (/^\/api\/chat-slack\/(?:managed\/)?oauth(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; if (/^\/api\/chat-endpoints\/[^/]+\/slack(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; if (!SECRET_SENSITIVE_HTTP_METHODS.has(method.toUpperCase())) return false; const pathname = normalizePath(url); diff --git a/server/src/middleware/redact-sensitive.ts b/server/src/middleware/redact-sensitive.ts index 0b4ba2d39d4..3d6b874f6d7 100644 --- a/server/src/middleware/redact-sensitive.ts +++ b/server/src/middleware/redact-sensitive.ts @@ -16,6 +16,13 @@ const SENSITIVE_KEYS = new Set([ // material under `credentials`. Redact the whole subtree instead of trying // to keep an ever-changing allowlist of provider-specific field names in // sync with every connector. + "sealed", + "handoff", + "claim", + "claimid", + "claim_id", + "managertoken", + "manager_token", "configurationtoken", "configuration_token", "credential", diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index 6395e5ecd89..6dbde753c35 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -1,4 +1,4 @@ -import { slackRegistrationSchema, slackSetupActionSchema } from "@paperclipai/shared"; +import { slackTestMessageSchema, slackRegistrationSchema, slackSetupActionSchema, slackManagedProvisionSchema } from "@paperclipai/shared"; import { isCrossSiteOAuthCallbackNavigation, oauthCallbackInterstitialHtml } from "../lib/oauth-browser-return.js"; import { Router, @@ -164,6 +164,10 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { async (req, res) => { const companyId = req.params.companyId as string; await assertConnectionManager(req, companyId); + if (req.body.provider === "slack" && !req.body.slackSetupMethod) { + req.body.slackSetupMethod = service.slackRegistration.managerGrants.defaultMethod(); + } + if (req.body.slackSetupMethod === "managed" && service.slackRegistration.managerGrants.defaultMethod() !== "managed") throw forbidden("Managed Slack setup is unavailable"); res .status(201) .json(await service.create(companyId, req.body, actorUserId(req))); @@ -182,6 +186,50 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { return { userId: actor.actorId, sessionId: actor.sessionId, bypassPermissionCheck: req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true }; }; + router.get("/companies/:companyId/chat-slack/setup-options", async (req, res) => { + const companyId = req.params.companyId as string; + await assertConnectionManager(req, companyId); + res.set("Cache-Control", "no-store"); + res.json(await service.slackRegistration.managerGrants.choices(companyId, slackActor(req))); + }); + router.post("/chat-endpoints/:endpointId/slack/managed/authorize", validate(slackSetupActionSchema), async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.slackRegistration.managerGrants.authorize(endpointId(req), slackActor(req))); + }); + router.post("/chat-endpoints/:endpointId/slack/managed/provision", validate(slackManagedProvisionSchema), async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.slackRegistration.managed.provision(endpointId(req), slackActor(req), req.body)); + }); + router.delete("/companies/:companyId/chat-slack/manager-grants/:grantId", async (req, res) => { + const companyId = req.params.companyId as string; + await assertConnectionManager(req, companyId); + await service.slackRegistration.managerGrants.revoke(req.params.grantId as string, companyId, slackActor(req)); + res.sendStatus(204); + }); + router.get("/chat-slack/managed/oauth/callback", async (req, res) => { + res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer"); + const actor = slackActor(req); + const state = typeof req.query.state === "string" ? req.query.state : ""; + const expiredId = await service.slackRegistration.managerGrants.expiredReturn(state, actor); + if (expiredId) { res.redirect(303, await service.slackRegistration.returnPath(expiredId)); return; } + const pending = await service.slackRegistration.managerGrants.pending(state, actor); + await assertConnectionManager(req, pending.current.endpoint.companyId); + if (req.get("accept")?.includes("text/html") && isCrossSiteOAuthCallbackNavigation(req)) { + res.type("html").send(oauthCallbackInterstitialHtml()); return; + } + const id = await service.slackRegistration.managerGrants.complete(state, + typeof req.query.claim_id === "string" ? req.query.claim_id : null, + typeof req.query.error === "string" ? req.query.error : null, actor, + typeof req.query.code === "string" ? req.query.code : null); + res.redirect(303, await service.slackRegistration.returnPath(id)); + }); + router.post("/chat-endpoints/:endpointId/slack/test-message", validate(slackTestMessageSchema), async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.sendSlackTestMessage(endpointId(req), slackActor(req), req.body)); + }); router.post("/chat-endpoints/:endpointId/slack/registration", validate(slackRegistrationSchema), async (req, res) => { if (!(await assertEndpointManagementAccess(req, res))) return; res.set("Cache-Control", "no-store"); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index f39cd53431a..9b113c62805 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1,5 +1,5 @@ import { updateFastResponseSchema } from "@paperclipai/shared"; -import { slackRegistrationSchema, slackSetupActionSchema, slackInstallAuthorizationSchema, slackRegistrationStateSchema, slackAppConfigurationSchema, slackAvatarStateSchema, slackAccountStateSchema } from "@paperclipai/shared"; +import { slackTestMessageSchema, slackTestMessageResultSchema, slackManagerAuthorizationSchema, slackSetupOptionsSchema, slackManagedProvisionSchema, slackManagedResultSchema, slackRegistrationSchema, slackSetupActionSchema, slackInstallAuthorizationSchema, slackRegistrationStateSchema, slackAppConfigurationSchema, slackAvatarStateSchema, slackAccountStateSchema } from "@paperclipai/shared"; import { experimentalApiMetadata } from "./experimental-api-metadata.js"; import { experimentalApiPaths, @@ -803,8 +803,9 @@ const chatAdapterCapabilitiesResponseSchema = z const chatEndpointSetupResponseSchema = z .object({ step: z.enum(["choose_agent", "provider_setup", "test", "complete"]), - slackSetupMethod: z.enum(["automatic", "manual", "existing"]).optional(), + slackSetupMethod: z.enum(["managed", "automatic", "manual", "existing"]).optional(), slackRegistration: slackRegistrationStateSchema.optional(), + slackManagerError: z.string().optional(), slackAvatar: slackAvatarStateSchema.optional(), slackAccount: slackAccountStateSchema.optional(), slackOAuthCallbackUri: z.string().nullable().optional(), @@ -2563,6 +2564,41 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", path: "/api/companies/{companyId}/chat-slack/setup-options", tags: ["chat-channels"], + summary: "Discover Slack setup methods and the current user's authorized workspaces", + description: "Managed setup is advertised only for approved Cloud deployments with the broker profile enabled. Self-hosted discovery makes no hosted-service requests. Grants are personal and company scoped.", + request: { params: z.object({ companyId: z.string().uuid() }) }, responses: { 200: r.ok(slackSetupOptionsSchema), 401: r.unauthorized, 403: r.forbidden }, +}); +for (const action of ["authorize", "provision"] as const) registry.registerPath({ + method: "post", path: `/api/chat-endpoints/{endpointId}/slack/managed/${action}`, tags: ["chat-channels"], + summary: action === "authorize" ? "Authorize a personal Slack manager workspace grant" : "Provision the saved managed Slack app", + description: "Requires the selected managed method, company membership, connection-management permission and Cloud availability. Authorization state is actor/session bound and single use. Provisioning accepts a personal grant ID and idempotent request ID; no provider credentials or arbitrary manifests are accepted. Explicit reconnect uses reconnect=true with a fresh request ID to reinstall the saved app. Repeating that reconnect request returns its saved result without reinstalling a healthy connection.", + request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(action === "authorize" ? slackSetupActionSchema : slackManagedProvisionSchema) }, + responses: { 200: r.ok(action === "authorize" ? slackManagerAuthorizationSchema : slackManagedResultSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable }, +}); +registry.registerPath({ + method: "delete", path: "/api/companies/{companyId}/chat-slack/manager-grants/{grantId}", tags: ["chat-channels"], + summary: "Revoke the current user's workspace grant for future managed provisioning", + description: "Removes local manager credentials and invalidates pending manager authorization. Installed bots remain connected; this does not uninstall Slack apps.", + request: { params: z.object({ companyId: z.string().uuid(), grantId: z.string().uuid() }) }, responses: { 204: { description: "Personal manager grant revoked" }, 401: r.unauthorized, 403: r.forbidden }, +}); +registry.registerPath({ + method: "get", path: "/api/chat-slack/managed/oauth/callback", tags: ["chat-channels"], + summary: "Redeem a sealed manager authorization and return to Slack setup", + description: "Authenticated, same-origin continuation with company, actor, session, endpoint revision and configured-origin checks. The broker handoff is single use; credentials are never returned to the browser. Explicit local development qualification accepts a Slack code instead of a broker claim; production ignores code.", + request: { query: z.object({ state: z.string(), claim_id: z.string().optional(), code: z.string().optional(), error: z.string().optional() }) }, + responses: { 200: { description: "Same-origin continuation page" }, 303: { description: "Return to saved setup" }, 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 409: r.conflict }, +}); + +registry.registerPath({ + method: "post", path: "/api/chat-endpoints/{endpointId}/slack/test-message", tags: ["chat-channels"], + summary: "Connect the current user's Slack identity and send a test DM", + description: "Requires a board connection manager and active company membership. Reuses only the actor's verified identity in the same workspace; otherwise returns paginated account choices and sends a private connection challenge. The account holder must confirm it while signed in before linking or test delivery. Revoked and conflicting links cannot be overwritten. The request ID prevents duplicate or ambiguous sends from being replayed.", + request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(slackTestMessageSchema) }, + responses: { 200: r.ok(slackTestMessageResultSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable, 429: { description: "Wait before sending another test message" } }, +}); + for (const action of ["registration", "install", "resume"] as const) { registry.registerPath({ method: "post", path: `/api/chat-endpoints/{endpointId}/slack/${action}`, tags: ["chat-channels"], diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 31338f7acc1..e1077280799 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1,3 +1,6 @@ +import { slackTestMessageService } from "./connectors/slack/test-message.js"; +import type { SlackSetupOptions } from "./connectors/slack/setup/registration.js"; +import { isProvisionedSlackSetup } from "@paperclipai/shared"; import { createTeamsFileAuthority } from "./chat-channels/microsoft-teams/file-transfer-authority.js"; import { createPhotonEvents } from "./chat-channels/imessage-photon/events.js"; import { createTelegramPublicationDrafts } from "./chat-channels/telegram/publication-drafts.js"; @@ -1297,6 +1300,7 @@ export interface ChatChannelServiceOptions { claimId: string; }) => Promise; renderSlackAvatar?: (request: AgentAvatarRequest) => Promise; + slackManaged?: import("./connectors/slack/setup/registration.js").SlackSetupOptions["managed"]; storage?: StorageService; } @@ -1682,6 +1686,7 @@ function providerSetupState( slackApp: endpoint.setup.slackApp, slackSetupMethod: endpoint.setup.slackSetupMethod, slackAvatar: endpoint.setup.slackAvatar, + slackManagerError: endpoint.setup.slackManagerError, slackAccount: endpoint.setup.slackAccount, // Slack registers the slash command in the provider configuration. // Keep that identity immutable when the assigned agent is renamed; @@ -4414,7 +4419,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { actorUserId?: string | null, ) { if ((input.provider as string) === "agentmail") throw badRequest("Use the email inbox setup API for AgentMail"); - if (input.slackApp && input.provider !== "slack") throw unprocessable("Slack app details only apply to Slack connections"); + if ((input.slackApp || input.slackSetupMethod) && input.provider !== "slack") throw unprocessable("Slack app details only apply to Slack connections"); + if (input.slackSetupMethod === "managed" && slackRegistration.managerGrants.defaultMethod() !== "managed") throw conflict("Managed Slack setup is unavailable"); const agent = await db .select({ id: agents.id, name: agents.name, status: agents.status }) .from(agents) @@ -4526,7 +4532,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { step: "provider_setup", ...(input.provider === "slack" ? { slackApp: input.slackApp ?? defaultSlackAppConfiguration(agent.name), - command: (input.slackApp ?? defaultSlackAppConfiguration(agent.name)).command, slackSetupMethod: "automatic" } + command: (input.slackApp ?? defaultSlackAppConfiguration(agent.name)).command, slackSetupMethod: input.slackSetupMethod ?? slackRegistration.managerGrants.defaultMethod() } : {}), }, }); @@ -4572,13 +4578,14 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } values.communicationInstructions = input.communicationInstructions; } + if (input.slackSetupMethod === "managed" && slackRegistration.managerGrants.defaultMethod() !== "managed") throw conflict("Managed Slack setup is unavailable"); if (input.slackSetupMethod && (existing.endpoint.provider !== "slack" || existing.endpoint.botExternalId || existing.endpoint.status !== "draft")) { throw conflict("Setup method can only change before connecting the app"); } if (input.slackApp || input.slackSetupMethod) { const [registration] = await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)); - if (registration && registration.status !== "failed") { - if (input.slackApp || input.slackSetupMethod === "automatic") throw conflict("Slack app details are locked after creation starts"); + if (registration && (registration.managerGrantId || registration.status !== "failed")) { + if (registration.managerGrantId || input.slackSetupMethod === "managed" || input.slackApp || input.slackSetupMethod === "automatic") throw conflict("Slack app details are locked after creation starts"); // Manual recovery fences pending authorization under the same credential lease. await slackRegistration.cleanup(endpointId, credentialLease); } @@ -6886,7 +6893,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { await syncSpekoVoiceTools(tx, endpoint, actorUserId, true, options.allowLocalVoiceBoard === true); } const observedSlackUrl = (current.setup as InternalSetupState).slackCallbackSurfaces?.events?.url; - const preserveSlackVerification = current.setup.slackSetupMethod === "automatic" + const preserveSlackVerification = isProvisionedSlackSetup(current.setup.slackSetupMethod) && (current.setup as InternalSetupState).slackVerificationSigningFingerprint === createHash("sha256").update(credentials.signingSecret ?? "").digest("hex") && Boolean(observedSlackUrl && slackCallbackMatchesPublicUrl(observedSlackUrl, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/slack`)); await notifyChatEndpointWork(tx); @@ -18637,7 +18644,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .where(and(eq(toolConnections.id, current.connectionId), eq(toolConnections.companyId, current.companyId))); if (!connection || connection.status === "archived") return new Response("ignored", { status: 200 }); let secret = (await resolveCredentialRefs(current, connection.refs.filter(ref => ref.configPath.replace(/^credentials\./, "") === "signingSecret"), tx)).signingSecret; - if (!secret && current.setup.slackSetupMethod === "automatic") { + if (!secret && isProvisionedSlackSetup(current.setup.slackSetupMethod)) { const [registration] = await tx.select().from(chatSlackRegistrations) .where(and(eq(chatSlackRegistrations.endpointId, current.id), eq(chatSlackRegistrations.companyId, current.companyId))); if (registration?.status !== "removed" && registration?.secretIds.signingSecret) { @@ -19252,7 +19259,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { // Recheck the signature under the current credential/runtime fence; // an SDK acknowledgement alone is not authentication evidence. let signingFingerprint: string | null = null; - if (!currentSetup.webhookVerifiedAt && currentSetup.slackSetupMethod === "automatic" + if (!currentSetup.webhookVerifiedAt && isProvisionedSlackSetup(currentSetup.slackSetupMethod) && callback.surface === "events" && !callback.isUrlVerification && slackCallbackMatchesPublicUrl(callback.url, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${current.publicId}/slack`)) { const payload = JSON.parse(eventBody) as { type?: string; team_id?: string; api_app_id?: string; event_id?: string; event?: { type?: string } }; @@ -26107,8 +26114,32 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return get(endpointId); } + const linkSlackAccount: SlackSetupOptions["linkInstaller"] = async (endpointId, user, actor, lease) => { + const record = await endpointRecord(endpointId); + if (!record || record.endpoint.provider !== "slack" || record.endpoint.providerAccountId !== user.team_id) throw conflict("Slack setup changed"); + const { principal } = await ensurePrincipal(record.endpoint, { + userId: String(user.id), userName: typeof user.name === "string" ? user.name : String(user.id), + fullName: typeof user.real_name === "string" ? user.real_name : String(user.id), isBot: false, isMe: false, + }); + await db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${record.endpoint.companyId}:${principal.id}`}, 0))`); + const membership = (await tx.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, record.endpoint.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, actor.userId))).for("update"))[0]; + if (membership?.status !== "active" || membership.membershipRole === "viewer") throw forbidden("The signed-in Paperclip account cannot use this connection"); + const links = await tx.select().from(chatIdentityLinks).where(and(eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.principalId, principal.id))).for("update"); + if (links.some(link => link.status === "linked" && link.paperclipUserId !== actor.userId) || links.some(link => link.endpointId === endpointId && link.status === "revoked")) throw conflict("This Slack account has an existing link", { code: "chat_identity_link_conflict" }); + if (links.some(link => link.endpointId === endpointId && link.status === "linked")) return; + await tx.insert(chatIdentityLinks).values({ companyId: record.endpoint.companyId, endpointId, principalId: principal.id, + paperclipUserId: actor.userId, status: "linked", confirmedAt: new Date() }) + .onConflictDoUpdate({ target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId], set: { + paperclipUserId: actor.userId, status: "linked", confirmationTokenHash: null, expiresAt: null, confirmedAt: new Date(), revokedAt: null, updatedAt: new Date(), + } }); + await lease.assertOwned(tx); + }); + }; + const slackRegistration = slackChatRegistrationService(db, { - publicOrigin: getPublicBaseUrl, webhookOrigin: getWebhookPublicBaseUrl, fetch: fetchImpl, renderAvatar: options.renderSlackAvatar, + publicOrigin: getPublicBaseUrl, webhookOrigin: getWebhookPublicBaseUrl, fetch: fetchImpl, renderAvatar: options.renderSlackAvatar, managed: options.slackManaged, withLock: async (endpointId, work) => { const record = await endpointRecord(endpointId); if (!record) throw notFound("Chat endpoint not found"); @@ -26136,29 +26167,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .where(and(eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.endpointId, endpointId), eq(chatIdentityLinks.status, "linked"), eq(chatIdentityLinks.paperclipUserId, account.paperclipUserId), eq(chatExternalPrincipals.externalId, account.externalUserId), eq(chatExternalPrincipals.providerAccountId, record.endpoint.providerAccountId!))); return Boolean(link); }, - linkInstaller: async (endpointId, user, actor, lease) => { - const record = await endpointRecord(endpointId); - if (!record || record.endpoint.provider !== "slack" || record.endpoint.providerAccountId !== user.team_id) throw conflict("Slack setup changed"); - const { principal } = await ensurePrincipal(record.endpoint, { - userId: String(user.id), userName: typeof user.name === "string" ? user.name : String(user.id), - fullName: typeof user.real_name === "string" ? user.real_name : String(user.id), isBot: false, isMe: false, - }); - await db.transaction(async tx => { - await lease.assertOwned(tx); - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${record.endpoint.companyId}:${principal.id}`}, 0))`); - const membership = (await tx.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, record.endpoint.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, actor.userId))).for("update"))[0]; - if (membership?.status !== "active" || membership.membershipRole === "viewer") throw forbidden("The signed-in Paperclip account cannot use this connection"); - const links = await tx.select().from(chatIdentityLinks).where(and(eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.principalId, principal.id))).for("update"); - if (links.some(link => link.status === "linked" && link.paperclipUserId !== actor.userId) || links.some(link => link.endpointId === endpointId && link.status === "revoked")) throw conflict("This Slack account has an existing link", { code: "chat_identity_link_conflict" }); - if (links.some(link => link.endpointId === endpointId && link.status === "linked")) return; - await tx.insert(chatIdentityLinks).values({ companyId: record.endpoint.companyId, endpointId, principalId: principal.id, - paperclipUserId: actor.userId, status: "linked", confirmedAt: new Date() }) - .onConflictDoUpdate({ target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId], set: { - paperclipUserId: actor.userId, status: "linked", confirmationTokenHash: null, expiresAt: null, confirmedAt: new Date(), revokedAt: null, updatedAt: new Date(), - } }); - await lease.assertOwned(tx); - }); - }, + linkInstaller: linkSlackAccount, configure: async (endpointId, credentials, actor, lease) => { const record = await endpointRecord(endpointId); if (!record) throw notFound("Chat endpoint not found"); @@ -26168,6 +26177,28 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }, }); + const sendSlackTestMessage = slackTestMessageService(db, { + fetch: fetchImpl, + withLock: async (endpointId, work) => { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + return withCredentialMutationLease(record.endpoint, work); + }, + runtimeBotToken: async endpointId => { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + return (await resolveCredentials(record.endpoint)).botToken; + }, + linkInstaller: linkSlackAccount, + ensureAccount: async (endpointId, user) => { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + const { principal } = await ensurePrincipal(record.endpoint, { userId: String(user.id), userName: String(user.name ?? user.id), fullName: String(user.real_name ?? user.id), isBot: false, isMe: false }); + return principal.id; + }, + createLinkIntent, + }); + const githubRegistration = githubChatRegistrationService(db, { publicOrigin: () => getPublicBaseUrl(), webhookOrigin: () => getWebhookPublicBaseUrl(), fetch: fetchImpl, storeApp: async (endpointId, userId, app) => { await storeGitHubApp(endpointId, userId, { appId: app.appId, privateKey: app.privateKey, webhookSecret: app.webhookSecret, ...(app.clientId ? { clientId: app.clientId } : {}), ...(app.clientSecret ? { clientSecret: app.clientSecret } : {}) }, "manifest"); }, @@ -26345,6 +26376,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { voice, authorizeFastResponse, slackRegistration, + sendSlackTestMessage, saveGitHubSetupProgress: async (endpointId: string, stage: NonNullable["stage"]) => { const record = await endpointRecord(endpointId); if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found"); diff --git a/server/src/services/chat-slack-registration-cleanup.ts b/server/src/services/chat-slack-registration-cleanup.ts index 22a2b758598..97292a773a5 100644 --- a/server/src/services/chat-slack-registration-cleanup.ts +++ b/server/src/services/chat-slack-registration-cleanup.ts @@ -6,8 +6,11 @@ import type { CredentialMutationLeaseGuard } from "./chat-credential-mutation-le // Keep generic connection removal independent of the registration service's // authorization graph; access routes already load tool-access through the barrel. export async function removeSlackRegistration(db: Db, endpointId: string, lease: CredentialMutationLeaseGuard) { + await lease.assertOwned(); const vault = secretService(db); const [row] = await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)); + const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)); + if (endpoint) await db.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, endpoint.connectionId), like(toolOauthStates.state, "slack-manager.%"))); if (!row) return; await lease.assertOwned(); await db.update(chatSlackRegistrations).set({ status: "removed", updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); @@ -16,4 +19,4 @@ export async function removeSlackRegistration(db: Db, endpointId: string, lease: for (const id of Object.values(row.secretIds)) { await lease.assertOwned(); await vault.remove(id); } await lease.assertOwned(); await db.update(chatSlackRegistrations).set({ secretIds: {} }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - } +} diff --git a/server/src/services/chat-slack-registration.ts b/server/src/services/chat-slack-registration.ts index 905c7ab0346..ea187cfff2f 100644 --- a/server/src/services/chat-slack-registration.ts +++ b/server/src/services/chat-slack-registration.ts @@ -1,531 +1,3 @@ -import { notifyChatVerificationWork } from "./chat-work-notifications.js"; -import { createAgentAvatarPool } from "./agent-avatar-pool.js"; -import type { AgentAvatarRequest } from "./agent-avatars.js"; -import { removeSlackRegistration } from "./chat-slack-registration-cleanup.js"; -import { createHash, randomBytes } from "node:crypto"; -import { and, eq, gt, inArray, like, sql } from "drizzle-orm"; -import { agents, chatEndpoints, chatSlackRegistrations, companies, toolConnections, toolOauthStates, type Db } from "@paperclipai/db"; -import { resolveAgentAppearance, buildSlackAppManifest, slackAppConfigurationSchema, slackRegistrationErrorMessage, type SlackRegistrationInput, type SlackRegistrationState, type SlackAvatarState, type SlackAccountState } from "@paperclipai/shared"; -import { badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js"; -import { accessService } from "./access.js"; -import { logActivity } from "./activity-log.js"; -import { logger } from "../middleware/logger.js"; -import { instanceSettingsService } from "./instance-settings.js"; -import { secretService } from "./secrets.js"; -import { writeConnectionCredential } from "./connection-credentials.js"; -import type { CredentialMutationLeaseGuard } from "./chat-credential-mutation-lease.js"; - -export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean } -type Registration = typeof chatSlackRegistrations.$inferSelect; -const prefix = "slack-install."; -const timeoutMs = 20_000; -const object = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -const providerFailure = (code: string) => unprocessable(slackRegistrationErrorMessage(code), { code }); -const knownProviderErrors: Record = { - invalid_auth: "slack_configuration_token_invalid", token_expired: "slack_configuration_token_invalid", - token_revoked: "slack_configuration_token_invalid", not_authed: "slack_configuration_token_invalid", - invalid_manifest: "slack_manifest_invalid", invalid_app: "slack_manifest_invalid", - ratelimited: "slack_setup_rate_limited", no_permission: "slack_setup_permission_denied", - missing_scope: "slack_setup_permission_denied", not_allowed_token_type: "slack_configuration_token_invalid", -}; - -export function slackRegistrationProjection(row: Registration): SlackRegistrationState { - return { - status: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "uncertain" : row.status, - appId: row.appId, - managementUrl: row.appId ? `https://api.slack.com/apps/${encodeURIComponent(row.appId)}` : "https://api.slack.com/apps", - errorCode: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "slack_creation_uncertain" : row.errorCode, - }; -} - -export function slackChatRegistrationService(db: Db, options: { - publicOrigin: () => string | null; - webhookOrigin: () => string | null; - fetch?: typeof fetch; - renderAvatar?: (request: AgentAvatarRequest) => Promise; - withLock: (endpointId: string, work: (lease: CredentialMutationLeaseGuard) => Promise) => Promise; - runtimeSigningSecret: (endpointId: string) => Promise; - runtimeBotToken: (endpointId: string) => Promise; - canWelcome: (endpointId: string, account: SlackAccountState) => Promise; - linkInstaller: (endpointId: string, user: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; - configure: (endpointId: string, credentials: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; -}) { - const fetchImpl = options.fetch ?? fetch; - const vault = secretService(db); - let avatarPool: ReturnType | undefined; - function origin(value: string | null) { - if (!value) throw badRequest("Configure a public HTTPS URL before creating a Slack app"); - const url = new URL(value); - if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) - throw badRequest("Slack setup requires a configured public HTTPS origin"); - return url.origin; - } - const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/oauth/callback`; - async function registration(endpointId: string) { - return (await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)))[0]; - } - async function endpoint(endpointId: string, actor: SlackSetupActor) { - if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); - const [row] = await db.select({ endpoint: chatEndpoints, connection: toolConnections, agentName: agents.name, agentAppearance: agents.appearance }) - .from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId))) - .innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId))) - .where(and(eq(chatEndpoints.id, endpointId), eq(chatEndpoints.provider, "slack"))); - if (!row || row.endpoint.status === "archived" || row.connection.status === "archived") throw notFound("Slack connection not found"); - if (!actor.bypassPermissionCheck && !await accessService(db).hasPermission(row.endpoint.companyId, "user", actor.userId, "tools:manage_connections")) - throw forbidden("Missing permission: tools:manage_connections"); - return row; - } - function assertOrigins(row: Registration, publicId: string) { - if (row.callbackUri !== callbackUri()) throw conflict("The Paperclip URL changed. Restore its configured address or update the Slack app and use manual setup."); - const expected = `${origin(options.webhookOrigin())}/api/chat-webhooks/${publicId}/slack`; - if (object(object(row.manifest.settings).event_subscriptions).request_url !== expected) - throw conflict("The Slack webhook address changed. Restore its configured address or update the Slack app and use manual setup."); - } - async function audit(row: Registration, actor: SlackSetupActor, action: string, code?: string) { - await logActivity(db, { companyId: row.companyId, actorType: "user", actorId: actor.userId, - action: `chat_slack.${action}`, entityType: "chat_endpoint", entityId: row.endpointId, - details: { endpointId: row.endpointId, appId: row.appId, ...(code ? { code } : {}) } }); - } - async function api(method: string, fields: Record | FormData, bearer?: string) { - const response = await fetchImpl(`https://slack.com/api/${method}`, { - method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs), - headers: { ...(fields instanceof FormData ? {} : { "content-type": "application/x-www-form-urlencoded" }), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }, - body: fields instanceof FormData ? fields : new URLSearchParams(fields), - }); - const result = object(await response.json()); - if (!response.ok || result.ok !== true) { - const code = knownProviderErrors[String(result.error)] ?? (response.status === 429 ? "slack_setup_rate_limited" : "slack_provider_failure"); - throw providerFailure(code); - } - return result; - } - async function saveSecrets(row: Registration, values: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, - patch: Partial = {}, account?: SlackAccountState) { - await db.transaction(async tx => { - await lease.assertOwned(tx); - const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; - if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); - const ids = { ...current.secretIds }; - for (const [key, value] of Object.entries(values)) { - const result = await writeConnectionCredential(tx, { companyId: row.companyId, connectionName: "Slack app registration", - configPath: `slack_registration.${key}`, label: key, value, actor: { userId: actor.userId }, - ...(ids[key] ? { existingRef: { secretId: ids[key], configPath: `slack_registration.${key}` } } : {}) }); - ids[key] = result.secret.id; - } - await notifyChatVerificationWork(tx); - await tx.update(chatSlackRegistrations).set({ ...patch, secretIds: ids, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, row.endpointId)); - if (account) { - const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; - if (!saved || saved.status === "archived") throw conflict("Slack setup changed"); - // Reauthorization never changes an established personal account binding. - if (saved.setup.slackAccount?.status !== "linked") await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); - } - await lease.assertOwned(tx); - }); - } - async function readSecret(row: Registration, key: string) { - if (!row.secretIds[key]) throw conflict("Slack setup credentials are unavailable. Resume the saved connection."); - return vault.resolveSecretValue(row.companyId, row.secretIds[key], "latest", { - accessContext: { consumerType: "system", consumerId: `slack-registration:${row.endpointId}`, configPath: `slack_registration.${key}`, actorType: "system" }, - }); - } - async function setFailure(row: Registration, status: Registration["status"], code: string, lease: CredentialMutationLeaseGuard) { - await lease.assertOwned(); - await db.update(chatSlackRegistrations).set({ status, errorCode: code, updatedAt: new Date() }) - .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); - } - async function saveAvatar(row: Registration, avatar: SlackAvatarState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - await endpoint(row.endpointId, actor); - await db.transaction(async tx => { - await lease.assertOwned(tx); - const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; - if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); - const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; - if (!saved || saved.status === "archived" || saved.setup.slackSetupMethod !== "automatic") throw conflict("Slack setup changed. Resume the saved connection."); - await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAvatar: avatar }, updatedAt: new Date() }) - .where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))); - await lease.assertOwned(tx); - }); - } - async function create(endpointId: string, actor: SlackSetupActor, input: SlackRegistrationInput) { - return options.withLock(endpointId, async lease => { - const current = await endpoint(endpointId, actor); - const previous = await registration(endpointId); - if (previous?.status === "removed") throw conflict("This registration was removed. Continue the selected manual setup."); - if (previous?.appId) { - if (previous.errorCode === "slack_manifest_update_pending") { - await configureManifest(previous, actor, input.credentials.configurationToken, lease); - } - return; - } - if (previous?.requestId === input.requestId) return; - if (previous && ["creating", "uncertain"].includes(previous.status) && !input.confirmedNoAppCreated) { - await setFailure(previous, "uncertain", "slack_creation_uncertain", lease); - return; - } - if (current.endpoint.status !== "draft" || current.endpoint.botExternalId || current.endpoint.setup.slackSetupMethod !== "automatic") - throw conflict("Automatic creation is only available for a new Slack connection"); - const app = slackAppConfigurationSchema.parse(current.endpoint.setup.slackApp); - const redirect = callbackUri(); - const manifest = buildSlackAppManifest({ app, agentName: current.agentName, - webhookUrl: `${origin(options.webhookOrigin())}/api/chat-webhooks/${current.endpoint.publicId}/slack`, redirectUri: redirect }); - // Slack cannot authenticate a challenge until its creation response gives us - // the signing secret. Add event subscriptions only after that secret is durable. - const { event_subscriptions: _events, ...creationSettings } = manifest.settings; - const creationManifest = { ...manifest, settings: creationSettings }; - try { await api("apps.manifest.validate", { manifest: JSON.stringify(manifest) }, input.credentials.configurationToken); } - catch (error) { - if (error instanceof Error && "status" in error) throw error; - throw providerFailure("slack_provider_failure"); - } - // No provider creation has happened until this intent is durable. - await endpoint(endpointId, actor); - await lease.assertOwned(); - const values = { companyId: current.endpoint.companyId, requestId: input.requestId, status: "creating" as const, - manifest, manifestHash: createHash("sha256").update(JSON.stringify(manifest)).digest("hex"), callbackUri: redirect, - createdByUserId: actor.userId, errorCode: null, updatedAt: new Date() }; - const [row] = await db.insert(chatSlackRegistrations).values({ endpointId, ...values }) - .onConflictDoUpdate({ target: chatSlackRegistrations.endpointId, set: values }).returning(); - await audit(row, actor, "creation_started"); - let createdAppId: string; - try { - const result = await api("apps.manifest.create", { manifest: JSON.stringify(creationManifest) }, input.credentials.configurationToken); - const credentials = object(result.credentials); - if (typeof result.app_id !== "string" || !/^A[A-Z0-9]+$/.test(result.app_id) - || typeof credentials.client_id !== "string" || !/^\d+\.\d+$/.test(credentials.client_id) - || typeof credentials.client_secret !== "string" || !credentials.client_secret - || typeof credentials.signing_secret !== "string" || !credentials.signing_secret) - throw new Error("Incomplete Slack creation response"); - await endpoint(endpointId, actor); - await saveSecrets(row, { signingSecret: credentials.signing_secret, clientSecret: credentials.client_secret }, actor, lease, - { appId: result.app_id, clientId: credentials.client_id, status: "install", errorCode: "slack_manifest_update_pending" }); - createdAppId = result.app_id; - } catch (error) { - // Only documented rejection responses prove that creation did not happen. - const code = object(object(error).details).code; - const rejected = typeof code === "string" && Object.values(knownProviderErrors).includes(code); - await setFailure(row, rejected ? "failed" : "uncertain", rejected ? code : "slack_creation_uncertain", lease); - await audit(row, actor, "creation_failed", rejected ? code : "slack_creation_uncertain"); - return; - } - // The app and credentials are durable before optional icon provisioning. - // A retry/restart always reuses this app, even if icon upload is interrupted. - const created = { ...row, appId: createdAppId }; - if (!await configureManifest(created, actor, input.credentials.configurationToken, lease)) return; - await saveAvatar(created, { status: "pending" }, actor, lease); - let avatar: SlackAvatarState; - try { - await endpoint(endpointId, actor); - await lease.assertOwned(); - // Cloud ingress requires a tenant session. Send the preset PNG bytes so - // Slack does not need to fetch an authenticated Paperclip URL. - const render = options.renderAvatar ?? (avatarPool ??= createAgentAvatarPool()).render; - const appearance = { ...resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId) }; - delete appearance.customAvatarAssetId; // The preset renderer cannot read private uploaded assets. - const png = await render({ appearance, - size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" }); - await endpoint(endpointId, actor); - await lease.assertOwned(); - const upload = new FormData(); - upload.set("app_id", createdAppId); - upload.set("file", new Blob([new Uint8Array(png)], { type: "image/png" }), "agent-avatar.png"); - await api("apps.icon.set", upload, input.credentials.configurationToken); - avatar = { status: "uploaded", uploadedAt: new Date().toISOString() }; - } catch { - // Provider echoes (including tokens) never become saved state or activity. - avatar = { status: "failed", errorCode: "slack_avatar_upload_failed" }; - } - await saveAvatar(created, avatar, actor, lease); - try { await audit(created, actor, avatar.status === "uploaded" ? "avatar_uploaded" : "avatar_upload_failed", avatar.status === "failed" ? avatar.errorCode : undefined); } - catch { logger.warn({ endpointId, appId: createdAppId }, "Slack avatar activity could not be recorded"); } - // An audit outage cannot change the saved creation outcome. - try { await audit({ ...row, appId: createdAppId }, actor, "app_created"); } - catch { logger.warn({ endpointId, appId: createdAppId }, "Slack app creation activity could not be recorded"); } - }); - } - async function configureManifest(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard) { - const current = await endpoint(row.endpointId, actor); - if (current.endpoint.status !== "draft" || current.endpoint.setup.slackSetupMethod !== "automatic") - throw conflict("Resume the saved Slack connection before configuring its app"); - assertOrigins(row, current.endpoint.publicId); - await lease.assertOwned(); - try { - await api("apps.manifest.update", { app_id: row.appId!, manifest: JSON.stringify(row.manifest) }, configurationToken); - } catch { - // The pending marker was saved with the app secrets before dispatch. Retrying - // the same manifest is safe even when Slack's response was lost. - await audit(row, actor, "manifest_update_failed", "slack_manifest_update_pending"); - return false; - } - await endpoint(row.endpointId, actor); - await lease.assertOwned(); - await db.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }) - .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); - await audit(row, actor, "manifest_updated"); - return true; - } - async function install(endpointId: string, actor: SlackSetupActor) { - return options.withLock(endpointId, async lease => { - const current = await endpoint(endpointId, actor); - const row = await registration(endpointId); - if (!row?.appId || !row.clientId || row.status === "removed") throw conflict("Create this Slack app before installing it"); - if (row.errorCode === "slack_manifest_update_pending") throw conflict("Finish configuring the saved Slack app before installing it"); - if (current.endpoint.setup.slackSetupMethod !== "automatic" || current.endpoint.status === "paused") throw conflict("Resume this connection before installing it"); - assertOrigins(row, current.endpoint.publicId); - const state = `${prefix}${randomBytes(32).toString("base64url")}`; - const expiresAt = new Date(Date.now() + 10 * 60_000); - const scopes = object(object(row.manifest.oauth_config).scopes).bot as string[]; - await db.transaction(async tx => { - await lease.assertOwned(tx); - await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, current.connection.id), like(toolOauthStates.state, `${prefix}%`))); - await tx.insert(toolOauthStates).values({ state, companyId: row.companyId, connectionId: current.connection.id, - codeVerifier: JSON.stringify({ endpointId, requestId: row.requestId, appId: row.appId, callbackUri: row.callbackUri, - endpointStatus: current.endpoint.status, runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }), - createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId, - requestedScopes: scopes, expiresAt }); - await tx.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - }); - const url = new URL("https://slack.com/oauth/v2/authorize"); - url.search = new URLSearchParams({ client_id: row.clientId, scope: scopes.join(","), state, redirect_uri: row.callbackUri, - ...(row.workspaceId ? { team: row.workspaceId } : {}) }).toString(); - await audit(row, actor, "installation_started"); - return { authorizationUrl: url.toString(), expiresAt: expiresAt.toISOString() }; - }); - } - async function expiredReturn(state: string, actor: SlackSetupActor) { - if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) return null; - const [attempt] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state)); - if (!attempt || attempt.expiresAt.getTime() > Date.now() || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) return null; - const binding = object(JSON.parse(attempt.codeVerifier)); - const current = await endpoint(String(binding.endpointId), actor); - return options.withLock(current.endpoint.id, async lease => { - const latest = await endpoint(current.endpoint.id, actor); - const row = await registration(current.endpoint.id); - if (!row || row.status === "removed" || row.requestId !== binding.requestId || row.companyId !== attempt.companyId || latest.connection.id !== attempt.connectionId - || latest.endpoint.setup.slackSetupMethod !== "automatic" || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri) return null; - assertOrigins(row, latest.endpoint.publicId); - await lease.assertOwned(); - await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); - await setFailure(row, row.status, "slack_install_expired", lease); - await audit(row, actor, "installation_failed", "slack_install_expired"); - return returnPath(row.endpointId); - }); - } - async function pending(state: string, actor: SlackSetupActor) { - if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) throw badRequest("Invalid Slack authorization state"); - const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))); - if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) - throw forbidden("Slack authorization expired or belongs to another session. Resume setup and install again."); - const binding = object(JSON.parse(attempt.codeVerifier)); - const current = await endpoint(String(binding.endpointId), actor); - const row = await registration(current.endpoint.id); - if (!row || row.status === "removed" || current.endpoint.setup.slackSetupMethod !== "automatic" - || current.endpoint.status === "paused" - || current.endpoint.status === "revoked" && binding.endpointStatus !== "revoked" - || binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0) - || row.requestId !== binding.requestId || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri - || row.callbackUri !== callbackUri() || attempt.companyId !== row.companyId || attempt.connectionId !== current.connection.id) - throw conflict("Slack setup changed. Resume the saved connection and install again."); - assertOrigins(row, current.endpoint.publicId); - return { attempt, current, row }; - } - async function saveAccount(row: Registration, account: SlackAccountState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - await endpoint(row.endpointId, actor); - await db.transaction(async tx => { - await lease.assertOwned(tx); - const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; - const registrationNow = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)))[0]; - if (!saved || saved.status === "archived" || registrationNow?.status === "removed" || registrationNow?.requestId !== row.requestId) throw conflict("Slack setup changed"); - await notifyChatVerificationWork(tx); - await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); - await lease.assertOwned(tx); - }); - } - async function linkAccount(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - const current = await endpoint(row.endpointId, actor); - const account = current.endpoint.setup.slackAccount; - if (!account) throw providerFailure("slack_install_account_missing"); - if (account.status === "linked") return; - const token = row.secretIds.botToken ? await readSecret(row, "botToken") : await options.runtimeBotToken(row.endpointId); - const user = object((await api("users.info", { user: account.externalUserId }, token)).user); - if (user.id !== account.externalUserId || user.team_id !== row.workspaceId || user.is_bot !== false || user.is_app_user === true || user.deleted !== false) throw providerFailure("slack_install_account_missing"); - await endpoint(row.endpointId, actor); - await options.linkInstaller(row.endpointId, user, { ...actor, userId: account.paperclipUserId }, lease); - await saveAccount(row, { ...account, status: "linked" }, actor, lease); - try { await audit(row, actor, "account_linked"); } - catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack account activity could not be recorded"); } - } - async function sendSetupMessage(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, kind: "welcome" | "verification") { - const current = await endpoint(row.endpointId, actor); - const account = current.endpoint.setup.slackAccount; - if (!account || account.status !== "linked") return; - if (kind === "verification" && (!current.endpoint.setup.webhookVerifiedAt || ["pending", "sending"].includes(account.welcomeStatus))) return; - const field = kind === "welcome" ? "welcomeStatus" : "verificationStatus"; - const status = account[field] ?? "pending"; - if (status === "sending") { - await saveAccount(row, { ...account, [field]: "uncertain" }, actor, lease); - return; // Never replay an ambiguous dispatch after a process restart. - } - if (status !== "pending") return; - await saveAccount(row, { ...account, [field]: "sending" }, actor, lease); - let result: SlackAccountState; - try { - await endpoint(row.endpointId, actor); - await lease.assertOwned(); - if (!await options.canWelcome(row.endpointId, account)) throw forbidden("Slack account access changed"); - const sent = await api("chat.postMessage", { channel: account.dmChannelId ?? account.externalUserId, - text: kind === "verification" ? "Your Slack connection is working! Now ask me a question about your Paperclip instance." - : `Hi, I’m ${current.agentName}. Your Slack account is connected to Paperclip.`, - unfurl_links: "false", unfurl_media: "false" }, await options.runtimeBotToken(row.endpointId)); - if (typeof sent.channel !== "string" || !/^D[A-Z0-9]+$/.test(sent.channel)) throw new Error("Incomplete message response"); - result = { ...account, [field]: "sent", dmChannelId: sent.channel }; - } catch (error) { - result = { ...account, [field]: error instanceof Error && "status" in error ? "failed" : "uncertain" }; - } - await saveAccount(row, result, actor, lease); - try { await audit(row, actor, `${kind}_${result[field] === "sent" ? "sent" : "failed"}`); } - catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack setup message activity could not be recorded"); } - } - async function welcome(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - await sendSetupMessage(row, actor, lease, "welcome"); - await sendSetupMessage(row, actor, lease, "verification"); - } - async function notifyVerified(endpointId: string) { - return options.withLock(endpointId, async lease => { - const row = await registration(endpointId); - if (!row || row.status !== "configured") return; - const [saved] = await db.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)); - const account = saved?.setup.slackAccount; - if (!account || !["verifying", "active"].includes(saved.status)) return; - await sendSetupMessage(row, { userId: account.paperclipUserId, sessionId: null, bypassPermissionCheck: false }, lease, "verification"); - }); - } - function pendingVerificationMessage() { - // A pending verification cannot progress until linking, welcome, and an - // authenticated callback finish. Their commits wake this lane. - return and(eq(chatSlackRegistrations.status, "configured"), - inArray(chatEndpoints.status, ["verifying", "active"]), - sql`${chatEndpoints.setup}->'slackAccount'->>'status' = 'linked'`, - sql`${chatEndpoints.setup}->>'webhookVerifiedAt' is not null`, - sql`coalesce(${chatEndpoints.setup}->'slackAccount'->>'welcomeStatus', '') not in ('pending', 'sending')`, - sql`${chatEndpoints.setup}->'slackAccount'->>'verificationStatus' in ('pending', 'sending')`); - } - function pendingVerificationMessages(limit: number) { - return db.select({ endpointId: chatEndpoints.id }).from(chatEndpoints) - .innerJoin(chatSlackRegistrations, and(eq(chatSlackRegistrations.endpointId, chatEndpoints.id), eq(chatSlackRegistrations.companyId, chatEndpoints.companyId))) - .where(pendingVerificationMessage()).limit(limit); - } - async function nextVerificationMessageAt(): Promise { - return (await pendingVerificationMessages(1)).length ? Date.now() : null; - } - async function processPendingVerificationMessages(limit = 25) { - for (const row of await pendingVerificationMessages(limit)) { - try { await notifyVerified(row.endpointId); } - catch { logger.warn({ endpointId: row.endpointId }, "Slack verification message could not be processed"); } - } - } - async function resumeLocked(endpointId: string, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - const current = await endpoint(endpointId, actor); - const row = await registration(endpointId); - if (!row || row.status === "removed") throw conflict("Slack registration is unavailable"); - if (row.status === "configured") { await cleanupStaged(endpointId, lease); await welcome(row, actor, lease); return; } - if (row.status !== "credentials_saved") throw conflict("Install this Slack app before connecting it"); - try { - assertOrigins(row, current.endpoint.publicId); - const botToken = await readSecret(row, "botToken"); - const auth = await api("auth.test", {}, botToken); - if (auth.team_id !== row.workspaceId || auth.user_id !== row.botUserId) throw providerFailure("slack_install_identity_mismatch"); - await options.configure(endpointId, { botToken, signingSecret: row.secretIds.signingSecret - ? await readSecret(row, "signingSecret") : await options.runtimeSigningSecret(endpointId) }, actor, { - assertOwned: async database => { - await lease.assertOwned(database); - await endpoint(endpointId, actor); - assertOrigins(row, current.endpoint.publicId); - }, - }); - await linkAccount(row, actor, lease); - } catch (error) { - const code = object(object(error).details).code; - const safeCode = code === "slack_install_account_missing" ? code : code === "chat_identity_link_conflict" || code === "chat_identity_already_linked" ? "slack_install_account_conflict" : code === "slack_install_identity_mismatch" || code === "chat_bot_identity_changed" - ? "slack_install_identity_mismatch" : code === "chat_provider_permissions_missing" ? "slack_install_scopes_missing" - : code === "chat_bot_identity_in_use" ? "slack_bot_already_connected" : "slack_configuration_incomplete"; - await setFailure(row, "credentials_saved", safeCode, lease); - await audit(row, actor, "configuration_failed", safeCode); - return; - } - await lease.assertOwned(); - await db.transaction(async tx => { - await notifyChatVerificationWork(tx); - await tx.update(chatSlackRegistrations).set({ status: "configured", errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - }); - // Runtime now owns a separate vaulted copy. Keep the registration IDs until - // deletion succeeds so cleanup itself is retryable. - await cleanupStaged(endpointId, lease); - try { await audit(row, actor, "installation_completed"); } - catch { logger.warn({ endpointId, appId: row.appId }, "Slack installation activity could not be recorded"); } - await welcome(row, actor, lease); - } - async function cleanupStaged(endpointId: string, lease: CredentialMutationLeaseGuard) { - const row = await registration(endpointId); - if (!row || row.status !== "configured") return; - const ids = { ...row.secretIds }; - for (const key of ["botToken", "signingSecret"]) { - if (ids[key]) { await lease.assertOwned(); await vault.remove(ids[key]); delete ids[key]; } - } - await lease.assertOwned(); - await db.update(chatSlackRegistrations).set({ secretIds: ids }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - } - async function complete(state: string, code: string | null, error: string | null, actor: SlackSetupActor) { - const before = await pending(state, actor); - return options.withLock(before.row.endpointId, async lease => { - const { row, attempt, current } = await pending(state, actor); - const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning(); - if (!claimed) throw conflict("This Slack authorization was already used"); - if (error) { - const failureCode = error === "access_denied" ? "slack_install_declined" : "slack_install_failed"; - await setFailure(row, row.status, failureCode, lease); - await audit(row, actor, "installation_failed", failureCode); - return row.endpointId; - } - try { - if (!code || code.length > 4096) throw providerFailure("slack_install_failed"); - const result = await api("oauth.v2.access", { code, client_id: row.clientId!, client_secret: await readSecret(row, "clientSecret"), redirect_uri: row.callbackUri }); - const teamId = object(result.team).id; - const installerId = object(result.authed_user).id; - if (result.app_id !== row.appId || result.token_type !== "bot" || typeof result.access_token !== "string" || !result.access_token.startsWith("xoxb-") - || typeof teamId !== "string" || !/^T[A-Z0-9]+$/.test(teamId) || typeof result.bot_user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(result.bot_user_id) - || row.workspaceId && row.workspaceId !== teamId || row.botUserId && row.botUserId !== result.bot_user_id) - throw providerFailure("slack_install_identity_mismatch"); - const granted = new Set(String(result.scope ?? "").split(",")); - if ((attempt.requestedScopes ?? []).some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing"); - if (current.endpoint.providerAccountId && current.endpoint.providerAccountId !== teamId - || current.endpoint.botExternalId && current.endpoint.botExternalId !== result.bot_user_id) - throw providerFailure("slack_install_identity_mismatch"); - if (typeof installerId !== "string" || !/^[UW][A-Z0-9]+$/.test(installerId) || installerId === result.bot_user_id) throw providerFailure("slack_install_account_missing"); - await endpoint(row.endpointId, actor); - // Reauthorization reuses the runtime signing secret after staging was cleaned. - await saveSecrets(row, { botToken: result.access_token }, actor, lease, - { status: "credentials_saved", workspaceId: teamId, botUserId: result.bot_user_id, errorCode: null }, - { externalUserId: installerId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" }); - } catch (failure) { - const code = object(object(failure).details).code; - const safeCode = typeof code === "string" && code.startsWith("slack_install_") ? code : "slack_install_failed"; - await setFailure(row, row.status, safeCode, lease); - await audit(row, actor, "installation_failed", safeCode); - return row.endpointId; - } - await resumeLocked(row.endpointId, actor, lease); - return row.endpointId; - }); - } - const cleanup = (endpointId: string, lease: CredentialMutationLeaseGuard) => removeSlackRegistration(db, endpointId, lease); - async function returnPath(endpointId: string) { - const [row] = await db.select({ prefix: companies.issuePrefix }).from(chatEndpoints) - .innerJoin(companies, eq(companies.id, chatEndpoints.companyId)).where(eq(chatEndpoints.id, endpointId)); - if (!row) throw notFound("Slack connection not found"); - return `/${encodeURIComponent(row.prefix)}/apps/chat/connect?provider=slack&resume=${encodeURIComponent(endpointId)}`; - } - return { create, install, pending, expiredReturn, complete, cleanup, returnPath, registration, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt, - close: async () => { await avatarPool?.close(); }, - resume: (endpointId: string, actor: SlackSetupActor) => options.withLock(endpointId, lease => resumeLocked(endpointId, actor, lease)) }; -} +// Compatibility boundary: both provisioning paths converge on the same Slack runtime. +export { slackChatRegistrationService } from "./connectors/slack/setup/service.js"; +export { slackRegistrationProjection, type SlackSetupActor } from "./connectors/slack/setup/registration.js"; diff --git a/server/src/services/connectors/slack/setup/completion.ts b/server/src/services/connectors/slack/setup/completion.ts new file mode 100644 index 00000000000..afefa935789 --- /dev/null +++ b/server/src/services/connectors/slack/setup/completion.ts @@ -0,0 +1,164 @@ +import { notifyChatVerificationWork } from "../../../chat-work-notifications.js"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { chatEndpoints, chatSlackRegistrations } from "@paperclipai/db"; +import { SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES, type SlackAccountState } from "@paperclipai/shared"; +import { conflict, forbidden } from "../../../../errors.js"; +import { logger } from "../../../../middleware/logger.js"; +import { secretService } from "../../../secrets.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; +import { object, providerFailure, type Registration, type SlackSetupActor, type SlackRegistrationStore } from "./registration.js"; +export function createSlackCompletion(store: SlackRegistrationStore) { + const { db, options, endpoint, registration, assertOrigins, api, readSecret, audit, setFailure } = store; + const vault = secretService(db); + async function saveAccount(row: Registration, account: SlackAccountState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + await endpoint(row.endpointId, actor); + await db.transaction(async tx => { + await lease.assertOwned(tx); + const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; + const registrationNow = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)))[0]; + if (!saved || saved.status === "archived" || registrationNow?.status === "removed" || registrationNow?.requestId !== row.requestId) throw conflict("Slack setup changed"); + await notifyChatVerificationWork(tx); + await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); + await lease.assertOwned(tx); + }); + } + async function linkAccount(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + const current = await endpoint(row.endpointId, actor); + const account = current.endpoint.setup.slackAccount; + if (!account) throw providerFailure("slack_install_account_missing"); + if (account.status === "linked") return; + const token = row.secretIds.botToken ? await readSecret(row, "botToken") : await options.runtimeBotToken(row.endpointId); + const user = object((await api("users.info", { user: account.externalUserId }, token)).user); + if (user.id !== account.externalUserId || user.team_id !== row.workspaceId || user.is_bot !== false || user.is_app_user === true || user.deleted !== false) throw providerFailure("slack_install_account_missing"); + await endpoint(row.endpointId, actor); + await options.linkInstaller(row.endpointId, user, { ...actor, userId: account.paperclipUserId }, lease); + await saveAccount(row, { ...account, status: "linked" }, actor, lease); + try { await audit(row, actor, "account_linked"); } + catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack account activity could not be recorded"); } + } + async function sendSetupMessage(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, kind: "welcome" | "verification") { + const current = await endpoint(row.endpointId, actor); + const account = current.endpoint.setup.slackAccount; + if (!account || account.status !== "linked") return; + if (kind === "verification" && (!current.endpoint.setup.webhookVerifiedAt || ["pending", "sending"].includes(account.welcomeStatus))) return; + const field = kind === "welcome" ? "welcomeStatus" : "verificationStatus"; + const status = account[field] ?? "pending"; + if (status === "sending") { + await saveAccount(row, { ...account, [field]: "uncertain" }, actor, lease); + return; // Never replay an ambiguous dispatch after a process restart. + } + if (status !== "pending") return; + await saveAccount(row, { ...account, [field]: "sending" }, actor, lease); + let result: SlackAccountState; + try { + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + if (!await options.canWelcome(row.endpointId, account)) throw forbidden("Slack account access changed"); + const sent = await api("chat.postMessage", { channel: account.dmChannelId ?? account.externalUserId, + text: kind === "verification" ? "Your Slack connection is working! Now ask me a question about your Paperclip instance." + : `Hi, I’m ${current.agentName}. Your Slack account is connected to Paperclip.`, + unfurl_links: "false", unfurl_media: "false" }, await options.runtimeBotToken(row.endpointId)); + if (typeof sent.channel !== "string" || !/^D[A-Z0-9]+$/.test(sent.channel)) throw new Error("Incomplete message response"); + result = { ...account, [field]: "sent", dmChannelId: sent.channel }; + } catch (error) { + result = { ...account, [field]: error instanceof Error && "status" in error ? "failed" : "uncertain" }; + } + await saveAccount(row, result, actor, lease); + try { await audit(row, actor, `${kind}_${result[field] === "sent" ? "sent" : "failed"}`); } + catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack setup message activity could not be recorded"); } + } + async function welcome(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + await sendSetupMessage(row, actor, lease, "welcome"); + await sendSetupMessage(row, actor, lease, "verification"); + } + async function notifyVerified(endpointId: string) { + return options.withLock(endpointId, async lease => { + const row = await registration(endpointId); + if (!row || row.status !== "configured") return; + const [saved] = await db.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)); + const account = saved?.setup.slackAccount; + if (!account || !["verifying", "active"].includes(saved.status)) return; + await sendSetupMessage(row, { userId: account.paperclipUserId, sessionId: null, bypassPermissionCheck: false }, lease, "verification"); + }); + } + function pendingVerificationMessages(limit: number) { + return db.select({ endpointId: chatEndpoints.id }).from(chatEndpoints) + .innerJoin(chatSlackRegistrations, and(eq(chatSlackRegistrations.endpointId, chatEndpoints.id), eq(chatSlackRegistrations.companyId, chatEndpoints.companyId))) + .where(and(eq(chatSlackRegistrations.status, "configured"), inArray(chatEndpoints.status, ["verifying", "active"]), + sql`${chatEndpoints.setup}->'slackAccount'->>'status' = 'linked'`, + sql`${chatEndpoints.setup}->>'webhookVerifiedAt' is not null`, + sql`coalesce(${chatEndpoints.setup}->'slackAccount'->>'welcomeStatus', '') not in ('pending', 'sending')`, + sql`${chatEndpoints.setup}->'slackAccount'->>'verificationStatus' in ('pending', 'sending')`)).limit(limit); + } + async function nextVerificationMessageAt(): Promise { + return (await pendingVerificationMessages(1)).length ? Date.now() : null; + } + async function processPendingVerificationMessages(limit = 25) { + for (const row of await pendingVerificationMessages(limit)) { + try { await notifyVerified(row.endpointId); } + catch { logger.warn({ endpointId: row.endpointId }, "Slack verification message could not be processed"); } + } + } + async function resumeLocked(endpointId: string, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + const current = await endpoint(endpointId, actor); + const row = await registration(endpointId); + if (!row || row.status === "removed") throw conflict("Slack registration is unavailable"); + if (row.status === "configured") { await cleanupStaged(endpointId, lease); await welcome(row, actor, lease); return; } + if (row.status !== "credentials_saved") throw conflict("Install this Slack app before connecting it"); + try { + assertOrigins(row, current.endpoint.publicId); + const botToken = await readSecret(row, "botToken"); + const auth = await api("auth.test", {}, botToken, row.managerGrantId ? [...SLACK_CHAT_BOT_SCOPES, ...SLACK_BOT_TOOL_SCOPES] : undefined); + if (auth.team_id !== row.workspaceId || row.botUserId && auth.user_id !== row.botUserId || typeof auth.user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(auth.user_id)) throw providerFailure("slack_install_identity_mismatch"); + if (row.managerGrantId) { + if (typeof auth.bot_id !== "string" || !/^B[A-Z0-9]+$/.test(auth.bot_id)) throw providerFailure("slack_install_identity_mismatch"); + const bot = object((await api("bots.info", { bot: auth.bot_id }, botToken)).bot); + if (bot.id !== auth.bot_id || bot.app_id !== row.appId || bot.user_id !== auth.user_id || bot.deleted !== false) throw providerFailure("slack_install_identity_mismatch"); + } + if (!row.botUserId) { + if (!row.managerGrantId) throw providerFailure("slack_install_identity_mismatch"); + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ botUserId: auth.user_id }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + } + await options.configure(endpointId, { botToken, signingSecret: row.secretIds.signingSecret + ? await readSecret(row, "signingSecret") : await options.runtimeSigningSecret(endpointId) }, actor, { + assertOwned: async database => { + await lease.assertOwned(database); + await endpoint(endpointId, actor); + assertOrigins(row, current.endpoint.publicId); + }, + }); + await linkAccount(row, actor, lease); + } catch (error) { + const code = object(object(error).details).code; + const safeCode = code === "slack_configuration_token_invalid" ? "slack_install_token_invalid" : code === "slack_install_account_missing" ? code : code === "chat_identity_link_conflict" || code === "chat_identity_already_linked" ? "slack_install_account_conflict" : code === "slack_install_identity_mismatch" || code === "chat_bot_identity_changed" + ? "slack_install_identity_mismatch" : (code === "chat_provider_permissions_missing" || code === "slack_install_scopes_missing" || code === "slack_setup_permission_denied") ? "slack_install_scopes_missing" + : code === "chat_bot_identity_in_use" ? "slack_bot_already_connected" : "slack_configuration_incomplete"; + await setFailure(row, "credentials_saved", safeCode, lease); + await audit(row, actor, "configuration_failed", safeCode); + return; + } + await lease.assertOwned(); + await db.transaction(async tx => { + await notifyChatVerificationWork(tx); + await tx.update(chatSlackRegistrations).set({ status: "configured", errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + }); + // Runtime now owns a separate vaulted copy. Keep the registration IDs until + // deletion succeeds so cleanup itself is retryable. + await cleanupStaged(endpointId, lease); + try { await audit(row, actor, "installation_completed"); } + catch { logger.warn({ endpointId, appId: row.appId }, "Slack installation activity could not be recorded"); } + await welcome(row, actor, lease); + } + async function cleanupStaged(endpointId: string, lease: CredentialMutationLeaseGuard) { + const row = await registration(endpointId); + if (!row || row.status !== "configured") return; + const ids = { ...row.secretIds }; + for (const key of ["botToken", "signingSecret"]) { + if (ids[key]) { await lease.assertOwned(); await vault.remove(ids[key]); delete ids[key]; } + } + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ secretIds: ids }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + } + return { resumeLocked, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt }; +} diff --git a/server/src/services/connectors/slack/setup/customer-owned.ts b/server/src/services/connectors/slack/setup/customer-owned.ts new file mode 100644 index 00000000000..6edd552d3fe --- /dev/null +++ b/server/src/services/connectors/slack/setup/customer-owned.ts @@ -0,0 +1,139 @@ +import { createHash } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { chatSlackRegistrations } from "@paperclipai/db"; +import { resolveAgentAppearance, buildSlackAppManifest, slackAppConfigurationSchema, type SlackRegistrationInput, type SlackAvatarState } from "@paperclipai/shared"; +import { conflict } from "../../../../errors.js"; +import { logger } from "../../../../middleware/logger.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; +import { object, knownProviderErrors, providerFailure, type Registration, type SlackSetupActor, type SlackRegistrationStore } from "./registration.js"; +export function createCustomerOwnedSetup(store: SlackRegistrationStore) { + const { db, options, endpoint, registration, callbackUri, origin, api, audit, saveSecrets, setFailure, saveAvatar, assertOrigins, renderAvatar } = store; + async function create(endpointId: string, actor: SlackSetupActor, input: SlackRegistrationInput, managed?: { grantId: string; workspaceId: string; request: typeof api }) { + return options.withLock(endpointId, async lease => { + const api = managed?.request ?? store.api; + const current = await endpoint(endpointId, actor); + const previous = await registration(endpointId); + const method = managed ? "managed" : "automatic"; + if (current.endpoint.setup.slackSetupMethod !== method) throw conflict("Resume the selected Slack setup method"); + if (previous && previous.managerGrantId !== (managed?.grantId ?? null)) throw conflict("Resume setup with the original Slack workspace"); + if (previous?.status === "removed") throw conflict("This registration was removed. Continue the selected manual setup."); + if (previous?.appId) { + if (previous.errorCode === "slack_manifest_update_pending") { + if (!await configureManifest(previous, actor, input.credentials.configurationToken, lease, method, api)) return; + if (current.endpoint.setup.slackAvatar?.status !== "uploaded") await configureAvatar(previous, actor, input.credentials.configurationToken, lease, api); + } + return; + } + if (previous?.requestId === input.requestId) return; + if (previous && ["creating", "uncertain"].includes(previous.status) && !input.confirmedNoAppCreated) { + await setFailure(previous, "uncertain", "slack_creation_uncertain", lease); + return; + } + if (current.endpoint.status !== "draft" || current.endpoint.botExternalId || current.endpoint.setup.slackSetupMethod !== method) + throw conflict("Automatic creation is only available for a new Slack connection"); + const app = slackAppConfigurationSchema.parse(current.endpoint.setup.slackApp); + const redirect = callbackUri(); + const manifest = buildSlackAppManifest({ app, agentName: current.agentName, + webhookUrl: `${origin(options.webhookOrigin())}/api/chat-webhooks/${current.endpoint.publicId}/slack`, redirectUri: redirect }); + // Slack cannot authenticate a challenge until its creation response gives us + // the signing secret. Add event subscriptions only after that secret is durable. + const { event_subscriptions: _events, ...creationSettings } = manifest.settings; + const creationManifest = { ...manifest, settings: creationSettings }; + try { await api("apps.manifest.validate", { manifest: JSON.stringify(manifest) }, input.credentials.configurationToken); } + catch (error) { + if (error instanceof Error && "status" in error) throw error; + throw providerFailure("slack_provider_failure"); + } + // No provider creation has happened until this intent is durable. + await endpoint(endpointId, actor); + await lease.assertOwned(); + const values = { companyId: current.endpoint.companyId, requestId: input.requestId, status: "creating" as const, + manifest, manifestHash: createHash("sha256").update(JSON.stringify(manifest)).digest("hex"), callbackUri: redirect, + createdByUserId: actor.userId, managerGrantId: managed?.grantId ?? null, workspaceId: managed?.workspaceId ?? null, errorCode: null, updatedAt: new Date() }; + const [row] = await db.insert(chatSlackRegistrations).values({ endpointId, ...values }) + .onConflictDoUpdate({ target: chatSlackRegistrations.endpointId, set: values }).returning(); + await audit(row, actor, "creation_started"); + let createdAppId: string; + try { + const result = await api("apps.manifest.create", { manifest: JSON.stringify(creationManifest) }, input.credentials.configurationToken); + const credentials = object(result.credentials); + if (typeof result.app_id !== "string" || !/^A[A-Z0-9]+$/.test(result.app_id) + || typeof credentials.client_id !== "string" || !/^\d+\.\d+$/.test(credentials.client_id) + || typeof credentials.client_secret !== "string" || !credentials.client_secret + || typeof credentials.signing_secret !== "string" || !credentials.signing_secret) + throw new Error("Incomplete Slack creation response"); + if (managed && result.team_id !== managed.workspaceId) throw new Error("Slack creation workspace mismatch"); + await endpoint(endpointId, actor); + await saveSecrets(row, { signingSecret: credentials.signing_secret, clientSecret: credentials.client_secret }, actor, lease, + { appId: result.app_id, clientId: credentials.client_id, status: "install", errorCode: "slack_manifest_update_pending" }); + createdAppId = result.app_id; + } catch (error) { + // Only documented rejection responses prove that creation did not happen. + const code = object(object(error).details).code; + const rejected = typeof code === "string" && [...Object.values(knownProviderErrors), "slack_manager_reauthorize"].includes(code); + await setFailure(row, rejected ? "failed" : "uncertain", rejected ? code : "slack_creation_uncertain", lease); + await audit(row, actor, "creation_failed", rejected ? code : "slack_creation_uncertain"); + return; + } + // The app and credentials are durable before optional icon provisioning. + // A retry/restart always reuses this app, even if icon upload is interrupted. + const created = { ...row, appId: createdAppId }; + if (!await configureManifest(created, actor, input.credentials.configurationToken, lease, method, api)) return; + await configureAvatar(created, actor, input.credentials.configurationToken, lease, api); + // An audit outage cannot change the saved creation outcome. + try { await audit({ ...row, appId: createdAppId }, actor, "app_created"); } + catch { logger.warn({ endpointId, appId: createdAppId }, "Slack app creation activity could not be recorded"); } + }); + } + async function configureAvatar(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard, api = store.api) { + const current = await endpoint(row.endpointId, actor); + await saveAvatar(row, { status: "pending" }, actor, lease); + let avatar: SlackAvatarState; + try { + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + // Cloud ingress requires a tenant session. Send the preset PNG bytes so + // Slack does not need to fetch an authenticated Paperclip URL. + const render = renderAvatar; + const appearance = { ...resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId) }; + delete appearance.customAvatarAssetId; // The preset renderer cannot read private uploaded assets. + const png = await render({ appearance, + size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" }); + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + const upload = new FormData(); + upload.set("app_id", row.appId!); + upload.set("file", new Blob([new Uint8Array(png)], { type: "image/png" }), "agent-avatar.png"); + await api("apps.icon.set", upload, configurationToken); + avatar = { status: "uploaded", uploadedAt: new Date().toISOString() }; + } catch { + // Provider echoes (including tokens) never become saved state or activity. + avatar = { status: "failed", errorCode: "slack_avatar_upload_failed" }; + } + await saveAvatar(row, avatar, actor, lease); + try { await audit(row, actor, avatar.status === "uploaded" ? "avatar_uploaded" : "avatar_upload_failed", avatar.status === "failed" ? avatar.errorCode : undefined); } + catch { logger.warn({ endpointId: row.endpointId, appId: row.appId! }, "Slack avatar activity could not be recorded"); } + } + async function configureManifest(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard, method: "automatic" | "managed" = "automatic", api = store.api) { + const current = await endpoint(row.endpointId, actor); + if (current.endpoint.status !== "draft" || current.endpoint.setup.slackSetupMethod !== method) + throw conflict("Resume the saved Slack connection before configuring its app"); + assertOrigins(row, current.endpoint.publicId); + await lease.assertOwned(); + try { + await api("apps.manifest.update", { app_id: row.appId!, manifest: JSON.stringify(row.manifest) }, configurationToken); + } catch { + // The pending marker was saved with the app secrets before dispatch. Retrying + // the same manifest is safe even when Slack's response was lost. + await audit(row, actor, "manifest_update_failed", "slack_manifest_update_pending"); + return false; + } + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }) + .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); + await audit(row, actor, "manifest_updated"); + return true; + } + return { create, configureManifest }; +} diff --git a/server/src/services/connectors/slack/setup/development-manager.ts b/server/src/services/connectors/slack/setup/development-manager.ts new file mode 100644 index 00000000000..d4e71fd868a --- /dev/null +++ b/server/src/services/connectors/slack/setup/development-manager.ts @@ -0,0 +1,73 @@ +import { readFileSync, statSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; +import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../../../paperclip-cloud-connector.js"; +import { createSlackSetupClient, object, providerFailure } from "./provider-client.js"; + +export type SlackManagerClient = Pick & { authorizationMode?: "code" }; + +// Explicit local qualification only. Production continues using the sealed Cloud +// broker; ordinary self-hosted installs neither load credentials nor contact it. +export function developmentSlackManager( + publicOrigin: () => string, + env: NodeJS.ProcessEnv = process.env, + fetchImpl: typeof fetch = fetch, +): SlackManagerClient | null { + if (env.NODE_ENV !== "development" || env.PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED !== "true" + || !env.PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG) return null; + let config: Record; + try { + const path = env.PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG; + if (!isAbsolute(path)) throw new Error(); + const stat = statSync(path); + if (!stat.isFile() || stat.size > 16_384 || (stat.mode & 0o077) !== 0) throw new Error(); + config = object(JSON.parse(readFileSync(path, "utf8"))); + } catch { throw providerFailure("slack_managed_unavailable"); } + const { appId, clientId, clientSecret } = config; + if (typeof appId !== "string" || !/^A[A-Z0-9]+$/.test(appId) + || typeof clientId !== "string" || !/^\d+\.\d+$/.test(clientId) + || typeof clientSecret !== "string" || !clientSecret) throw providerFailure("slack_managed_unavailable"); + const callback = () => { + const url = new URL(publicOrigin()); + if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw providerFailure("slack_managed_unavailable"); + return `${url.origin}/api/chat-slack/managed/oauth/callback`; + }; + const api = createSlackSetupClient(fetchImpl); + async function credentials(fields: Record, subject: string, companyId: string, initial: boolean): Promise { + let result: Record; + try { result = await api("oauth.v2.access", { client_id: clientId as string, client_secret: clientSecret as string, ...fields }); } + catch { throw providerFailure("slack_manager_reauthorize"); } + const user = initial ? object(result.authed_user) : result; + const scopes = typeof user.scope === "string" ? user.scope.split(/[, ]+/) : []; + const team = object(result.team); + if (typeof user.access_token !== "string" || !user.access_token || typeof user.refresh_token !== "string" || !user.refresh_token + || user.token_type !== "user" || typeof user.expires_in !== "number" || !Number.isFinite(user.expires_in) || user.expires_in <= 0 + || SLACK_MANAGER_SCOPES.some(scope => !scopes.includes(scope)) + || (initial && (result.app_id !== appId || typeof team.id !== "string" || !/^T[A-Z0-9]+$/.test(team.id) + || typeof team.name !== "string" || !team.name || typeof user.id !== "string" || !/^[UW][A-Z0-9]+$/.test(user.id)))) throw providerFailure("slack_manager_reauthorize"); + return { + v: 1, accessToken: user.access_token, refreshToken: user.refresh_token, tokenType: "user", + accessTokenExpiresAt: new Date(Date.now() + user.expires_in * 1000).toISOString(), refreshTokenExpiresAt: null, + scopes, subject, companyId, instanceId: "local-slack-qualification", environment: "development", provider: "slack", profile: "slack.manager", + ...(initial ? { slackManager: { appId: appId as string, workspaceId: team.id as string, workspaceName: team.name as string, userId: user.id as string } } : {}), + }; + } + return { + authorizationMode: "code", + async getSlackManagerAppId() { callback(); return appId as string; }, + async startAuthorization(values) { + if (values.returnUri !== callback() || values.profile !== "slack.manager") throw providerFailure("slack_manager_reauthorize"); + const url = new URL("https://slack.com/oauth/v2/authorize"); + url.search = new URLSearchParams({ client_id: clientId as string, user_scope: SLACK_MANAGER_SCOPES.join(","), redirect_uri: callback(), state: values.returnState }).toString(); + return { authorizationUrl: url.toString(), expiresAt: new Date(Date.now() + 600_000).toISOString() }; + }, + async claim(values) { return credentials({ code: values.claimId, redirect_uri: callback() }, values.subject, values.companyId, true); }, + async refresh(values) { return credentials({ grant_type: "refresh_token", refresh_token: values.refreshToken }, values.subject, values.companyId, false); }, + async revoke(values) { + try { await api("auth.revoke", {}, values.token); } + catch { throw providerFailure("slack_manager_reauthorize"); } + }, + }; +} diff --git a/server/src/services/connectors/slack/setup/managed.ts b/server/src/services/connectors/slack/setup/managed.ts new file mode 100644 index 00000000000..fc529fc7b9b --- /dev/null +++ b/server/src/services/connectors/slack/setup/managed.ts @@ -0,0 +1,81 @@ +import { and, eq } from "drizzle-orm"; +import { chatActions, chatSlackRegistrations } from "@paperclipai/db"; +import { type SlackManagedProvisionInput, type SlackManagedResult } from "@paperclipai/shared"; +import { conflict } from "../../../../errors.js"; +import type { createCustomerOwnedSetup } from "./customer-owned.js"; +import type { createSlackManagerGrants } from "./manager-grants.js"; +import type { createSlackCompletion } from "./completion.js"; +import { object, providerFailure, type SlackRegistrationStore, type SlackSetupActor } from "./registration.js"; + +/** Managed provisioning ends at the same saved-credential boundary as child OAuth. */ +export function createManagedSlackSetup(store: SlackRegistrationStore, grants: ReturnType, + provisioner: ReturnType, completion: ReturnType, + installFallback: (endpointId: string, actor: SlackSetupActor) => Promise<{ authorizationUrl: string; expiresAt: string }>) { + async function provision(endpointId: string, actor: SlackSetupActor, input: SlackManagedProvisionInput): Promise { + const current = await store.endpoint(endpointId, actor); + if (current.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Resume the selected Slack setup method"); + const authorized = await grants.token(input.grantId, current.endpoint.companyId, actor); + await provisioner.create(endpointId, actor, { requestId: input.requestId, confirmedNoAppCreated: input.confirmedNoAppCreated, + credentials: { configurationToken: authorized.token } }, { grantId: authorized.grant.id, workspaceId: authorized.grant.workspaceId, request: (method, fields) => grants.request(input.grantId, current.endpoint.companyId, actor, method, fields) }); + let fallback = false; + await store.options.withLock(endpointId, async lease => { + const latest = await store.endpoint(endpointId, actor); + const row = await store.registration(endpointId); + if (!row?.appId || row.status === "removed" || row.errorCode === "slack_manifest_update_pending") return; + if (row.managerGrantId !== authorized.grant.id || latest.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Slack setup changed"); + store.assertOrigins(row, latest.endpoint.publicId); + const reconnectKey = input.reconnect ? `slack_managed_reconnect:${actor.userId}:${input.requestId}` : null; + const [previous] = reconnectKey ? await store.db.select().from(chatActions).where(and(eq(chatActions.companyId, row.companyId), eq(chatActions.endpointId, endpointId), eq(chatActions.providerActionId, reconnectKey))) : []; + async function completeSaved() { + await completion.resumeLocked(endpointId, actor, lease); + if (reconnectKey && (await store.registration(endpointId))?.status === "configured") { + await lease.assertOwned(); + await store.db.update(chatActions).set({ status: "processed", updatedAt: new Date() }).where(and(eq(chatActions.companyId, row!.companyId), eq(chatActions.endpointId, endpointId), eq(chatActions.providerActionId, reconnectKey))); + } + } + if (previous && row.status === "configured" && !["revoked", "attention"].includes(latest.endpoint.status)) { + await completeSaved(); return; + } + const needsReinstall = row.status === "configured" && (input.reconnect || ["revoked", "attention"].includes(latest.endpoint.status)) + || row.status === "credentials_saved" && ["slack_install_token_invalid", "slack_install_scopes_missing"].includes(row.errorCode ?? ""); + if (reconnectKey && !previous) { + // Atomically bind an explicit reconnect to its durable installation intent. + // Duplicate requests can return the saved connection without reinstalling. + await store.db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.insert(chatActions).values({ companyId: row.companyId, endpointId, kind: "slack_managed_reconnect", providerActionId: reconnectKey, status: "processing", payload: { grantId: row.managerGrantId, registrationRequestId: row.requestId } }); + if (needsReinstall) await tx.update(chatSlackRegistrations).set({ status: "install", errorCode: row.errorCode ?? "slack_install_failed", updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + }); + } + if ((row.status === "configured" || row.status === "credentials_saved") && !needsReinstall) { + await completeSaved(); + return; + } + // Keep reinstall intent durable even if this dispatch fails or the process exits. + if (needsReinstall && (!reconnectKey || previous)) await store.setFailure(row, "install", row.errorCode ?? "slack_install_failed", lease); + const { grant } = await grants.token(input.grantId, row.companyId, actor); + try { + await lease.assertOwned(); + const result = await grants.request(grant.id, row.companyId, actor, "apps.managedInstall", { app_id: row.appId, team_id: grant.workspaceId }); + const botToken = object(result.api_access_tokens).bot_access_token; + if (result.app_id !== row.appId || result.team_id !== grant.workspaceId || typeof botToken !== "string" || !botToken.startsWith("xoxb-")) throw providerFailure("slack_install_identity_mismatch"); + if ((await grants.get(grant.id, row.companyId, actor)).status !== "active") throw providerFailure("slack_manager_reauthorize"); + await store.endpoint(endpointId, actor); + // Vault before auth.test/inventory so network failures never lose this installation. + await store.saveSecrets(row, { botToken }, actor, lease, { status: "credentials_saved", workspaceId: grant.workspaceId, errorCode: null }, + { externalUserId: grant.slackUserId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" }); + } catch (error) { + const code = object(object(error).details).code; + const safe = typeof code === "string" ? code : "slack_install_failed"; + await store.setFailure(row, needsReinstall ? "install" : row.status, safe, lease); + await store.audit(row, actor, "installation_failed", safe); + fallback = ["slack_approval_required", "slack_approval_pending", "slack_approval_denied"].includes(safe); + return; + } + await completeSaved(); + }); + // OAuth's state/actor binding is ours; never trust a provider-supplied redirect. + return fallback ? { authorization: await installFallback(endpointId, actor) } : {}; + } + return { provision }; +} diff --git a/server/src/services/connectors/slack/setup/manager-grants.ts b/server/src/services/connectors/slack/setup/manager-grants.ts new file mode 100644 index 00000000000..3d902d9928e --- /dev/null +++ b/server/src/services/connectors/slack/setup/manager-grants.ts @@ -0,0 +1,269 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { and, eq, gt, like, sql } from "drizzle-orm"; +import { chatEndpoints, chatSlackManagerGrants, chatSlackRegistrations, companySecrets, toolOauthStates } from "@paperclipai/db"; +import { SLACK_MANAGER_SCOPES, type SlackSetupOptions as SetupChoices } from "@paperclipai/shared"; +import { conflict, forbidden } from "../../../../errors.js"; +import { getCloudRuntimeIdentity } from "../../../cloud-runtime-identity.js"; +import { createPaperclipCloudConnector, paperclipCloudConnectorConfigFromEnv, type SealedConnectorCredentials } from "../../../paperclip-cloud-connector.js"; +import { instanceSettingsService } from "../../../instance-settings.js"; +import { accessService } from "../../../access.js"; +import { secretService } from "../../../secrets.js"; +import { writeConnectionCredential } from "../../../connection-credentials.js"; +import { logActivity } from "../../../activity-log.js"; +import { object, providerFailure, type SlackRegistrationStore, type SlackSetupActor } from "./registration.js"; +import { developmentSlackManager, type SlackManagerClient } from "./development-manager.js"; + +export const SLACK_MANAGER_STATE_PREFIX = "slack-manager."; +export function createSlackManagerGrants(store: SlackRegistrationStore) { + const { db, options, endpoint, origin, api } = store; + type Database = Parameters[0]; + function broker(): SlackManagerClient | null { + if (options.managed) return options.managed.enabled ? options.managed.broker : null; + if (!getCloudRuntimeIdentity()) { + const development = developmentSlackManager(() => origin(options.publicOrigin())); + if (development) return development; + } + // Open-source instances never contact a hosted service to discover this path. + if (!getCloudRuntimeIdentity() || process.env.PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED !== "true") return null; + const config = paperclipCloudConnectorConfigFromEnv(); + return config ? createPaperclipCloudConnector({ config }) : null; + } + async function access(companyId: string, actor: SlackSetupActor, database: Database = db) { + if (!(await instanceSettingsService(database as typeof db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); + if (!actor.bypassPermissionCheck && !await accessService(database as typeof db).hasPermission(companyId, "user", actor.userId, "tools:manage_connections")) throw forbidden("Missing permission: tools:manage_connections"); + } + // Deployment intent is stable across broker outages and preview rollbacks. + // A missing live profile pauses managed setup; it must not select token entry. + function defaultMethod(): "managed" | "automatic" { return broker() ? "managed" : "automatic"; } + async function available() { + const client = broker(); + if (!client || !(await instanceSettingsService(db).getExperimental()).enableChatConnectors) return false; + try { origin(options.publicOrigin()); origin(options.webhookOrigin()); return Boolean(await client.getSlackManagerAppId()); } + catch { return false; } + } + async function requiredBroker() { + if (!await available()) throw providerFailure("slack_managed_unavailable"); + return broker()!; + } + async function choices(companyId: string, actor: SlackSetupActor): Promise { + await access(companyId, actor); + const managedAvailable = await available(); + const managerAppId = managedAvailable ? await broker()!.getSlackManagerAppId() : null; + const grants = managedAvailable && managerAppId ? await db.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.status, "active"), eq(chatSlackManagerGrants.managerAppId, managerAppId))) : []; + return { managedAvailable, defaultMethod: defaultMethod(), workspaces: grants.map(g => ({ grantId: g.id, workspaceId: g.workspaceId, workspaceName: g.workspaceName, userId: g.slackUserId })) }; + } + const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/managed/oauth/callback`; + async function authorize(endpointId: string, actor: SlackSetupActor) { + const client = await requiredBroker(); + return options.withLock(endpointId, async lease => { + const current = await endpoint(endpointId, actor); + if (current.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Resume the selected Slack setup method"); + const row = await store.registration(endpointId); + const revisions = await db.select({ id: chatSlackManagerGrants.id, revision: chatSlackManagerGrants.revision }).from(chatSlackManagerGrants) + .where(and(eq(chatSlackManagerGrants.companyId, current.endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId))); + const state = `${SLACK_MANAGER_STATE_PREFIX}${randomBytes(32).toString("base64url")}`; + const expiresAt = new Date(Date.now() + 10 * 60_000); + await lease.assertOwned(); + await db.insert(toolOauthStates).values({ state, companyId: current.endpoint.companyId, connectionId: current.connection.id, + codeVerifier: JSON.stringify({ endpointId, revisions, authorizationMode: client.authorizationMode ?? "claim", requestId: row?.requestId ?? null, callbackUri: callbackUri(), webhookOrigin: origin(options.webhookOrigin()), runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }), + createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId, requestedScopes: [...SLACK_MANAGER_SCOPES], expiresAt }); + try { return await client.startAuthorization({ profile: "slack.manager", subject: actor.userId, companyId: current.endpoint.companyId, returnUri: callbackUri(), returnState: state }); } + catch { await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); throw providerFailure("slack_manager_reauthorize"); } + }); + } + async function pending(state: string, actor: SlackSetupActor, allowExpired = false) { + if (!/^slack-manager\.[A-Za-z0-9_-]{43}$/.test(state)) throw forbidden("Invalid Slack authorization state"); + const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), ...(allowExpired ? [] : [gt(toolOauthStates.expiresAt, new Date())]))); + if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) throw forbidden("Slack authorization expired or belongs to another session"); + const binding = object(JSON.parse(attempt.codeVerifier)); + const current = await endpoint(String(binding.endpointId), actor); + const row = await store.registration(current.endpoint.id); + if (current.endpoint.setup.slackSetupMethod !== "managed" || current.connection.id !== attempt.connectionId || current.endpoint.companyId !== attempt.companyId + || ["paused", "revoked"].includes(current.endpoint.status) || row?.status === "removed" + || (binding.authorizationMode ?? "claim") !== (broker()?.authorizationMode ?? "claim") + || (row?.requestId ?? null) !== binding.requestId || binding.callbackUri !== callbackUri() || binding.webhookOrigin !== origin(options.webhookOrigin()) + || binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0)) throw conflict("Slack setup changed. Resume authorization."); + return { attempt, current, row, binding }; + } + async function expiredReturn(state: string, actor: SlackSetupActor) { + const { current, attempt } = await pending(state, actor, true); + if (attempt.expiresAt.getTime() > Date.now()) return null; + return options.withLock(current.endpoint.id, async lease => { + const latest = await pending(state, actor, true); + await lease.assertOwned(); + await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); + await db.update(chatEndpoints).set({ setup: { ...latest.current.endpoint.setup, slackManagerError: "slack_install_expired" } }).where(eq(chatEndpoints.id, current.endpoint.id)); + return current.endpoint.id; + }); + } + async function complete(state: string, claimId: string | null, error: string | null, actor: SlackSetupActor, authorizationCode: string | null = null) { + const client = await requiredBroker(); + claimId = client.authorizationMode === "code" ? authorizationCode : claimId; + const before = await pending(state, actor); + return options.withLock(before.current.endpoint.id, async lease => { + const { current, row, binding } = await pending(state, actor); + await lease.assertOwned(); + const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning(); + if (!claimed) throw conflict("Slack authorization was already used"); + async function outcome(code: string | null) { + await lease.assertOwned(); + const latest = await endpoint(current.endpoint.id, actor); + await db.update(chatEndpoints).set({ setup: { ...latest.endpoint.setup, slackManagerError: code ?? undefined } }).where(eq(chatEndpoints.id, current.endpoint.id)); + if (code) await logActivity(db, { companyId: current.endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_authorization_failed", entityType: "chat_endpoint", entityId: current.endpoint.id, details: { code } }); + return current.endpoint.id; + } + if (error || !claimId || claimId.length > 4096) return outcome(error === "access_denied" ? "slack_install_declined" : "slack_manager_reauthorize"); + try { + let grant: SealedConnectorCredentials; + try { grant = await client.claim({ profile: "slack.manager", subject: actor.userId, companyId: current.endpoint.companyId, claimId, redemptionId: randomUUID() }); } + catch { throw providerFailure("slack_manager_reauthorize"); } + const identity = grant.slackManager; + if (!identity || identity.appId !== await client.getSlackManagerAppId() || !/^A[A-Z0-9]+$/.test(identity.appId) || !/^T[A-Z0-9]+$/.test(identity.workspaceId) || !/^[UW][A-Z0-9]+$/.test(identity.userId) || typeof identity.workspaceName !== "string" || !identity.workspaceName || identity.workspaceName.length > 255) throw providerFailure("slack_manager_reauthorize"); + if (row?.managerGrantId) { + const existing = await get(row.managerGrantId, current.endpoint.companyId, actor); + if (existing.managerAppId !== identity.appId || existing.workspaceId !== identity.workspaceId || existing.slackUserId !== identity.userId) throw providerFailure("slack_install_identity_mismatch"); + } + await endpoint(current.endpoint.id, actor); + await access(current.endpoint.companyId, actor); + await db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager:${current.endpoint.companyId}:${actor.userId}:${identity.appId}:${identity.workspaceId}`}, 0))`); + const [existing] = await tx.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, current.endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.managerAppId, identity.appId), eq(chatSlackManagerGrants.workspaceId, identity.workspaceId))); + if (existing) { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${existing.id}`}, 0))`); + const [latest] = await tx.select().from(chatSlackManagerGrants).where(eq(chatSlackManagerGrants.id, existing.id)); + const revision = Array.isArray(binding.revisions) ? binding.revisions.map(object).find(value => value.id === existing.id)?.revision : undefined; + if (!latest || latest.revision !== revision) throw conflict("Workspace authorization changed. Start again."); + } + const [saved] = await tx.insert(chatSlackManagerGrants).values({ companyId: current.endpoint.companyId, userId: actor.userId, slackUserId: identity.userId, workspaceId: identity.workspaceId, workspaceName: identity.workspaceName, managerAppId: identity.appId }) + .onConflictDoUpdate({ target: [chatSlackManagerGrants.companyId, chatSlackManagerGrants.userId, chatSlackManagerGrants.managerAppId, chatSlackManagerGrants.workspaceId], set: { updatedAt: new Date() } }).returning(); + // A reauthorization must never change which Slack person this grant represents. + if (saved.slackUserId !== identity.userId) throw conflict("Reconnect using the original Slack account"); + await access(current.endpoint.companyId, actor, tx); + await save(tx, saved, grant, actor); + if (row?.managerGrantId === saved.id) { + await tx.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }) + .where(and(eq(chatSlackRegistrations.endpointId, current.endpoint.id), eq(chatSlackRegistrations.managerGrantId, saved.id), eq(chatSlackRegistrations.errorCode, "slack_manager_reauthorize"))); + } + }); + await logActivity(db, { companyId: current.endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_authorized", entityType: "chat_endpoint", entityId: current.endpoint.id, details: { workspaceId: identity.workspaceId, managerAppId: identity.appId } }); + return outcome(null); + } catch { return outcome("slack_manager_reauthorize"); } + }); + } + type Grant = typeof chatSlackManagerGrants.$inferSelect; + async function get(id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor); + return readGrant(db, id, companyId, actor); + } + async function readGrant(database: Database, id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor, database); + const [grant] = await database.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.id, id), eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.userId, actor.userId))); + if (!grant) throw forbidden("This Slack workspace authorization belongs to another account"); + return grant; + } + async function save(tx: Parameters[0]>[0], row: Grant, credentials: SealedConnectorCredentials, actor: SlackSetupActor) { + if (credentials.tokenType !== "user" || !credentials.accessToken || !credentials.refreshToken || !credentials.accessTokenExpiresAt || !Number.isFinite(Date.parse(credentials.accessTokenExpiresAt)) || Date.parse(credentials.accessTokenExpiresAt) <= Date.now() || credentials.provider !== "slack" || credentials.profile !== "slack.manager" || credentials.subject !== row.userId || credentials.companyId !== row.companyId || SLACK_MANAGER_SCOPES.some(scope => !credentials.scopes.includes(scope))) throw providerFailure("slack_manager_reauthorize"); + const write = (key: string, value: string, secretId: string | null) => writeConnectionCredential(tx, { companyId: row.companyId, ownerUserId: row.userId, actor: { userId: actor.userId }, connectionName: "Slack workspace management", configPath: `slack_manager.${key}`, definitionKey: `slack_manager.${row.id}.${key}`, label: key, value, ...(secretId ? { existingRef: { secretId, configPath: `slack_manager.${key}` } } : {}) }); + const accessToken = await write("access_token", credentials.accessToken, row.accessSecretId); + const refreshToken = credentials.refreshToken ? await write("refresh_token", credentials.refreshToken, row.refreshSecretId) : null; + await tx.update(chatSlackManagerGrants).set({ accessSecretId: accessToken.secret.id, refreshSecretId: refreshToken?.secret.id ?? row.refreshSecretId, expiresAt: credentials.accessTokenExpiresAt ? new Date(credentials.accessTokenExpiresAt) : null, status: "active", revision: row.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, row.id)); + } + async function secret(row: Grant, id: string | null, database: Database = db) { + if (!id) throw providerFailure("slack_manager_reauthorize"); + const [ref] = await database.select().from(companySecrets).where(and(eq(companySecrets.id, id), eq(companySecrets.companyId, row.companyId), eq(companySecrets.scope, "user"), eq(companySecrets.ownerUserId, row.userId))); + if (!ref?.userSecretDefinitionId) throw providerFailure("slack_manager_reauthorize"); + // Owner-scoped system consumer; the durable grant is its authority, not a tool binding. + const value = await secretService(database).resolveUserSecretValue(row.companyId, { definitionId: ref.userSecretDefinitionId, responsibleUserId: row.userId }, { consumerType: "system", consumerId: `slack-manager:${row.id}`, actorType: "user", actorId: row.userId, responsibleUserId: row.userId }); + if (!value) throw providerFailure("slack_manager_reauthorize"); + return value.value; + } + const tokenRequests = new Map>(); + async function token(id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor); + const client = await requiredBroker(); + const managerAppId = await client.getSlackManagerAppId(); + const key = `${companyId}:${actor.userId}:${id}`; + const running = tokenRequests.get(key); + if (running) return running; + const operation = (async () => { + // Commit the single-use refresh claim before dispatch, without holding a + // pool connection while another query or the broker needs it. + const prepared = await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const grant = await readGrant(tx, id, companyId, actor); + if (grant.managerAppId !== managerAppId || grant.status !== "active") throw providerFailure("slack_manager_reauthorize"); + if (!grant.expiresAt || grant.expiresAt.getTime() > Date.now() + 60_000) return { grant, token: await secret(grant, grant.accessSecretId, tx) }; + const refreshToken = await secret(grant, grant.refreshSecretId, tx); + const [claimed] = await tx.update(chatSlackManagerGrants).set({ status: "reauthorize", revision: grant.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)).returning(); + return { grant: claimed!, refreshToken }; + }); + if ("token" in prepared) return { grant: prepared.grant, token: prepared.token! }; + try { + const refreshed = await client.refresh({ profile: "slack.manager", subject: actor.userId, companyId, refreshToken: prepared.refreshToken }); + await access(companyId, actor); + return await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const current = await readGrant(tx, id, companyId, actor); + if (current.status !== "reauthorize" || current.revision !== prepared.grant.revision) throw providerFailure("slack_manager_reauthorize"); + await save(tx, current, refreshed, actor); + return { grant: await readGrant(tx, id, companyId, actor), token: refreshed.accessToken }; + }); + } catch { throw providerFailure("slack_manager_reauthorize"); } + })(); + tokenRequests.set(key, operation); + try { return await operation; } finally { if (tokenRequests.get(key) === operation) tokenRequests.delete(key); } + } + async function revoke(id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor); + let providerRevoked = true; + const grant = await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const current = await readGrant(tx, id, companyId, actor); + await tx.update(chatSlackManagerGrants).set({ status: "revoked", revision: current.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)); + await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.companyId, companyId), eq(toolOauthStates.createdByActorId, actor.userId), like(toolOauthStates.state, `${SLACK_MANAGER_STATE_PREFIX}%`))); + const client = broker(); + for (const secretId of [current.accessSecretId, current.refreshSecretId]) if (secretId) { + try { + if (!client) throw providerFailure("slack_managed_unavailable"); + await client.revoke({ subject: actor.userId, companyId, profile: "slack.manager", token: await secret(current, secretId, tx) }); + } catch { providerRevoked = false; } + await secretService(tx).remove(secretId); + } + await tx.update(chatSlackManagerGrants).set({ accessSecretId: null, refreshSecretId: null }).where(eq(chatSlackManagerGrants.id, id)); + return current; + }); + await logActivity(db, { companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_revoked", entityType: "slack_manager_grant", entityId: id, details: { workspaceId: grant.workspaceId, providerRevoked } }); + } + async function request(id: string, companyId: string, actor: SlackSetupActor, method: string, fields: Record | FormData) { + const authorized = await token(id, companyId, actor); + const { grant } = authorized; + // Slack shares a method budget across manager tokens in the same workspace. + // Persist Retry-After across grants and process restarts; never retry a dispatch. + const result = await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-api:${companyId}:${grant.managerAppId}:${grant.workspaceId}:${method}`}, 0))`); + // Serialize provider dispatch against refresh/revocation for this personal grant. + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const peers = await tx.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.managerAppId, grant.managerAppId), eq(chatSlackManagerGrants.workspaceId, grant.workspaceId))).orderBy(chatSlackManagerGrants.id); + if (peers.some(peer => Date.parse(peer.rateLimits[method] ?? "") > Date.now())) return { error: providerFailure("slack_setup_rate_limited") }; + const current = await readGrant(tx, id, companyId, actor); + if (current.status !== "active") return { error: providerFailure("slack_manager_reauthorize") }; + try { return { value: await api(method, fields, await secret(current, current.accessSecretId, tx)) }; } + catch (error) { + const details = object(object(error).details); + if (details.code === "slack_setup_rate_limited") { + const until = new Date(Date.now() + Math.min(Number(details.retryAfterSeconds) || 60, 86_400) * 1000).toISOString(); + for (const peer of peers) await tx.update(chatSlackManagerGrants).set({ rateLimits: sql`${chatSlackManagerGrants.rateLimits} || ${JSON.stringify({ [method]: until })}::jsonb` }).where(eq(chatSlackManagerGrants.id, peer.id)); + } + if (details.code === "slack_configuration_token_invalid") { + await tx.update(chatSlackManagerGrants).set({ status: "reauthorize", revision: current.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)); + return { error: providerFailure("slack_manager_reauthorize") }; + } + return { error }; + } + }); + if ("error" in result) throw result.error; + return result.value!; + } + return { choices, defaultMethod, available, authorize, pending, expiredReturn, complete, get, token, revoke, request }; +} diff --git a/server/src/services/connectors/slack/setup/provider-client.ts b/server/src/services/connectors/slack/setup/provider-client.ts new file mode 100644 index 00000000000..bc051d651cd --- /dev/null +++ b/server/src/services/connectors/slack/setup/provider-client.ts @@ -0,0 +1,58 @@ +import { slackRegistrationErrorMessage } from "@paperclipai/shared"; +import { unprocessable } from "../../../../errors.js"; +export const object = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +export const providerFailure = (code: string) => unprocessable(slackRegistrationErrorMessage(code), { code }); +export const knownProviderErrors: Record = { + app_approval_request_eligible: "slack_approval_required", app_approval_request_pending: "slack_approval_pending", + app_approval_request_denied: "slack_approval_denied", admin_approval_required: "slack_approval_required", + manager_app_not_eligible: "slack_managed_unavailable", feature_not_enabled: "slack_managed_unavailable", + invalid_auth: "slack_configuration_token_invalid", token_expired: "slack_configuration_token_invalid", + token_revoked: "slack_configuration_token_invalid", not_authed: "slack_configuration_token_invalid", + invalid_manifest: "slack_manifest_invalid", invalid_app: "slack_manifest_invalid", + ratelimited: "slack_setup_rate_limited", no_permission: "slack_setup_permission_denied", + missing_scope: "slack_setup_permission_denied", not_allowed_token_type: "slack_configuration_token_invalid", +}; + +const timeoutMs = 20_000; +export function createSlackSetupClient(fetchImpl: typeof fetch = fetch) { + return async function api(method: string, fields: Record | FormData, bearer?: string, requiredScopes?: readonly string[]) { + const response = await fetchImpl(`https://slack.com/api/${method}`, { + method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs), + headers: { ...(fields instanceof FormData ? {} : { "content-type": "application/x-www-form-urlencoded" }), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }, + body: fields instanceof FormData ? fields : new URLSearchParams(fields), + }); + // Bound the streamed response as well as the request lifetime. + const reader = response.body?.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + if (reader) { + try { + while (true) { + const part = await reader.read(); + if (part.done) break; + size += part.value.byteLength; + if (size > 1_048_576) { await reader.cancel(); throw providerFailure("slack_provider_failure"); } + chunks.push(part.value); + } + } finally { reader.releaseLock(); } + } + let result: Record; + try { result = object(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } + catch { result = {}; } + if (!response.ok || result.ok !== true) { + const code = knownProviderErrors[String(result.error)] ?? (response.status === 429 ? "slack_setup_rate_limited" : "slack_provider_failure"); + const retry = Number(response.headers.get("retry-after")); + if (response.status === 429 || result.error === "ratelimited") { + throw unprocessable(slackRegistrationErrorMessage("slack_setup_rate_limited"), { + code: "slack_setup_rate_limited", retryAfterSeconds: Number.isFinite(retry) && retry > 0 ? Math.ceil(retry) : 60, + }); + } + throw providerFailure(code); + } + if (requiredScopes) { + const granted = new Set((response.headers.get("x-oauth-scopes") ?? "").split(/[, ]+/)); + if (requiredScopes.some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing"); + } + return result; + } +} diff --git a/server/src/services/connectors/slack/setup/registration.ts b/server/src/services/connectors/slack/setup/registration.ts new file mode 100644 index 00000000000..ca44f0e7f50 --- /dev/null +++ b/server/src/services/connectors/slack/setup/registration.ts @@ -0,0 +1,136 @@ +import { notifyChatVerificationWork } from "../../../chat-work-notifications.js"; +import { createAgentAvatarPool } from "../../../agent-avatar-pool.js"; +import type { AgentAvatarRequest } from "../../../agent-avatars.js"; +import { and, eq } from "drizzle-orm"; +import { agents, chatEndpoints, chatSlackRegistrations, toolConnections, type Db } from "@paperclipai/db"; +import { slackRegistrationErrorMessage, type SlackRegistrationState, type SlackAvatarState, type SlackAccountState, isProvisionedSlackSetup } from "@paperclipai/shared"; +import { badRequest, conflict, forbidden, notFound, unprocessable } from "../../../../errors.js"; +import { accessService } from "../../../access.js"; +import { logActivity } from "../../../activity-log.js"; +import { instanceSettingsService } from "../../../instance-settings.js"; +import { secretService } from "../../../secrets.js"; +import { writeConnectionCredential } from "../../../connection-credentials.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; + +export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean } +export type Registration = typeof chatSlackRegistrations.$inferSelect; + + +export { object, providerFailure, knownProviderErrors } from "./provider-client.js"; +import { object } from "./provider-client.js"; + +export function slackRegistrationProjection(row: Registration): SlackRegistrationState { + return { + status: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "uncertain" : row.status, + appId: row.appId, + ...(row.managerGrantId ? { managerGrantId: row.managerGrantId } : {}), + managementUrl: row.appId ? `https://api.slack.com/apps/${encodeURIComponent(row.appId)}` : "https://api.slack.com/apps", + errorCode: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "slack_creation_uncertain" : row.errorCode, + }; +} + +import { createSlackSetupClient } from "./provider-client.js"; +export type SlackSetupOptions = { + managed?: { enabled: boolean; broker: import("../../../paperclip-cloud-connector.js").PaperclipCloudConnector }; + publicOrigin: () => string | null; + webhookOrigin: () => string | null; + fetch?: typeof fetch; + renderAvatar?: (request: AgentAvatarRequest) => Promise; + withLock: (endpointId: string, work: (lease: CredentialMutationLeaseGuard) => Promise) => Promise; + runtimeSigningSecret: (endpointId: string) => Promise; + runtimeBotToken: (endpointId: string) => Promise; + canWelcome: (endpointId: string, account: SlackAccountState) => Promise; + linkInstaller: (endpointId: string, user: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; + configure: (endpointId: string, credentials: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; +}; +export function createSlackRegistrationStore(db: Db, options: SlackSetupOptions) { + const fetchImpl = options.fetch ?? fetch; + const vault = secretService(db); + let avatarPool: ReturnType | undefined; + function origin(value: string | null) { + if (!value) throw badRequest("Configure a public HTTPS URL before creating a Slack app"); + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) + throw badRequest("Slack setup requires a configured public HTTPS origin"); + return url.origin; + } + const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/oauth/callback`; + async function registration(endpointId: string) { + return (await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)))[0]; + } + async function endpoint(endpointId: string, actor: SlackSetupActor) { + if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); + const [row] = await db.select({ endpoint: chatEndpoints, connection: toolConnections, agentName: agents.name, agentAppearance: agents.appearance }) + .from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId))) + .innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId))) + .where(and(eq(chatEndpoints.id, endpointId), eq(chatEndpoints.provider, "slack"))); + if (!row || row.endpoint.status === "archived" || row.connection.status === "archived") throw notFound("Slack connection not found"); + if (!actor.bypassPermissionCheck && !await accessService(db).hasPermission(row.endpoint.companyId, "user", actor.userId, "tools:manage_connections")) + throw forbidden("Missing permission: tools:manage_connections"); + return row; + } + function assertOrigins(row: Registration, publicId: string) { + if (row.callbackUri !== callbackUri()) throw conflict("The Paperclip URL changed. Restore its configured address or update the Slack app and use manual setup."); + const expected = `${origin(options.webhookOrigin())}/api/chat-webhooks/${publicId}/slack`; + if (object(object(row.manifest.settings).event_subscriptions).request_url !== expected) + throw conflict("The Slack webhook address changed. Restore its configured address or update the Slack app and use manual setup."); + } + async function audit(row: Registration, actor: SlackSetupActor, action: string, code?: string) { + await logActivity(db, { companyId: row.companyId, actorType: "user", actorId: actor.userId, + action: `chat_slack.${action}`, entityType: "chat_endpoint", entityId: row.endpointId, + details: { endpointId: row.endpointId, appId: row.appId, ...(code ? { code } : {}) } }); + } + const api = createSlackSetupClient(fetchImpl); + async function saveSecrets(row: Registration, values: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, + patch: Partial = {}, account?: SlackAccountState) { + await db.transaction(async tx => { + await lease.assertOwned(tx); + const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; + if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); + const ids = { ...current.secretIds }; + for (const [key, value] of Object.entries(values)) { + const result = await writeConnectionCredential(tx, { companyId: row.companyId, connectionName: "Slack app registration", + configPath: `slack_registration.${key}`, label: key, value, actor: { userId: actor.userId }, + ...(ids[key] ? { existingRef: { secretId: ids[key], configPath: `slack_registration.${key}` } } : {}) }); + ids[key] = result.secret.id; + } + await notifyChatVerificationWork(tx); + await tx.update(chatSlackRegistrations).set({ ...patch, secretIds: ids, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, row.endpointId)); + if (account) { + const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; + if (!saved || saved.status === "archived") throw conflict("Slack setup changed"); + // Reauthorization never changes an established personal account binding. + if (saved.setup.slackAccount?.status !== "linked") await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); + } + await lease.assertOwned(tx); + }); + } + async function readSecret(row: Registration, key: string) { + if (!row.secretIds[key]) throw conflict("Slack setup credentials are unavailable. Resume the saved connection."); + return vault.resolveSecretValue(row.companyId, row.secretIds[key], "latest", { + accessContext: { consumerType: "system", consumerId: `slack-registration:${row.endpointId}`, configPath: `slack_registration.${key}`, actorType: "system" }, + }); + } + async function setFailure(row: Registration, status: Registration["status"], code: string, lease: CredentialMutationLeaseGuard) { + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ status, errorCode: code, updatedAt: new Date() }) + .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); + } + async function saveAvatar(row: Registration, avatar: SlackAvatarState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + await endpoint(row.endpointId, actor); + await db.transaction(async tx => { + await lease.assertOwned(tx); + const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; + if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); + const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; + if (!saved || saved.status === "archived" || !isProvisionedSlackSetup(saved.setup.slackSetupMethod)) throw conflict("Slack setup changed. Resume the saved connection."); + await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAvatar: avatar }, updatedAt: new Date() }) + .where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))); + await lease.assertOwned(tx); + }); + } + const renderAvatar = (request: AgentAvatarRequest) => (options.renderAvatar ?? (avatarPool ??= createAgentAvatarPool()).render)(request); + return { db, options, origin, callbackUri, registration, endpoint, assertOrigins, audit, api, saveSecrets, readSecret, setFailure, saveAvatar, renderAvatar, + close: async () => { await avatarPool?.close(); } }; +} +export type SlackRegistrationStore = ReturnType; diff --git a/server/src/services/connectors/slack/setup/service.ts b/server/src/services/connectors/slack/setup/service.ts new file mode 100644 index 00000000000..f6b61281aea --- /dev/null +++ b/server/src/services/connectors/slack/setup/service.ts @@ -0,0 +1,151 @@ +import { isProvisionedSlackSetup } from "@paperclipai/shared"; +import { createSlackManagerGrants } from "./manager-grants.js"; +import { createManagedSlackSetup } from "./managed.js"; +import { randomBytes } from "node:crypto"; +import { and, eq, gt, like } from "drizzle-orm"; +import { chatEndpoints, chatSlackRegistrations, companies, toolOauthStates, type Db } from "@paperclipai/db"; +import { badRequest, conflict, forbidden, notFound } from "../../../../errors.js"; +import { removeSlackRegistration } from "../../../chat-slack-registration-cleanup.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; +import { object, providerFailure, createSlackRegistrationStore, type SlackSetupOptions, type SlackSetupActor } from "./registration.js"; +import { createCustomerOwnedSetup } from "./customer-owned.js"; +import { createSlackCompletion } from "./completion.js"; +const prefix = "slack-install."; +export function slackChatRegistrationService(db: Db, options: SlackSetupOptions) { + const store = createSlackRegistrationStore(db, options); + const { endpoint, registration, assertOrigins, callbackUri, audit, api, readSecret, setFailure, saveSecrets } = store; + const provisioner = createCustomerOwnedSetup(store); + const { create } = provisioner; + const completion = createSlackCompletion(store); + const { resumeLocked, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt } = completion; + const managerGrants = createSlackManagerGrants(store); + const managed = createManagedSlackSetup(store, managerGrants, provisioner, completion, install); + async function install(endpointId: string, actor: SlackSetupActor) { + return options.withLock(endpointId, async lease => { + const current = await endpoint(endpointId, actor); + const row = await registration(endpointId); + if (!row?.appId || !row.clientId || row.status === "removed") throw conflict("Create this Slack app before installing it"); + if (row.errorCode === "slack_manifest_update_pending") throw conflict("Finish configuring the saved Slack app before installing it"); + if (!isProvisionedSlackSetup(current.endpoint.setup.slackSetupMethod) || current.endpoint.status === "paused") throw conflict("Resume this connection before installing it"); + if (row.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor); + assertOrigins(row, current.endpoint.publicId); + const state = `${prefix}${randomBytes(32).toString("base64url")}`; + const expiresAt = new Date(Date.now() + 10 * 60_000); + const scopes = object(object(row.manifest.oauth_config).scopes).bot as string[]; + await db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, current.connection.id), like(toolOauthStates.state, `${prefix}%`))); + await tx.insert(toolOauthStates).values({ state, companyId: row.companyId, connectionId: current.connection.id, + codeVerifier: JSON.stringify({ endpointId, requestId: row.requestId, appId: row.appId, callbackUri: row.callbackUri, + endpointStatus: current.endpoint.status, runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }), + createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId, + requestedScopes: scopes, expiresAt }); + await tx.update(chatSlackRegistrations).set({ errorCode: row.managerGrantId ? row.errorCode : null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + }); + const url = new URL("https://slack.com/oauth/v2/authorize"); + url.search = new URLSearchParams({ client_id: row.clientId, scope: scopes.join(","), state, redirect_uri: row.callbackUri, + ...(row.workspaceId ? { team: row.workspaceId } : {}) }).toString(); + await audit(row, actor, "installation_started"); + return { authorizationUrl: url.toString(), expiresAt: expiresAt.toISOString() }; + }); + } + async function expiredReturn(state: string, actor: SlackSetupActor) { + if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) return null; + const [attempt] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state)); + if (!attempt || attempt.expiresAt.getTime() > Date.now() || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) return null; + const binding = object(JSON.parse(attempt.codeVerifier)); + const current = await endpoint(String(binding.endpointId), actor); + return options.withLock(current.endpoint.id, async lease => { + const latest = await endpoint(current.endpoint.id, actor); + const row = await registration(current.endpoint.id); + if (!row || row.status === "removed" || row.requestId !== binding.requestId || row.companyId !== attempt.companyId || latest.connection.id !== attempt.connectionId + || !isProvisionedSlackSetup(latest.endpoint.setup.slackSetupMethod) || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri) return null; + assertOrigins(row, latest.endpoint.publicId); + await lease.assertOwned(); + await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); + await setFailure(row, row.status, "slack_install_expired", lease); + await audit(row, actor, "installation_failed", "slack_install_expired"); + return returnPath(row.endpointId); + }); + } + async function pending(state: string, actor: SlackSetupActor) { + if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) throw badRequest("Invalid Slack authorization state"); + const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))); + if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) + throw forbidden("Slack authorization expired or belongs to another session. Resume setup and install again."); + const binding = object(JSON.parse(attempt.codeVerifier)); + const current = await endpoint(String(binding.endpointId), actor); + const row = await registration(current.endpoint.id); + if (!row || row.status === "removed" || !isProvisionedSlackSetup(current.endpoint.setup.slackSetupMethod) + || current.endpoint.status === "paused" + || current.endpoint.status === "revoked" && binding.endpointStatus !== "revoked" + || binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0) + || row.requestId !== binding.requestId || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri + || row.callbackUri !== callbackUri() || attempt.companyId !== row.companyId || attempt.connectionId !== current.connection.id) + throw conflict("Slack setup changed. Resume the saved connection and install again."); + if (row.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor); + assertOrigins(row, current.endpoint.publicId); + return { attempt, current, row }; + } + async function complete(state: string, code: string | null, error: string | null, actor: SlackSetupActor) { + const before = await pending(state, actor); + return options.withLock(before.row.endpointId, async lease => { + const { row, attempt, current } = await pending(state, actor); + const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning(); + if (!claimed) throw conflict("This Slack authorization was already used"); + if (error) { + const failureCode = error === "access_denied" ? "slack_install_declined" : "slack_install_failed"; + await setFailure(row, row.status, failureCode, lease); + await audit(row, actor, "installation_failed", failureCode); + return row.endpointId; + } + try { + if (!code || code.length > 4096) throw providerFailure("slack_install_failed"); + const result = await api("oauth.v2.access", { code, client_id: row.clientId!, client_secret: await readSecret(row, "clientSecret"), redirect_uri: row.callbackUri }); + const teamId = object(result.team).id; + const installerId = object(result.authed_user).id; + if (result.app_id !== row.appId || result.token_type !== "bot" || typeof result.access_token !== "string" || !result.access_token.startsWith("xoxb-") + || typeof teamId !== "string" || !/^T[A-Z0-9]+$/.test(teamId) || typeof result.bot_user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(result.bot_user_id) + || row.workspaceId && row.workspaceId !== teamId || row.botUserId && row.botUserId !== result.bot_user_id) + throw providerFailure("slack_install_identity_mismatch"); + const granted = new Set(String(result.scope ?? "").split(",")); + if ((attempt.requestedScopes ?? []).some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing"); + if (current.endpoint.providerAccountId && current.endpoint.providerAccountId !== teamId + || current.endpoint.botExternalId && current.endpoint.botExternalId !== result.bot_user_id) + throw providerFailure("slack_install_identity_mismatch"); + if (typeof installerId !== "string" || !/^[UW][A-Z0-9]+$/.test(installerId) || installerId === result.bot_user_id) throw providerFailure("slack_install_account_missing"); + if (row.managerGrantId) { + const grant = await managerGrants.get(row.managerGrantId, row.companyId, actor); + if (grant.status !== "active" || installerId !== grant.slackUserId || teamId !== grant.workspaceId) throw providerFailure("slack_install_identity_mismatch"); + } + await endpoint(row.endpointId, actor); + // Reauthorization reuses the runtime signing secret after staging was cleaned. + await saveSecrets(row, { botToken: result.access_token }, actor, lease, + { status: "credentials_saved", workspaceId: teamId, botUserId: result.bot_user_id, errorCode: null }, + { externalUserId: installerId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" }); + } catch (failure) { + const code = object(object(failure).details).code; + const safeCode = typeof code === "string" && code.startsWith("slack_install_") ? code : "slack_install_failed"; + await setFailure(row, row.status, safeCode, lease); + await audit(row, actor, "installation_failed", safeCode); + return row.endpointId; + } + await resumeLocked(row.endpointId, actor, lease); + return row.endpointId; + }); + } + const cleanup = (endpointId: string, lease: CredentialMutationLeaseGuard) => removeSlackRegistration(db, endpointId, lease); + async function returnPath(endpointId: string) { + const [row] = await db.select({ prefix: companies.issuePrefix }).from(chatEndpoints) + .innerJoin(companies, eq(companies.id, chatEndpoints.companyId)).where(eq(chatEndpoints.id, endpointId)); + if (!row) throw notFound("Slack connection not found"); + return `/${encodeURIComponent(row.prefix)}/apps/chat/connect?provider=slack&resume=${encodeURIComponent(endpointId)}`; + } + return { create, managerGrants, managed, install, pending, expiredReturn, complete, cleanup, returnPath, registration, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt, + close: store.close, + resume: (endpointId: string, actor: SlackSetupActor) => options.withLock(endpointId, async lease => { + const row = await registration(endpointId); + if (row?.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor); + return resumeLocked(endpointId, actor, lease); + }) }; +} diff --git a/server/src/services/connectors/slack/test-message.ts b/server/src/services/connectors/slack/test-message.ts new file mode 100644 index 00000000000..47996f0acf5 --- /dev/null +++ b/server/src/services/connectors/slack/test-message.ts @@ -0,0 +1,134 @@ +import { and, eq, gte, inArray, ne } from "drizzle-orm"; +import { agents, chatActions, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, chatSlackManagerGrants, companyMemberships, toolConnections, type Db } from "@paperclipai/db"; +import { slackTestMessageSchema, type SlackTestMessageInput, type SlackTestMessageResult } from "@paperclipai/shared"; +import { conflict, forbidden, notFound, tooManyRequests, unprocessable } from "../../../errors.js"; +import { logger } from "../../../middleware/logger.js"; +import { logActivity } from "../../activity-log.js"; +import { instanceSettingsService } from "../../instance-settings.js"; +import { createSlackSetupClient, object } from "./setup/provider-client.js"; +import type { SlackSetupActor, SlackSetupOptions } from "./setup/registration.js"; + +type Options = Pick & { + ensureAccount: (endpointId: string, user: Record) => Promise; + createLinkIntent: (endpointId: string, principalId: string, expiresInSeconds: number) => Promise<{ confirmationUrl: string; expiresAt: string }>; +}; +const human = (user: Record, workspaceId: string) => + typeof user.id === "string" && /^[UW][A-Z0-9]+$/.test(user.id) && user.team_id === workspaceId && + user.is_bot === false && user.is_app_user !== true && user.deleted !== true && user.id !== "USLACKBOT"; + +export function slackTestMessageService(db: Db, options: Options) { + const api = createSlackSetupClient(options.fetch); + return async (endpointId: string, actor: SlackSetupActor, rawInput: SlackTestMessageInput): Promise => { + const input = slackTestMessageSchema.parse(rawInput); + if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); + return options.withLock(endpointId, async lease => { + const [record] = await db.select({ endpoint: chatEndpoints, agentName: agents.name }).from(chatEndpoints) + .innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId))) + .innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId), eq(toolConnections.enabled, true), eq(toolConnections.status, "active"))) + .where(eq(chatEndpoints.id, endpointId)); + if (!record || record.endpoint.provider !== "slack") throw notFound("Active Slack connection not found"); + const endpoint = record.endpoint; + if (!["active", "verifying"].includes(endpoint.status) || !endpoint.providerAccountId || !endpoint.allowDirectMessages) throw conflict("Enable direct messages on an active Slack connection first"); + const [membership] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and( + eq(companyMemberships.companyId, endpoint.companyId), eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, actor.userId), eq(companyMemberships.status, "active"), ne(companyMemberships.membershipRole, "viewer"), + )); + if (!membership) throw forbidden("An active Paperclip account is required"); + const actionKey = `slack_test_message:${actor.userId}:${input.requestId}`; + const [previous] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), eq(chatActions.providerActionId, actionKey))); + if (previous) { + if (previous.status === "processed" && typeof previous.result?.slackUrl === "string") return { status: "sent", slackUrl: previous.result.slackUrl }; + throw conflict("This message may already have been sent. Check your Slack DMs before sending another."); + } + const recent = await db.select({ id: chatActions.id }).from(chatActions).where(and( + eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), inArray(chatActions.kind, ["slack_test_message", "slack_test_message_invitation"]), gte(chatActions.createdAt, new Date(Date.now() - 60_000)), + )).limit(5); + if (recent.length >= 5) throw tooManyRequests("Wait a minute before sending another test message"); + const links = await db.select({ endpointId: chatIdentityLinks.endpointId, slackUserId: chatExternalPrincipals.externalId }).from(chatIdentityLinks) + .innerJoin(chatExternalPrincipals, and(eq(chatExternalPrincipals.id, chatIdentityLinks.principalId), eq(chatExternalPrincipals.companyId, chatIdentityLinks.companyId))) + .where(and(eq(chatIdentityLinks.companyId, endpoint.companyId), eq(chatIdentityLinks.paperclipUserId, actor.userId), eq(chatIdentityLinks.status, "linked"), + eq(chatExternalPrincipals.provider, "slack"), eq(chatExternalPrincipals.providerAccountId, endpoint.providerAccountId), eq(chatExternalPrincipals.isBot, false))); + const current = links.filter(link => link.endpointId === endpointId); + const grants = await db.select({ slackUserId: chatSlackManagerGrants.slackUserId }).from(chatSlackManagerGrants).where(and( + eq(chatSlackManagerGrants.companyId, endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.workspaceId, endpoint.providerAccountId), eq(chatSlackManagerGrants.status, "active"), + )); + const known = [...new Set((current.length ? current : [...links, ...grants]).map(link => link.slackUserId))]; + const [invitation] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), eq(chatActions.providerActionId, `${actionKey}:invite`))); + const invitedUser = typeof invitation?.payload.slackUserId === "string" ? invitation.payload.slackUserId : undefined; + if (invitedUser && input.slackUserId && input.slackUserId !== invitedUser) throw conflict("This request already invited a different Slack account"); + const userId = input.slackUserId ?? invitedUser ?? (known.length === 1 ? known[0] : undefined); + const token = await options.runtimeBotToken(endpointId); + if (!token) throw conflict("Reconnect this Slack app before sending a message"); + async function call(method: string, fields: Record) { + try { return await api(method, fields, token); } + catch { throw unprocessable("Slack could not complete this request. Check the connection and try again."); } + } + if (!userId) { + const accounts: { id: string; name: string }[] = []; + const result = await call("users.list", { limit: "200", ...(input.accountCursor ? { cursor: input.accountCursor } : {}) }); + for (const value of Array.isArray(result.members) ? result.members : []) { + const user = object(value); + if (human(user, endpoint.providerAccountId)) accounts.push({ id: String(user.id), name: String(user.real_name || user.name || user.id) }); + } + const cursor = object(result.response_metadata).next_cursor; + return { status: "connection_required", accounts, nextCursor: typeof cursor === "string" && cursor ? cursor : null }; + } + const user = object((await call("users.info", { user: userId })).user); + if (user.id !== userId || !human(user, endpoint.providerAccountId)) throw unprocessable("Choose your personal Slack account in this workspace"); + if (!known.includes(userId)) { + const principalId = await options.ensureAccount(endpointId, user); + const principalLinks = await db.select().from(chatIdentityLinks).where(and(eq(chatIdentityLinks.companyId, endpoint.companyId), eq(chatIdentityLinks.principalId, principalId))); + if (principalLinks.some(link => link.status === "linked" && link.paperclipUserId !== actor.userId) + || principalLinks.some(link => link.endpointId === endpointId && link.status === "revoked")) throw conflict("This Slack account has an existing link"); + if (invitation) { + if (invitation.status !== "processed") throw conflict("The invitation may already have been sent. Check Slack before requesting another."); + const expiresAt = String(invitation.result?.expiresAt ?? ""); + if (!expiresAt || new Date(expiresAt).getTime() <= Date.now()) throw conflict("The Slack invitation expired. Request a new test message."); + return { status: "connection_pending", principalId, expiresAt }; + } + // The account holder must follow the private Slack challenge and sign in. + // Selecting a workspace member does not prove identity ownership. + const intent = await options.createLinkIntent(endpointId, principalId, 600); + const [pending] = await db.insert(chatActions).values({ companyId: endpoint.companyId, endpointId, principalId, + kind: "slack_test_message_invitation", providerActionId: `${actionKey}:invite`, status: "processing", payload: { userId: actor.userId, slackUserId: userId } }).returning(); + try { + const response = await call("chat.postMessage", { channel: userId, + text: `Connect your Slack account to ${record.agentName} in Paperclip to receive your test message.`, + blocks: JSON.stringify([{ type: "section", text: { type: "plain_text", text: `Connect your Slack account to ${record.agentName} in Paperclip.` } }, + { type: "section", text: { type: "mrkdwn", text: `<${intent.confirmationUrl}|Connect my account> to receive your test message.` } }]), + unfurl_links: "false", unfurl_media: "false" }); + if (typeof response.channel !== "string" || !/^D[A-Z0-9]+$/.test(response.channel) || typeof response.ts !== "string") throw unprocessable("Slack did not confirm invitation delivery"); + await lease.assertOwned(); + await db.update(chatActions).set({ status: "processed", result: { expiresAt: intent.expiresAt }, updatedAt: new Date() }).where(eq(chatActions.id, pending!.id)); + } catch { + await db.update(chatActions).set({ status: "uncertain", result: { code: "slack_invitation_unconfirmed" }, updatedAt: new Date() }).where(eq(chatActions.id, pending!.id)); + throw unprocessable("Invitation delivery was not confirmed. Check Slack before requesting another."); + } + try { await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_invited", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); } + catch { logger.warn({ endpointId }, "Slack test invitation activity could not be recorded"); } + return { status: "connection_pending", principalId, expiresAt: intent.expiresAt }; + } + await options.linkInstaller(endpointId, user, actor, lease); + await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_requested", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); + await lease.assertOwned(); + // Persist before dispatch: a crash or ambiguous response must never replay the DM. + const [action] = await db.insert(chatActions).values({ companyId: endpoint.companyId, endpointId, kind: "slack_test_message", providerActionId: actionKey, + status: "processing", payload: { userId: actor.userId, slackUserId: userId } }).returning(); + let slackUrl: string; + try { + const response = await call("chat.postMessage", { channel: userId, + text: `Hi, I’m ${record.agentName}. This is your test message. Reply here to talk to me.`, unfurl_links: "false", unfurl_media: "false" }); + if (typeof response.channel !== "string" || !/^D[A-Z0-9]+$/.test(response.channel) || typeof response.ts !== "string") throw unprocessable("Slack did not confirm delivery. Check your DMs before trying again."); + slackUrl = `https://app.slack.com/client/${encodeURIComponent(endpoint.providerAccountId)}/${response.channel}`; + await lease.assertOwned(); + await db.update(chatActions).set({ status: "processed", result: { slackUrl }, updatedAt: new Date() }).where(eq(chatActions.id, action!.id)); + } catch { + await db.update(chatActions).set({ status: "uncertain", result: { code: "slack_test_message_unconfirmed" }, updatedAt: new Date() }).where(eq(chatActions.id, action!.id)); + throw unprocessable("Delivery was not confirmed. Check your Slack DMs before sending another test message."); + } + try { await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_sent", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); } + catch { logger.warn({ endpointId }, "Slack test message activity could not be recorded"); } + return { status: "sent", slackUrl }; + }); + }; +} diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index 42e8a3cd4bb..a2a0b37abe0 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -7,6 +7,7 @@ import { randomBytes, type KeyObject, } from "node:crypto"; +import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; import { describe, expect, it, vi } from "vitest"; import { readFileSync } from "node:fs"; @@ -406,6 +407,16 @@ describe("Paperclip Cloud connector", () => { })).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" }); }); + it("opens Slack manager handoffs with their exact profile and personal identity", async () => { + const keys = config(); + const credentials = { v: 1, accessToken: "manager-access-canary", refreshToken: "manager-refresh-canary", tokenType: "user", accessTokenExpiresAt: "2030-01-01T00:00:00.000Z", refreshTokenExpiresAt: null, + scopes: [...SLACK_MANAGER_SCOPES], subject, companyId, instanceId, environment: "staging", provider: "slack", profile: "slack.manager", + slackManager: { appId: "AMANAGER", workspaceId: "TWORKSPACE", workspaceName: "Paperclip", userId: "UOWNER" } }; + const sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "slack.manager"); + const connector = createPaperclipCloudConnector({ config: keys.config, request: vi.fn(async () => Response.json({ claimId: "claim", scopes: [...SLACK_MANAGER_SCOPES], sealed })) as typeof fetch }); + await expect(connector.claim({ subject, companyId, profile: "slack.manager", claimId: "claim", redemptionId: "manager-state" })).resolves.toEqual(credentials); + await expect(connector.claim({ subject: "another-user", companyId, profile: "slack.manager", claimId: "claim", redemptionId: "manager-state" })).rejects.toThrow(); + }); it("opens an instance-sealed claim and verifies its user, company, and exact scopes", async () => { const keys = config(); const credentials = { @@ -612,10 +623,10 @@ function seal( recipientPublicKey: KeyObject, purpose: "initial" | "access" | "events", configValue: PaperclipCloudConnectorConfig, - profile: keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES | "github.bot" | "github.code", + profile: keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES | "slack.manager" | "github.bot" | "github.code", ) { - const provider = profile.startsWith("github.") ? "github" : "google"; - const scopes = provider === "github" ? [] : GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile as keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES].scopes; + const provider = profile.startsWith("github.") ? "github" : profile === "slack.manager" ? "slack" : "google"; + const scopes = provider === "github" ? [] : profile === "slack.manager" ? SLACK_MANAGER_SCOPES : GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile as keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES].scopes; const ephemeral = generateKeyPairSync("x25519"); const ephemeralJwk = ephemeral.publicKey.export({ format: "jwk" }) as { x: string }; const recipientJwk = recipientPublicKey.export({ format: "jwk" }) as { x: string }; diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index 7785deeb596..241ff937b57 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -1,4 +1,4 @@ -import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; +import { SLACK_MANAGER_SCOPES, ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; import { createDecipheriv, createHash, @@ -34,8 +34,8 @@ export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES }; export type PaperclipCloudConnectorEnvironment = "development" | "staging" | "production"; export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack" | "github-app"; -export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "github.bot"; -export type PaperclipCloudConnectorProvider = "google" | "github" | "asana"; +export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "slack.manager" | "github.bot"; +export type PaperclipCloudConnectorProvider = "google" | "github" | "asana" | "slack"; export type PaperclipCloudConnectorConfig = { baseUrl: string; @@ -60,6 +60,7 @@ export type SealedConnectorCredentials = { provider: PaperclipCloudConnectorProvider; profile: string; appSlug?: string; + slackManager?: { appId: string; workspaceId: string; workspaceName: string; userId: string }; }; export type SealedGmailCredentials = SealedConnectorCredentials & { provider: "google" }; @@ -95,6 +96,7 @@ type SealedEnvelope = { }; type ConnectorResponse = { + slackManager?: unknown; confirmationUrl?: unknown; authorizationUrl?: unknown; handoff?: unknown; @@ -413,6 +415,13 @@ export function createPaperclipCloudConnector(input: { typeof value === "string" && isPaperclipCloudConnectorProfileId(value) ? [value] : [] ))]; }, + async getSlackManagerAppId(): Promise { + const response = await call("status", { subject: "instance-capabilities", companyId: "instance-capabilities" }); + const manager = response.slackManager; + if (response.active !== true || response.status !== "active" || !Array.isArray(response.profiles) || !response.profiles.includes("slack.manager") + || !manager || typeof manager !== "object" || !("appId" in manager) || typeof manager.appId !== "string" || !/^A[A-Z0-9]+$/.test(manager.appId)) return null; + return manager.appId; + }, async startAuthorization(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri: string; returnState: string }) { const profile = values.profile ?? "gmail.draft"; const response = await call("session", { ...values, profile }); @@ -782,6 +791,7 @@ function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId): provider: PaperclipCloudConnectorProvider; scopes: readonly string[]; } { + if (profile === "slack.manager") return { provider: "slack", scopes: SLACK_MANAGER_SCOPES }; if (profile === "github.bot") return { provider: "github", scopes: [] }; if (isAsanaConnectorProfileId(profile)) { return { provider: "asana", scopes: ASANA_CONNECTOR_SCOPES }; @@ -796,6 +806,7 @@ function isExpectedProviderAuthorizationUrl( profile: PaperclipCloudConnectorProfileId, url: URL, ): boolean { + if (profile === "slack.manager") return url.origin === "https://slack.com" && url.pathname === "/oauth/v2/authorize"; if (isAsanaConnectorProfileId(profile)) { return url.origin === "https://app.asana.com" && url.pathname === "/-/oauth_authorize"; } @@ -806,7 +817,7 @@ function isExpectedProviderAuthorizationUrl( } function isPaperclipCloudConnectorProfileId(value: string): value is PaperclipCloudConnectorProfileId { - return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value); + return value === "slack.manager" || isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value); } async function sha256Base64Url(value: string): Promise { diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts index c64cb950ca8..a7795120a94 100644 --- a/tests/e2e/chat-adapters-ui-providers.spec.ts +++ b/tests/e2e/chat-adapters-ui-providers.spec.ts @@ -156,6 +156,114 @@ test.describe.serial("native chat adapter UI", () => { seed = await seedCompanyAndAgent(request); }); + test("Slack: managed workspace setup has two stops, resumes and completes without pasting credentials", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat`); + await selectMaya(page); + await expect(page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button")).toHaveCount(2); + await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible(); + await expect(page.getByLabel("App configuration access token", { exact: true })).toHaveCount(0); + await expect(page.getByLabel("Signing Secret", { exact: true })).toHaveCount(0); + await page.reload(); + expect(mock.slackCreations).toBe(1); expect(mock.slackInstallations).toBe(1); + mock.setWebhookVerified(); + await expect(page.getByRole("heading", { name: "Success", exact: true })).toBeVisible(); + await expect(page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button")).toHaveCount(2); + await page.getByRole("button", { name: "Done", exact: true }).click(); + await expect(page).toHaveURL(/\/apps\/chat\/endpoint-slack\/settings$/); + }); + test("Slack: a failed manager refresh reloads workspace authorization before retry", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + let refreshFailed = false; + let authorizationRequests = 0; + await page.route("**/chat-slack/setup-options", async route => { + if (!refreshFailed) return route.fallback(); + await route.fulfill({ json: { managedAvailable: true, defaultMethod: "managed", workspaces: [] } }); + }); + await page.route("**/slack/managed/provision", async route => { + refreshFailed = true; + await route.fulfill({ status: 422, json: { error: "Reconnect your Slack workspace.", details: { code: "slack_manager_reauthorize" } } }); + }); + await page.route("**/slack/managed/authorize", async route => { + authorizationRequests++; + await route.fulfill({ json: { authorizationUrl: "https://slack.test/manager-consent", expiresAt: new Date(Date.now() + 600_000).toISOString() } }); + }); + await page.route("https://slack.test/manager-consent", route => route.fulfill({ contentType: "text/html", body: "Slack authorization fixture" })); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + await expect(page.getByRole("alert")).toBeVisible(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + await expect.poll(() => authorizationRequests).toBe(1); + await expect(page).toHaveURL("https://slack.test/manager-consent"); + }); + test("Slack: every uncertain creation retry needs a fresh confirmation", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + mock.setSlackUncertain(); + const attempts: { requestId: string; confirmedNoAppCreated?: boolean }[] = []; + await page.route("**/slack/managed/provision", async route => { + attempts.push(route.request().postDataJSON()); + await route.fulfill({ json: {} }); + }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + const confirm = page.getByRole("checkbox", { name: "I checked Slack and no app was created. Create a new app." }); + const create = page.getByRole("button", { name: "Continue", exact: true }); + await expect(create).toBeDisabled(); + await confirm.check(); await create.click(); + await expect(confirm).not.toBeChecked(); await expect(create).toBeDisabled(); + expect(attempts).toHaveLength(1); expect(attempts[0]!.confirmedNoAppCreated).toBe(true); + await confirm.check(); await create.click(); + await expect(confirm).not.toBeChecked(); await expect(create).toBeDisabled(); + expect(attempts).toHaveLength(2); expect(attempts[1]!.requestId).not.toBe(attempts[0]!.requestId); + }); + test("Slack: own-app creation consumes confirmation even when the response is lost", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, automaticSlack: true }); + mock.setSlackUncertain(); + let attempts = 0; + await page.route("**/slack/registration", async route => { + attempts++; expect(route.request().postDataJSON().confirmedNoAppCreated).toBe(true); + await route.fulfill({ status: 502, json: { error: "Creation response unavailable" } }); + }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + const confirm = page.getByRole("checkbox"); + await confirm.check(); + await page.getByLabel("App configuration access token", { exact: true }).fill("fixture-config-token"); + await page.getByRole("button", { name: "Create Slack app", exact: true }).click(); + await expect(page.getByRole("alert")).toBeVisible(); + await expect(confirm).not.toBeChecked(); + await expect(page.getByRole("button", { name: "Create Slack app", exact: true })).toBeDisabled(); + expect(attempts).toBe(1); + }); + test("Slack: temporarily unavailable managed setup can retry in place", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + let available = false; + await page.route("**/chat-slack/setup-options", async route => { + if (available) return route.fallback(); + await route.fulfill({ json: { managedAvailable: false, defaultMethod: "automatic", workspaces: [] } }); + }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + await expect(page.getByText("Managed Slack setup is unavailable. Try again later.")).toBeVisible(); + available = true; await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible(); + }); + test("Slack: own-app selection persists before provisioning", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat`); + await page.getByRole("button", { name: "Choose an active agent" }).click(); + await page.getByRole("button", { name: "Select Maya" }).click(); + await page.getByText("Advanced", { exact: true }).click(); + await page.getByRole("button", { name: "Use your own app" }).click(); + await expect(page.getByRole("heading", { name: "App configuration access token", exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByRole("heading", { name: "App configuration access token", exact: true })).toBeVisible(); + await page.getByText("Advanced", { exact: true }).click(); + await page.getByRole("button", { name: "Use managed setup" }).click(); + await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible(); + }); test("Slack: automatic creation survives refresh, consent, and connection evidence without copying durable secrets", async ({ page }) => { const slack = PROVIDERS.find(provider => provider.provider === "slack")!; const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, automaticSlack: true }); @@ -440,6 +548,10 @@ test.describe.serial("native chat adapter UI", () => { ).toBeVisible(); await expectSetupRail(page, true); await selectMaya(page); + if (provider.provider === "slack") { + await page.getByText("Advanced", { exact: true }).click(); + await page.getByRole("button", { name: "Create manually", exact: true }).click(); + } await expect.poll(() => mock.createdWithAgentId).toBe(seed.agentId); expect(mock.createdWithAgentId).not.toBe(seed.otherAgentId); await expect( @@ -573,18 +685,12 @@ test.describe.serial("native chat adapter UI", () => { page.getByRole("heading", { name: provider.provider === "slack" ? "Allowed Channels" : "Destinations" }), ).toBeVisible(); if (provider.provider === "slack") { - await page.locator("summary").filter({ hasText: "Message in a channel" }).click(); - await expect(page.getByText("@maya-paperclip you there?", { exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Copy message" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Allowed Channels" })).toBeVisible(); - await page.locator("summary").filter({ hasText: /^Agent avatar$/ }).click(); - const avatarSection = page.getByRole("region", { name: "Slack avatar" }); - await expect(avatarSection.getByRole("link", { name: "Download avatar" })).toBeVisible(); - await avatarSection.getByText("How to upload in Slack", { exact: true }).click(); - await expect(avatarSection.getByRole("link", { name: "Open Slack app Settings" })).toBeVisible(); - const settingsDownloadEvent = page.waitForEvent("download"); - await avatarSection.getByRole("link", { name: "Download avatar" }).click(); - expect((await settingsDownloadEvent).suggestedFilename()).toBe("maya-paperclip-avatar.png"); + await expect(page.getByRole("button", { name: "Send me a test message" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Open Slack configuration" })).toBeVisible(); + await expect(page.getByText("Message in a channel", { exact: true })).toHaveCount(0); + await expect(page.getByText("Agent avatar", { exact: true })).toHaveCount(0); + await expect(page.getByRole("link", { name: "View message" })).toHaveCount(0); } await expect( diff --git a/tests/e2e/chat-adapters-ui.shared.ts b/tests/e2e/chat-adapters-ui.shared.ts index e8904fc98ed..afa8a9686b9 100644 --- a/tests/e2e/chat-adapters-ui.shared.ts +++ b/tests/e2e/chat-adapters-ui.shared.ts @@ -281,7 +281,7 @@ export function endpointFixture(provider: ProviderCase, seed: Seed) { proactiveDirectMessages: false, }, setup: { - slackSetupMethod: undefined as "automatic" | "manual" | "existing" | undefined, + slackSetupMethod: undefined as "managed" | "automatic" | "manual" | "existing" | undefined, slackRegistration: undefined as SlackRegistrationState | undefined, slackApp: undefined as SlackAppConfiguration | undefined, slackOAuthCallbackUri: "https://paperclip.example.test/api/chat-slack/oauth/callback", @@ -353,10 +353,11 @@ export async function installChatControlPlaneMock( resourceCount = 2, photonShared = false, automaticSlack = false, - }: { enableChatConnectors: boolean; enableGitHubReviewBots?: boolean; resourceCount?: number; photonShared?: boolean; automaticSlack?: boolean }, + managedSlack = false, + }: { enableChatConnectors: boolean; enableGitHubReviewBots?: boolean; resourceCount?: number; photonShared?: boolean; automaticSlack?: boolean; managedSlack?: boolean }, ): Promise { const endpoint = endpointFixture(provider, seed); - if (automaticSlack) endpoint.setup.slackSetupMethod = "automatic"; + if (automaticSlack || managedSlack) endpoint.setup.slackSetupMethod = managedSlack ? "managed" : "automatic"; let slackIdentityLinked = false; let githubAppConnected = false; let githubConfiguration = { revision: 0, configuration: { @@ -510,6 +511,7 @@ export async function installChatControlPlaneMock( assignedAgentId: seed.agentId, }); state.createdWithAgentId = String(body.assignedAgentId); + if (body.slackSetupMethod) endpoint.setup.slackSetupMethod = body.slackSetupMethod as "managed" | "automatic" | "manual" | "existing"; if (provider.provider === "slack" && body.slackApp) { endpoint.setup.slackApp = body.slackApp as SlackAppConfiguration; endpoint.setup.command = endpoint.setup.slackApp.command; @@ -536,7 +538,7 @@ export async function installChatControlPlaneMock( if (method === "PATCH") { const body = bodyOf(route); if (body.slackApp) endpoint.setup.slackApp = body.slackApp as SlackAppConfiguration; - if (body.slackSetupMethod) endpoint.setup.slackSetupMethod = body.slackSetupMethod as "automatic" | "manual" | "existing"; + if (body.slackSetupMethod) endpoint.setup.slackSetupMethod = body.slackSetupMethod as "managed" | "automatic" | "manual" | "existing"; if (typeof body.allowDirectMessages === "boolean") state.allowDirectMessages = body.allowDirectMessages; if (typeof body.allowGroupChats === "boolean") @@ -563,6 +565,16 @@ export async function installChatControlPlaneMock( return; } + if (pathname === `/api/companies/${seed.companyId}/chat-slack/setup-options`) { + await fulfill(route, { managedAvailable: managedSlack, defaultMethod: managedSlack ? "managed" : "automatic", workspaces: managedSlack ? [{ grantId: "11111111-1111-4111-8111-111111111111", workspaceId: "TE2E", workspaceName: "Paperclip", userId: "UINSTALLER" }] : [] }); return; + } + if (pathname === `/api/chat-endpoints/${endpoint.id}/slack/managed/provision`) { + const body = bodyOf(route); + expect(body.grantId).toBe("11111111-1111-4111-8111-111111111111"); + expect(body).not.toHaveProperty("credentials"); + state.slackCreations++; state.slackInstallations++; + state.setSlackInstalled(); await fulfill(route, {}); return; + } if (pathname === `/api/chat-endpoints/${endpoint.id}/slack/registration`) { const body = bodyOf(route); expect(body.credentials).toEqual({ configurationToken: "fixture-config-token" }); diff --git a/ui/src/api/chatEndpoints.ts b/ui/src/api/chatEndpoints.ts index a640a8e3f98..73aa8080ef4 100644 --- a/ui/src/api/chatEndpoints.ts +++ b/ui/src/api/chatEndpoints.ts @@ -1,3 +1,4 @@ +import type { SlackSetupOptions, SlackManagedProvisionInput, SlackManagedResult } from "@paperclipai/shared"; import { api } from "./client"; import type { SlackAppConfiguration, @@ -140,7 +141,8 @@ export interface ChatEndpoint { messagingEndpoint?: string | null; command?: string | null; slackApp?: SlackAppConfiguration; - slackSetupMethod?: "automatic" | "manual" | "existing"; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; + slackManagerError?: string; slackRegistration?: SlackRegistrationState; slackAvatar?: SlackAvatarState; slackAccount?: SlackAccountState; @@ -202,7 +204,7 @@ export const chatEndpointsApi = { api.get(`/chat-endpoints/${endpointId}`), create: ( companyId: string, - input: { provider: ChatProvider; assignedAgentId: string; slackApp?: SlackAppConfiguration }, + input: { provider: ChatProvider; assignedAgentId: string; slackApp?: SlackAppConfiguration; slackSetupMethod?: "managed" | "automatic" | "manual" | "existing" }, ) => api.post(`/companies/${companyId}/chat-endpoints`, input), update: ( endpointId: string, @@ -216,6 +218,11 @@ export const chatEndpointsApi = { photon?: PhotonChannelConfiguration; }, ) => api.post(`/chat-endpoints/${endpointId}/setup`, input), + sendSlackTestMessage: (endpointId: string, input: import("@paperclipai/shared").SlackTestMessageInput) => + api.post(`/chat-endpoints/${endpointId}/slack/test-message`, input), + slackSetupOptions: (companyId: string) => api.get(`/companies/${companyId}/chat-slack/setup-options`), + authorizeSlackManager: (endpointId: string) => api.post(`/chat-endpoints/${endpointId}/slack/managed/authorize`, {}), + provisionManagedSlack: (endpointId: string, input: SlackManagedProvisionInput) => api.post(`/chat-endpoints/${endpointId}/slack/managed/provision`, input), createSlackApp: (endpointId: string, input: SlackRegistrationInput) => api.post(`/chat-endpoints/${endpointId}/slack/registration`, input), installSlackApp: (endpointId: string) => diff --git a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx index dedf6127417..adf87af5089 100644 --- a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx +++ b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx @@ -503,23 +503,15 @@ describe("chat setup and identity-link clipboard actions", () => { expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("4Send a message"); }); - it("keeps a terminated agent’s saved avatar available in settings", async () => { - mocks.tab = "settings"; - mocks.listAgents.mockResolvedValue([]); - mocks.getAgent.mockResolvedValue({ id: "agent-a", name: "Maya", status: "terminated", appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "orchid-peach" } }); - await render("slack", true); - expect(container.querySelector('a[download]')?.getAttribute("href")).toBe("/api/agent-avatars/cap-v1/orchid-peach/rest.png?size=512&scale=1&background=paperclip-dark"); - expect(mocks.getAgent).toHaveBeenCalledWith("agent-a", "company-a"); - }); - - it("keeps avatar download available in connector settings", async () => { + it("keeps settings focused on channel access and the test-message action", async () => { mocks.tab = "settings"; await render("slack", true); - const section = container.querySelector('section[aria-label="Slack avatar"]')!; - expect(section.textContent).toContain("Download avatar"); - expect(section.querySelector('a[download]')?.getAttribute("href")).toContain("/cherry-pop/rest.png?size=512&scale=1"); - expect(section.querySelector("details")?.open).toBe(false); - expect(mocks.getAgent).toHaveBeenCalledWith("agent-a", "company-a"); + expect(container.textContent).toContain("Send me a test message"); + expect(container.textContent).toContain("Open Slack configuration"); + expect(container.textContent).not.toContain("Message in a channel"); + expect(container.textContent).not.toContain("Agent avatar"); + expect(container.textContent).not.toContain("Manage action permissions"); + expect(container.querySelector('a[download]')).toBeNull(); }); it("discovers a newly invited, enabled Slack channel while settings stays open", async () => { diff --git a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx index d26e6b75416..dd075cf795e 100644 --- a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx @@ -1,14 +1,13 @@ +import { agentAvatarUrl } from "@/lib/agent-avatar-url"; +import { resolveAgentAppearance } from "@paperclipai/shared"; +import { SlackTestMessageButton } from "./SlackTestMessageButton"; +import { AgentAvatar } from "@/components/AgentAvatar"; import { Identity } from "@/components/Identity"; import { SlackSetupAdvanced } from "./SlackAppDetails"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { SlackToolsSettings, SlackSearchAccess } from "./SlackToolSettings"; -import { defaultSlackAppName } from "./slack-app-name"; import { ChatCommunicationInstructions } from "./ChatCommunicationInstructions"; -import { SlackAvatarSettings } from "./SlackAvatarStep"; import { agentsApi } from "@/api/agents"; -import { agentAvatarUrl } from "@/lib/agent-avatar-url"; -import { resolveAgentAppearance } from "@paperclipai/shared"; -import { AgentAvatar } from "@/components/AgentAvatar"; import { ChatConversationList } from "./ChatConversationList"; import { GitHubBotManagement, GitHubReviews } from "./GitHubBotManagement"; import { GitHubBotMention, GitHubAppIdentityActions } from "./GitHubAppIdentity"; @@ -315,8 +314,9 @@ export function ChatEndpointDetail() { endpoint.setup?.step !== "complete" && !(endpoint.provider === "slack" && endpoint.setup?.step === "test") && ["draft", "verifying", "attention", "revoked"].includes(endpoint.status); - const slackUrl = endpoint.provider === "slack" && endpoint.providerAccountId && endpoint.botExternalId - ? `https://app.slack.com/client/${encodeURIComponent(endpoint.providerAccountId)}/user/${encodeURIComponent(endpoint.botExternalId)}` + const slackAppId = endpoint.setup?.slackRegistration?.appId; + const slackUrl = endpoint.provider === "slack" + ? slackAppId ? `https://api.slack.com/apps/${encodeURIComponent(slackAppId)}/oauth` : "https://api.slack.com/apps" : null; return ( @@ -347,8 +347,8 @@ export function ChatEndpointDetail() { )} -
- {slackUrl && } +
+ {slackUrl && } {setupIncomplete ? (
+ {endpoint.provider === "slack" && } {endpoint.provider === "github" && } @@ -435,13 +436,6 @@ function Settings({ }) { const queryClient = useQueryClient(); const { pushToast } = useToast(); - const [messageCopied, setMessageCopied] = useState(false); - const avatarAgent = useQuery({ - queryKey: queryKeys.agents.detail(endpoint.assignedAgentId), - queryFn: () => agentsApi.get(endpoint.assignedAgentId, endpoint.companyId), - enabled: endpoint.provider === "slack", - }); - const mentionMessage = `@${(endpoint.botUsername ?? endpoint.botLabel ?? endpoint.assignedAgentName).replace(/^@/, "")} you there?`; const resourcesQuery = useQuery({ queryKey: queryKeys.chatEndpoints.resources(endpointId), queryFn: () => chatEndpointsApi.listResources(endpointId), @@ -512,7 +506,7 @@ function Settings({ No destinations yet.

) : ( -
+
{destinationResources.map((resource) => (
@@ -595,27 +589,7 @@ function Settings({ queryClient.setQueryData(queryKeys.chatEndpoints.detail(endpointId), next); }} />} - {endpoint.provider === "slack" && ( -
-

Invite the bot to a channel, then mention it to start a conversation.

-
- {mentionMessage} - -
-
- )} - {endpoint.provider === "slack" && - {avatarAgent.isPending ?

Loading agent avatar…

- : avatarAgent.isError ?

Couldn’t load the agent’s avatar.

- : } -
} - {endpoint.provider === "slack" && } + {endpoint.provider === "slack" && } ); } @@ -634,7 +608,7 @@ function SettingToggle({ onChange: (value: boolean) => void; }) { return ( -
+

{label}

{detail &&

{detail}

} diff --git a/ui/src/pages/apps/chat/ChatEndpointSetup.tsx b/ui/src/pages/apps/chat/ChatEndpointSetup.tsx index fae0c99bb7f..a8d45b008ac 100644 --- a/ui/src/pages/apps/chat/ChatEndpointSetup.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointSetup.tsx @@ -1,6 +1,7 @@ import { buildSlackAppManifest, defaultSlackAppConfiguration } from "@paperclipai/shared"; import { SlackAppDetails, SlackSetupAdvanced } from "./SlackAppDetails"; -import { SlackAutomaticSetup } from "./SlackAutomaticSetup"; +import { SlackSetup } from "./slack/SlackSetup"; +import { isProvisionedSlackSetup, slackSetupState } from "./slack/setup-state"; import { defaultSlackAppName } from "./slack-app-name"; import { GitHubChatSetup } from "./GitHubChatSetup"; import { GitHubAgentTrustWarning } from "@/components/GitHubAgentTrustWarning"; @@ -105,10 +106,11 @@ export function isChatEndpointRepairing( ); } -export function ChatConnectionPurpose({ provider, onChat, onTools }: { +export function ChatConnectionPurpose({ provider, onChat, onTools, slackLabels }: { provider: ChatProvider; onChat: () => void; onTools: () => void; + slackLabels?: string[]; }) { const { setBreadcrumbs } = useBreadcrumbs(); useEffect(() => { @@ -117,7 +119,7 @@ export function ChatConnectionPurpose({ provider, onChat, onTools }: { }, [setBreadcrumbs]); return (
- +

Choose how to connect

@@ -192,6 +194,7 @@ function ChatSdkEndpointSetup() { const [viewedStep, setViewedStep] = useState(null); const [slackIdentityReady, setSlackIdentityReady] = useState(false); const [automaticBusy, setAutomaticBusy] = useState(false); + const [managedAutoStart, setManagedAutoStart] = useState(Boolean(resumeEndpointId)); const [slackDraft, setSlackDraft] = useState<{ agentId: string; app: SlackAppConfiguration } | null>(null); const [endpoint, setEndpoint] = useState(null); const [credentials, setCredentials] = useState>({}); @@ -220,7 +223,7 @@ function ChatSdkEndpointSetup() { useEffect(() => { if (!resumeQuery.data) return; setEndpoint(current => { - if (current?.setup?.slackSetupMethod === "automatic" && !current.providerAccountId && resumeQuery.data.providerAccountId) setViewedStep(null); + if (isProvisionedSlackSetup(current?.setup?.slackSetupMethod) && !current?.providerAccountId && resumeQuery.data.providerAccountId) setViewedStep(null); return resumeQuery.data; }); setAgentId(resumeQuery.data.assignedAgentId); @@ -273,7 +276,8 @@ function ChatSdkEndpointSetup() { next: ChatEndpoint, onlyIfStillVisible = false, ) => { - if (next.setup?.slackSetupMethod === "automatic" && next.setup.slackRegistration?.status === "configured" + if (next.setup?.slackSetupMethod === "managed" && endpoint && endpoint.setup?.slackSetupMethod !== "managed") setManagedAutoStart(true); + if (isProvisionedSlackSetup(next.setup?.slackSetupMethod) && next.setup?.slackRegistration?.status === "configured" && endpoint?.setup?.slackRegistration?.status !== "configured") setViewedStep(null); setEndpoint((visible) => onlyIfStillVisible && visible?.id !== next.id ? visible : next, @@ -287,21 +291,31 @@ function ChatSdkEndpointSetup() { ); } }; + const slackChoices = useQuery({ + queryKey: ["slack-setup-options", selectedCompanyId], + queryFn: () => chatEndpointsApi.slackSetupOptions(selectedCompanyId!), + enabled: provider === "slack" && Boolean(selectedCompanyId), + retry: false, + }); const createEndpoint = useMutation({ - mutationFn: () => - chatEndpointsApi.create(selectedCompanyId!, { + mutationFn: ({ ownApp = false }: { ownApp?: boolean; exit?: boolean }) => { + if (provider === "slack" && !slackChoices.data) throw new Error("Slack setup is still loading. Try again."); + return chatEndpointsApi.create(selectedCompanyId!, { provider: provider!, assignedAgentId: agentId, - ...(provider === "slack" ? { slackApp: slackDetailsValidation.success ? slackDetailsValidation.data : slackDetails } : {}), - }), - onSuccess: (next) => { - setViewedStep(null); + ...(provider === "slack" ? { slackSetupMethod: ownApp ? "automatic" : slackChoices.data!.defaultMethod, slackApp: slackDetailsValidation.success ? slackDetailsValidation.data : slackDetails } : {}), + }); + }, + onSuccess: (next, { exit }) => { + setManagedAutoStart(!exit); + setViewedStep(exit ? 0 : null); syncEndpointSnapshot(next); if (next.provider === "imessage-photon" || next.provider === "slack") { const resumed = new URLSearchParams(params); resumed.set("resume", next.id); setParams(resumed, { replace: true }); } + if (exit) navigate("/apps"); }, onError: (error) => pushToast({ @@ -324,13 +338,22 @@ function ChatSdkEndpointSetup() { if (exit && provider !== "slack") { navigate("/apps"); return; } if (!endpoint) { if (!agentId) { if (exit) navigate("/apps"); return; } - createEndpoint.mutate(undefined, { onSuccess: () => { if (exit) navigate("/apps"); } }); + createEndpoint.mutate({ exit }); return; } try { if (provider === "slack") await persistSlackDetails(); } catch { return; } - if (exit) navigate("/apps"); else setViewedStep(1); + if (exit) navigate("/apps"); else { setManagedAutoStart(true); setViewedStep(null); } }; + const useOwnSlackApp = useMutation({ + mutationFn: async () => { + if (!endpoint) return createEndpoint.mutateAsync({ ownApp: true }); + await persistSlackDetails(); + return chatEndpointsApi.update(endpoint.id, { slackSetupMethod: "automatic" }); + }, + onSuccess: next => { setManagedAutoStart(false); syncEndpointSnapshot(next); setViewedStep(1); }, + onError: () => pushToast({ title: "Couldn't switch Slack setup", body: "Try again.", tone: "error" }), + }); const setupAction = useMutation({ mutationFn: async ({ action, @@ -454,21 +477,14 @@ function ChatSdkEndpointSetup() { reconnectRequested, ); const isSlack = provider === "slack"; - const automaticSlack = isSlack && (!endpoint || endpoint.setup?.slackSetupMethod === "automatic"); - const automaticSlackComplete = automaticSlack && (endpoint?.setup?.step === "test" || endpoint?.setup?.step === "complete"); - const tryStep = isSlack ? automaticSlack ? 3 : 5 : 2; - const availableStep = endpoint - ? automaticSlack && endpoint.setup?.slackRegistration?.status === "configured" && endpoint.setup.slackAccount?.status !== "linked" && endpoint.setup.step !== "complete" ? 2 - : !repairing && - (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") - ? isSlack && endpoint.setup?.step !== "complete" - ? !automaticSlack && !slackIdentityReady ? 4 : tryStep - : tryStep - : automaticSlack && endpoint.setup?.slackRegistration?.status === "credentials_saved" ? 2 - : isSlack && endpoint.providerAccountId && !repairing ? 3 - : isSlack && (slackCredentialsReady || repairing || endpoint.setup?.slackSetupMethod === "existing" || automaticSlack && endpoint.setup?.slackRegistration?.appId) ? 2 : 1 - : 0; + const slackNavigation = slackSetupState({ endpoint, repairing, credentialsReady: slackCredentialsReady, identityReady: slackIdentityReady, defaultMethod: slackChoices.data?.defaultMethod }); + const automaticSlack = isSlack && slackNavigation.provisioned; + const automaticSlackComplete = automaticSlack && slackNavigation.complete; + const tryStep = isSlack ? slackNavigation.finalStage : 2; + const availableStep = isSlack ? slackNavigation.availableStage : !endpoint ? 0 + : !repairing && (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") ? tryStep : 1; const step = Math.min(viewedStep ?? availableStep, availableStep); + const agentStepBusy = automaticBusy || createEndpoint.isPending || saveSlackDetails.isPending || useOwnSlackApp.isPending; const slackVerificationQuery = useQuery({ queryKey: ["chat-endpoint-slack-webhook-verification", endpoint?.id], queryFn: () => chatEndpointsApi.get(endpoint!.id), @@ -507,17 +523,17 @@ function ChatSdkEndpointSetup() { ); if (purpose === "choice") { - return setPurpose("chat")} onTools={() => navigate(toolHref)} />; + return setPurpose("chat")} onTools={() => navigate(toolHref)} />; } return (

setViewedStep(isSlack ? slackNavigation.toStage(value) : value)} />
{step === 0 ? ( @@ -541,15 +557,23 @@ function ChatSdkEndpointSetup() { emptyMessage="No active agents are available." />} {isSlack && (selectedAgent || endpoint) && - setSlackDraft({ agentId, app })} onBlur={() => void persistSlackDetails().catch(() => {})} /> {saveSlackDetails.isError &&

Couldn't save the Slack app details. Try again before continuing.

} + {slackChoices.data?.defaultMethod === "managed" && (!endpoint || endpoint.setup?.slackSetupMethod === "managed") && !endpoint?.setup?.slackRegistration && }
} + {isSlack && !endpoint && !slackChoices.data && (slackChoices.isError + ?
Couldn't load Slack setup.
+ :

Loading Slack…

)} {provider === "github" && } - void continueFromAgent(true)}> + void continueFromAgent(true)}>
)}
-

{slackStage === "app" ? automaticSlack ? "App configuration access token" : "Create a Slack app" : automaticSlack ? "Install Slack app" : "Add Slack credentials"}

+

{endpoint.setup?.slackSetupMethod === "managed" ? "Connect to Slack" : slackStage === "app" ? automaticSlack ? "App configuration access token" : "Create a Slack app" : automaticSlack ? "Install Slack app" : "Add Slack credentials"}

{repairing && !automaticSlack && (

Reconnect verifies or replaces credentials for this same Slack app. It does not reinstall the app or change its workspace or channel membership. Leave credentials blank to reuse the saved values. @@ -1628,8 +1655,10 @@ function ProviderConnectStep({

}
- {automaticSlack && {}} onBusy={onAutomaticBusy} onSaved={onEndpointSaved} onContinue={onSlackAppCreated} diff --git a/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx b/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx index 3fe861aaedb..ff342c014a4 100644 --- a/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx +++ b/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx @@ -1,183 +1 @@ -import { useEffect, useRef, useState } from "react"; -import { ExternalLink, Loader2 } from "lucide-react"; -import { slackRegistrationErrorMessage } from "@paperclipai/shared"; -import { chatEndpointsApi, type ChatEndpoint } from "@/api/chatEndpoints"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Checkbox } from "@/components/ui/checkbox"; -import { SetupWizardFooter } from "@/components/SetupWizard"; -import { sanitizedSetupErrorMessage } from "./chat-setup-error"; -import { SlackSetupAdvanced } from "./SlackAppDetails"; - -/** Uses ordinary request-local state: configuration tokens never enter a query/mutation cache. */ -export function SlackAutomaticSetup({ endpoint, stage, disabled, saveDetails, onSaved, onBusy, - onManual, onContinue, onSaveExit }: { - endpoint: ChatEndpoint; - stage: "app" | "credentials"; - disabled: boolean; - saveDetails: () => Promise; - onSaved: (endpoint: ChatEndpoint) => void; - onBusy: (busy: boolean) => void; - onManual: (existing: boolean) => Promise; - onContinue: () => void; - onSaveExit: () => void; -}) { - const [configurationToken, setConfigurationToken] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [checkedNoApp, setCheckedNoApp] = useState(false); - const requestId = useRef(crypto.randomUUID()); - const inFlight = useRef(false); - const pendingWindow = useRef(null); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - pendingWindow.current?.close(); - pendingWindow.current = null; - }; - }, []); - const registration = endpoint.setup?.slackRegistration; - const uncertain = registration?.status === "uncertain"; - const creating = registration?.status === "creating"; - const created = Boolean(registration?.appId); - const configurationPending = registration?.errorCode === "slack_manifest_update_pending"; - - async function run(action: () => Promise) { - if (inFlight.current) return; - inFlight.current = true; - setBusy(true); - onBusy(true); - setError(null); - try { await action(); } - catch (failure) { setError(sanitizedSetupErrorMessage(failure, { configurationToken })); } - finally { - setConfigurationToken(""); - setBusy(false); - onBusy(false); - inFlight.current = false; - } - } - - async function create() { - // Reserve the window during the click so async app creation cannot trigger a popup blocker. - const installWindow = window.open("about:blank", "_blank"); - if (installWindow) { - installWindow.opener = null; - installWindow.document.title = "Preparing Slack installation"; - installWindow.document.body.textContent = "Preparing your Slack installation…"; - pendingWindow.current = installWindow; - } - let token = configurationToken.trim(); - setConfigurationToken(""); - try { - let next: ChatEndpoint; - try { - if (!created) await saveDetails(); - if (registration?.status === "failed" || checkedNoApp) requestId.current = crypto.randomUUID(); - next = await chatEndpointsApi.createSlackApp(endpoint.id, { - requestId: requestId.current, - credentials: { configurationToken: token }, - ...(checkedNoApp ? { confirmedNoAppCreated: true } : {}), - }); - } catch (failure) { - // A response can be lost after Slack creates the app. Only saved state can advance. - const current = await chatEndpointsApi.get(endpoint.id).catch(() => null); - if (current && mounted.current) onSaved(current); - if (!current?.setup?.slackRegistration?.appId) { - throw new Error(sanitizedSetupErrorMessage(failure, { configurationToken: token })); - } - next = current; - } - token = ""; - if (!mounted.current) return; - setCheckedNoApp(false); - onSaved(next); - const saved = next.setup?.slackRegistration; - if (!saved?.appId) return; - onContinue(); - // Pending/uncertain configuration and existing installations must not start OAuth. - if (saved.status !== "install" || saved.errorCode) return; - if (!installWindow || installWindow.closed) { - setError("Select Install in Slack to open the installation page."); - return; - } - const result = await chatEndpointsApi.installSlackApp(endpoint.id); - if (!mounted.current) return; - if (installWindow.closed) { - setError("Select Install in Slack to reopen the installation page."); - return; - } - installWindow.location.replace(result.authorizationUrl); - pendingWindow.current = null; - } finally { - token = ""; - pendingWindow.current?.close(); - pendingWindow.current = null; - } - } - - async function authorize() { - const result = await chatEndpointsApi.installSlackApp(endpoint.id); - // Current-tab navigation avoids popup blockers. The callback resumes this saved draft. - window.location.assign(result.authorizationUrl); - } - - return
- {error || registration?.errorCode ?

- {error ?? slackRegistrationErrorMessage(registration!.errorCode!)} -

: null} - {(stage === "app" && !created && !creating || configurationPending) && <> - {uncertain &&
- Open Slack app settings - -
} - {(!uncertain || checkedNoApp) &&
- -
-
    -
  1. In Slack app settings, find Your App Configuration Tokens and choose Generate Token.
  2. -
  3. Select the workspace where you want to install your bot.
  4. -
  5. Copy the Access Token and paste it below.
  6. -
-

Paperclip uses this token to create your app

-
- - setConfigurationToken(event.target.value)} - aria-describedby="slack-configuration-token-help" /> -
} - } - {creating &&

Creating your Slack app. You can return to this saved setup.

} - {created &&

- {endpoint.setup?.slackApp?.appName ?? "Your Slack app"} is created. - {stage === "credentials" && " Approve installation with your Slack account. Paperclip will link it to your signed-in Paperclip account and send you a welcome DM."} -

} - {stage === "credentials" && registration?.status === "credentials_saved" &&
-

Your installation credentials are saved. Retry connecting to finish setup. If Slack access has changed, authorize the same app again.

- -
} - {stage === "app" &&
- - -
} - - {configurationPending ? : stage === "app" ? : registration?.status === "credentials_saved" ? : } - -
; -} +export { OwnSlackAppSetup as SlackAutomaticSetup } from "./slack/OwnSlackAppSetup"; diff --git a/ui/src/pages/apps/chat/SlackSetupPrompt.tsx b/ui/src/pages/apps/chat/SlackSetupPrompt.tsx index b9256c27d59..2cc0568ea77 100644 --- a/ui/src/pages/apps/chat/SlackSetupPrompt.tsx +++ b/ui/src/pages/apps/chat/SlackSetupPrompt.tsx @@ -12,19 +12,21 @@ Ask me to take over for login, two-factor authentication, CAPTCHA, workspace-adm 2. Check public HTTPS before creating the app. Slack must be able to reach Paperclip's generated webhook URL over publicly reachable HTTPS with a valid certificate. Paperclip Cloud includes HTTPS. A private Tailscale Serve address alone is not reachable by Slack; self-hosting needs an authorized public ingress, such as Tailscale Funnel or a reverse proxy with a trusted certificate. Follow the warning's Learn more link if needed. Use the callback URL generated by this connection, including its full path. Check that it uses the intended public instance hostname or an explicitly configured webhook ingress, not localhost, an internal service address, or an old pool hostname. If it is wrong or unreachable, resolve the instance configuration before registration; do not invent a callback path, disable signature verification, or bypass Cloud authentication protections. -3. Create Slack app automatically. Review Slack app name, Bot display name, and Slash command under Advanced on Choose agent in Paperclip. Keep the generated defaults unless I requested other names. Use the automatic setup path for a new bot. Ask me to open Slack's Your App Configuration Tokens > Generate Token, select the agreed workspace, and enter the temporary configuration token directly into Paperclip's App configuration access token password field. Pause while I do that. Never read, copy, reveal, screenshot, or include that token in your prompt or tool output. Do not request its refresh token. Click Create Slack app after I enter it. Paperclip creates the app, saves its signing secret, and uploads the assigned agent's avatar. Avatar upload has no wizard step; if it fails, finish setup and optionally use Settings > Agent avatar for manual upload later. Do not recreate the app or request another configuration token for an optional avatar. Its saved state is the evidence of creation; a click or navigation is not proof. Resume the matching draft after refresh or Save & exit. If creation is uncertain, open Slack app settings and check the agreed workspace for the exact app before continuing. Never retry creation automatically. Only confirm that no app was created after I explicitly confirm that check, or recover the existing app on this same draft. No Slack CLI is required. +3. Use the saved Slack setup method. For a new connection, keep managed setup when it is the instance default. Continue on Choose agent opens Slack authorization or starts installation automatically; there is no separate Add to Slack screen. To explicitly use your own app, open Advanced on Choose agent and choose Use your own app below Slash command. Select only a workspace authorized by my own Paperclip account, or let me complete the manager authorization in Slack; pause for login and administrator consent. Reuse my saved workspace authorization for additional agents. Paperclip creates and installs the child app, uploads its avatar, and links my verified Slack identity. Continue within the same two-stop wizard: Choose agent, Connect. If Slack requires child-app consent or approval, complete that for the saved app and resume. Never switch methods after creation dispatch, migrate an existing bot, read manager credentials, or reuse a teammate's grant. If managed setup becomes unavailable, preserve the draft and report its recovery action. Use your own app only when selected or when managed setup is unavailable before creation. + +For the own-app path, create Slack app automatically. Review Slack app name, Bot display name, and Slash command under Advanced on Choose agent in Paperclip. Keep the generated defaults unless I requested other names. Use the automatic setup path for a new bot. Ask me to open Slack's Your App Configuration Tokens > Generate Token, select the agreed workspace, and enter the temporary configuration token directly into Paperclip's App configuration access token password field. Pause while I do that. Never read, copy, reveal, screenshot, or include that token in your prompt or tool output. Do not request its refresh token. Click Create Slack app after I enter it. Paperclip creates the app, saves its signing secret, and uploads the assigned agent's avatar. Avatar upload has no wizard step; if it fails, finish setup and optionally use Slack app settings > Basic Information to set its app icon later. Do not recreate the app or request another configuration token for an optional avatar. Its saved state is the evidence of creation; a click or navigation is not proof. Resume the matching draft after refresh or Save & exit. If creation is uncertain, open Slack app settings and check the agreed workspace for the exact app before continuing. Never retry creation automatically. Only confirm that no app was created after I explicitly confirm that check, or recover the existing app on this same draft. No Slack CLI is required. 4. Install Slack app. Successful creation and configuration advance Paperclip to Install Slack app and open Slack consent in a new window automatically. Use Install in Slack if that window was blocked or closed. Check the workspace and approve installation within the agreed scope. Pause for required login, ownership decisions, or administrator approval. Return to the saved wizard through Slack's OAuth redirect. Paperclip obtains and saves the bot token itself; do not locate, copy, or request the bot token or signing secret on the automatic path. Wait for Paperclip to report saved credentials. The fourth screen, Send a message to your agent, becomes Success automatically after Paperclip receives qualifying connection evidence. There is no separate Try it screen. If approval is declined or pending, preserve the app and resume its installation later without another configuration token. If authorization expires, start a fresh installation of this same app. If credentials are saved but connecting fails, use Retry connecting. If Slack access changed or the token was revoked, use Authorize in Slack again for this same app. Do not recreate the app. Installation automatically links the Slack account returned by OAuth to my signed-in Paperclip account and sends a welcome DM. Have me approve with my own Slack account; do not authorize with your account or a teammate’s account. There is no separate personal-linking step on the automatic path. Existing/conflicting/revoked links are managed in the connection’s Access settings later. Never overwrite them as a shortcut. Manual fallback only when selected or needed for recovery: choose Create manually or Use an existing app on the creation step. For manual creation, use Paperclip's generated manifest, select the agreed workspace in Slack, and install that exact app. Preserve all scopes, bot events, slash command, interactivity, and signed HTTP callbacks. For an existing app, reconcile its configuration with this connection's manifest and generated URLs. Have me enter the Bot User OAuth Token (OAuth & Permissions, xoxb- prefix) and Signing Secret (Basic Information > App Credentials) directly into the matching Paperclip fields, or use safe clipboard transfer only as described above. The Signing Secret is not the Client Secret, Verification Token, or app-level token. Click Connect Slack app. On a resumed manual setup, leave credential fields blank to keep saved values. Never use Socket Mode as a shortcut. -5. On the automatic path, use Send a message to your agent and its Open Slack link. If authorized, send a short message. Paperclip accepts an authenticated message event from the saved app/workspace as connection evidence and replaces this screen with Success. The welcome event may complete the check first. If Success appears, skip the Slack settings visit even if Slack's Request URL still says unverified. If Paperclip keeps waiting, expand Didn’t work? for Request URL instructions. On the manual path, open the same Slack app's Event Subscriptions. Make sure events are enabled. Beside the manifest's prefilled Request URL, click Retry if needed, wait for Slack to display Verified, and save changes when offered. Verification can fail before Paperclip has the credentials, so retry now. Return to Paperclip and wait for its verified state and automatic advancement, or Continue when available. If verification fails, inspect the specific error and the wizard's Troubleshooting URL: check public reachability, the correct app/workspace and signing secret, and the full generated callback URL. Also confirm the generated Slash Commands request URL and Interactivity & Shortcuts request URL were retained if those features fail. Use the manifest's values for each surface; do not assume verifying events proves every callback works. Do not recreate the app or change arbitrary URLs to hide a failed check. +5. On either provisioned path, use Send a message to your agent and its Open Slack link. If authorized, send a short message. Paperclip accepts an authenticated message event from the saved app/workspace as connection evidence and replaces this screen with Success. The welcome event may complete the check first. If Success appears, skip the Slack settings visit even if Slack's Request URL still says unverified. If Paperclip keeps waiting, expand Didn’t work? for Request URL instructions. On the manual path, open the same Slack app's Event Subscriptions. Make sure events are enabled. Beside the manifest's prefilled Request URL, click Retry if needed, wait for Slack to display Verified, and save changes when offered. Verification can fail before Paperclip has the credentials, so retry now. Return to Paperclip and wait for its verified state and automatic advancement, or Continue when available. If verification fails, inspect the specific error and the wizard's Troubleshooting URL: check public reachability, the correct app/workspace and signing secret, and the full generated callback URL. Also confirm the generated Slash Commands request URL and Interactivity & Shortcuts request URL were retained if those features fail. Use the manifest's values for each surface; do not assume verifying events proves every callback works. Do not recreate the app or change arbitrary URLs to hide a failed check. 6. Manual/existing-app paths only: connect your Slack account. Copy the exact command shown by Paperclip, consisting of this app's saved slash command followed by connect. In the correct Slack workspace, send it as my signed-in Slack user. This discovers identity without starting agent work. Return to Paperclip and wait for that identity to appear. Compare the Slack account/workspace and the target Paperclip user. Only select the ownership confirmation for that identity when they are verified as my accounts; its visible label is This is my Slack account and browser tools may identify it as Link to my Paperclip account. Ask me if ownership is ambiguous. Never link a teammate's account to mine. Confirm Linked to you and use Continue to message test. Keep unlinked-person access off unless I explicitly requested a different policy. On these manual paths installing the bot and linking a person are separate steps; future work uses that linked person's Paperclip permissions. 7. Prepare the agreed destination. If using a channel, invite/add this bot to that channel through Slack's UI if authorized. In the connection's Settings, inspect Where this agent can work. Use its Refresh action if available, or reload the page after the bot invitation if the channel is missing. Inspect Allowed Channels and make sure the intended test channel is enabled; newly invited channels start enabled, while an explicitly disabled channel stays off until a person re-enables it. Do not invite the bot to unrelated channels, enable every channel, or broaden anyone's access just to get a test to pass. For a DM, check the connection permits direct messages and use my conversation with this bot. Return to the saved wizard if you visited Settings. Reading access and allowed response destinations are distinct; seeing a channel in Slack does not prove the bot is allowed to reply there. -8. Optional live qualification when explicitly requested: on the automatic path, the wizard ends at Success and Done, with no additional test screen. Open the bot's DM directly if a conversation test was requested. The welcome DM and connection check alone do not prove an agent response. For a channel test or manual setup, send the wizard's suggested short message, for example @ you there? Find the bot belonging to this Slack app in Slack's mention suggestions and select it. Slack's actual display name can differ from Paperclip's suggested username: for example, Paperclip may suggest @cedarqa while Slack shows cedar-qa. Preserve Slack's displayed punctuation and select the real bot; pasting plain text that resembles a mention may not notify it. Use the configured bot, not a literal example. For a channel test, use a mention rather than the slash connect command. Wait for a real response in the originating Slack thread. Open the connection's Conversations page, follow the matching Open task link, and verify that the assigned agent's run completed successfully; the agent's Runs page provides the execution result if the Slack reply has no task link. Send one short follow-up in the same thread without another mention and confirm continuity and a single delivered answer. Do not claim success from a typing indicator, a verified webhook, the wizard's checkmark, or a manually posted substitute reply. If a run stops or no answer arrives, inspect the task/run and connection Activity, distinguish credential/callback/destination/identity failures from an agent-runtime failure, and resolve the specific issue or report the remaining blocker without exposing secrets. +8. Optional live qualification when explicitly requested: on either provisioned path, the wizard ends at Success and Done, with no additional test screen. Open the bot's DM directly if a conversation test was requested. The welcome DM and connection check alone do not prove an agent response. For a channel test or manual setup, send the wizard's suggested short message, for example @ you there? Find the bot belonging to this Slack app in Slack's mention suggestions and select it. Slack's actual display name can differ from Paperclip's suggested username: for example, Paperclip may suggest @cedarqa while Slack shows cedar-qa. Preserve Slack's displayed punctuation and select the real bot; pasting plain text that resembles a mention may not notify it. Use the configured bot, not a literal example. For a channel test, use a mention rather than the slash connect command. Wait for a real response in the originating Slack thread. Open the connection's Conversations page, follow the matching Open task link, and verify that the assigned agent's run completed successfully; the agent's Runs page provides the execution result if the Slack reply has no task link. Send one short follow-up in the same thread without another mention and confirm continuity and a single delivered answer. Do not claim success from a typing indicator, a verified webhook, the wizard's checkmark, or a manually posted substitute reply. If a run stops or no answer arrives, inspect the task/run and connection Activity, distinguish credential/callback/destination/identity failures from an agent-runtime failure, and resolve the specific issue or report the remaining blocker without exposing secrets. 9. Finish and review. Use Done to finish setup. The message test is optional and does not waive required credentials, callback verification, or identity linking. If I declined testing, finish without claiming it passed. Confirm the connection is saved for the correct agent/workspace, inspect its permitted destinations and linked identity, and leave existing access restrictions and per-action approvals intact. Slack tools for this agent's eligible tasks and routines use this bot connection and the responsible user's linked Slack account; do not create a separate personal Slack MCP connection or promise all methods work regardless of scopes or plan. Additional communication instructions are optional and apply to new tasks; add them only if I supplied preferences. Other people link their own accounts using the Access page's connect instructions; they do not share the bot secrets or my identity. diff --git a/ui/src/pages/apps/chat/SlackTestMessageButton.tsx b/ui/src/pages/apps/chat/SlackTestMessageButton.tsx new file mode 100644 index 00000000000..10cea6448d2 --- /dev/null +++ b/ui/src/pages/apps/chat/SlackTestMessageButton.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Loader2, Send } from "lucide-react"; +import type { SlackTestMessageResult } from "@paperclipai/shared"; +import { chatEndpointsApi } from "@/api/chatEndpoints"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { queryKeys } from "@/lib/queryKeys"; + +export function SlackTestMessageButton({ endpointId }: { endpointId: string }) { + const queryClient = useQueryClient(); + const [requestId, setRequestId] = useState(() => crypto.randomUUID()); + const [accounts, setAccounts] = useState["accounts"] | null>(null); + const [nextCursor, setNextCursor] = useState(null); + const [selected, setSelected] = useState(""); + const [invitationExpired, setInvitationExpired] = useState(false); + const [search, setSearch] = useState(""); + const send = useMutation({ + mutationFn: (input: { slackUserId?: string; accountCursor?: string; requestId?: string }) => chatEndpointsApi.sendSlackTestMessage(endpointId, { requestId, ...input }), + onSuccess: (result, input) => { + if (result.status === "connection_required") { + setAccounts(previous => input.accountCursor ? [...new Map([...(previous ?? []), ...result.accounts].map(account => [account.id, account])).values()] : result.accounts); + setNextCursor(result.nextCursor); + return; + } + setAccounts(null); + void queryClient.invalidateQueries({ queryKey: queryKeys.chatEndpoints.principals(endpointId) }); + }, + }); + const sent = send.data?.status === "sent"; + const pendingConnection = send.data?.status === "connection_pending" ? send.data : null; + useEffect(() => { + setInvitationExpired(false); + if (!pendingConnection) return; + const timer = setTimeout(() => setInvitationExpired(true), Math.max(0, new Date(pendingConnection.expiresAt).getTime() - Date.now())); + return () => clearTimeout(timer); + }, [pendingConnection?.expiresAt]); + const principals = useQuery({ + queryKey: queryKeys.chatEndpoints.principals(endpointId), + queryFn: () => chatEndpointsApi.listPrincipals(endpointId), + enabled: Boolean(pendingConnection) && !invitationExpired, refetchInterval: pendingConnection && !invitationExpired ? 3000 : false, + }); + const confirmed = Boolean(pendingConnection && principals.data?.some(link => link.principalId === pendingConnection.principalId && link.status === "linked")); + useEffect(() => { + if (confirmed && !send.isPending && !send.isError) send.mutate({ slackUserId: selected }); + }, [confirmed, send.isPending, send.isError]); + const reset = () => { setRequestId(crypto.randomUUID()); send.reset(); }; + const startTest = () => { + if (invitationExpired) { + const next = crypto.randomUUID(); setRequestId(next); + send.mutate({ requestId: next, slackUserId: selected }); + } else send.mutate({}); + }; + return
+ + {pendingConnection &&

{invitationExpired ? "The Slack invitation expired." : "Open the invitation in Slack to connect your account."}

} + {send.isError &&
+ {send.error.message} +
} + { if (!open && !send.isPending) setAccounts(null); }}> + + + Connect your Slack account + Choose your account. Messages to this agent will use your Paperclip permissions. + + setSearch(event.target.value)} /> +
+ {accounts?.filter(account => `${account.name} ${account.id}`.toLowerCase().includes(search.toLowerCase())).map(account => + )} + {accounts?.length === 0 &&

No available accounts found in this Slack workspace.

} +
+ {nextCursor && } + {send.isError &&

{send.error.message}

} +
+ + +
+
+
+
; +} diff --git a/ui/src/pages/apps/chat/SlackToolSettings.tsx b/ui/src/pages/apps/chat/SlackToolSettings.tsx index 8e2fb86df9b..772eb0af66c 100644 --- a/ui/src/pages/apps/chat/SlackToolSettings.tsx +++ b/ui/src/pages/apps/chat/SlackToolSettings.tsx @@ -1,4 +1,3 @@ -import { Link } from "react-router-dom"; import { useId, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { @@ -200,11 +199,9 @@ export function SlackSearchView({ export function SlackToolsSettings({ companyId, endpointId, - connectionId, }: { companyId: string; endpointId: string; - connectionId?: string | null; }) { const query = useQuery({ queryKey: ["slack-capabilities", companyId, endpointId], @@ -217,14 +214,7 @@ export function SlackToolsSettings({ capabilities={query.data} error={query.error?.message} /> - {connectionId && ( - - Manage action permissions - - )} +
); } diff --git a/ui/src/pages/apps/chat/chat-ui-contract.test.ts b/ui/src/pages/apps/chat/chat-ui-contract.test.ts index f67ccd5be70..34b79e374d4 100644 --- a/ui/src/pages/apps/chat/chat-ui-contract.test.ts +++ b/ui/src/pages/apps/chat/chat-ui-contract.test.ts @@ -242,7 +242,8 @@ describe("chat connector UI contract", () => { expect(setup).toContain("defaultSlackAppConfiguration"); expect(setup).not.toContain("No credentials"); expect(setup).not.toContain("managed Microsoft app"); - expect(setup).toContain("endpoint.providerAccountId && !repairing"); + expect(setup).toContain("slackSetupState({ endpoint, repairing"); + expect(source("./slack/setup-state.ts")).toContain("endpoint.providerAccountId && !repairing"); expect(setup).not.toContain('field("webhookSecret"'); expect(setup).not.toContain("@paperclipai/teams-connect"); expect(setup).not.toContain("Copy setup command"); diff --git a/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx new file mode 100644 index 00000000000..245773a6a70 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +import { StrictMode, act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, afterEach, expect, it, vi } from "vitest"; +import type { ChatEndpoint } from "@/api/chatEndpoints"; +import { ManagedSlackSetup } from "./ManagedSlackSetup"; + +const api = vi.hoisted(() => ({ slackSetupOptions: vi.fn(), provisionManagedSlack: vi.fn(), authorizeSlackManager: vi.fn(), get: vi.fn() })); +vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: api })); +// Stop at the navigation boundary without leaving jsdom. +const navigation = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/oauthHandoff", () => ({ prepareOAuthNavigation: navigation, savePendingCloudHandoff: vi.fn() })); +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +let endpoint: ChatEndpoint; +const props = () => ({ endpoint, stage: "app" as const, disabled: false, autoStart: true, saveDetails: vi.fn().mockResolvedValue(undefined), onSaved: vi.fn(), onBusy: vi.fn(), onSaveExit: vi.fn(), onManual: vi.fn(), onContinue: vi.fn() }); +beforeEach(() => { + vi.resetAllMocks(); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + endpoint = { id: "endpoint", companyId: "company", assignedAgentName: "Maya", setup: { slackSetupMethod: "managed" } } as ChatEndpoint; + api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [{ grantId: "grant", workspaceName: "Paperclip" }] }); + api.provisionManagedSlack.mockResolvedValue({}); api.get.mockResolvedValue(endpoint); + api.authorizeSlackManager.mockResolvedValue({ authorizationUrl: "https://slack.com/oauth" }); + navigation.mockImplementation(() => new Promise(() => {})); +}); +afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); }); +async function render(overrides = {}) { + await act(async () => { root.render(); }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); }); +} +it("starts a single managed install after Continue even across rerenders", async () => { + await render(); await render(); + expect(api.provisionManagedSlack).toHaveBeenCalledTimes(1); + expect(api.provisionManagedSlack).toHaveBeenCalledWith("endpoint", expect.objectContaining({ grantId: "grant" })); + expect(api.authorizeSlackManager).not.toHaveBeenCalled(); +}); +it("requests explicit reinstall when reconnecting a configured app", async () => { + endpoint.setup!.slackRegistration = { status: "configured", appId: "app", managerGrantId: "grant" } as NonNullable["slackRegistration"]; + await render({ autoStart: false, reconnect: true }); + const button = Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Continue in Slack"); + expect(button).toBeDefined(); + await act(async () => button!.click()); + expect(api.provisionManagedSlack).toHaveBeenCalledWith("endpoint", expect.objectContaining({ grantId: "grant", reconnect: true, requestId: expect.any(String) })); +}); +it("reuses the reconnect receipt after a lost response and failed recovery read", async () => { + endpoint.setup!.slackRegistration = { status: "configured", appId: "app", managerGrantId: "grant" } as NonNullable["slackRegistration"]; + api.provisionManagedSlack.mockRejectedValue(new Error("Response lost")); + api.get.mockRejectedValue(new Error("Recovery read unavailable")); + await render({ autoStart: false, reconnect: true }); + const button = () => Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Continue in Slack")!; + await act(async () => button().click()); + await act(async () => button().click()); + expect(api.provisionManagedSlack).toHaveBeenCalledTimes(2); + expect(api.provisionManagedSlack.mock.calls[1]![1]).toEqual(api.provisionManagedSlack.mock.calls[0]![1]); +}); +it("opens manager authorization directly when there is no saved workspace", async () => { + api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [] }); + await render(); + expect(api.authorizeSlackManager).toHaveBeenCalledTimes(1); + expect(navigation).toHaveBeenCalledWith({ authorizationUrl: "https://slack.com/oauth" }); + expect(api.provisionManagedSlack).not.toHaveBeenCalled(); +}); +it("does not start while Choose agent is visible or after Save & exit", async () => { + await render({ autoStart: false }); + expect(api.provisionManagedSlack).not.toHaveBeenCalled(); + expect(api.authorizeSlackManager).not.toHaveBeenCalled(); +}); +it("waits for an explicit workspace choice when there are several", async () => { + api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [{ grantId: "one", workspaceName: "One" }, { grantId: "two", workspaceName: "Two" }] }); + await render(); expect(api.provisionManagedSlack).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Choose a Slack workspace"); +}); +it.each(["uncertain", "failed", "creating", "install", "credentials_saved"])("does not automatically retry a %s registration", async status => { + endpoint.setup!.slackRegistration = { status } as NonNullable["slackRegistration"]; + await render(); expect(api.provisionManagedSlack).not.toHaveBeenCalled(); expect(api.authorizeSlackManager).not.toHaveBeenCalled(); +}); +it("does not loop authorization after denial", async () => { + endpoint.setup!.slackManagerError = "slack_install_declined"; + await render(); expect(api.authorizeSlackManager).not.toHaveBeenCalled(); expect(api.provisionManagedSlack).not.toHaveBeenCalled(); +}); +it("does not silently retry an ambiguous request after refresh of setup options", async () => { + api.provisionManagedSlack.mockRejectedValue(new Error("Request failed")); + await render(); await render(); + expect(api.provisionManagedSlack).toHaveBeenCalledTimes(1); +}); diff --git a/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx new file mode 100644 index 00000000000..d9023162f02 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState, type ComponentProps } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { slackRegistrationErrorMessage } from "@paperclipai/shared"; +import { chatEndpointsApi } from "@/api/chatEndpoints"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { SetupWizardFooter } from "@/components/SetupWizard"; +import type { OwnSlackAppSetup } from "./OwnSlackAppSetup"; +import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff"; +import { sanitizedSetupErrorMessage } from "../chat-setup-error"; + +export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, onBusy, onSaveExit, autoStart = false, reconnect = false }: ComponentProps & { autoStart?: boolean; reconnect?: boolean }) { + const choices = useQuery({ queryKey: ["slack-managed-workspaces", endpoint.companyId], queryFn: () => chatEndpointsApi.slackSetupOptions(endpoint.companyId), retry: false }); + const [selectedGrant, setSelectedGrant] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [confirmed, setConfirmed] = useState(false); + const inFlight = useRef(false); + const requestId = useRef(crypto.randomUUID()); + const registration = endpoint.setup?.slackRegistration; + const started = Boolean(registration); + const workspaces = choices.data?.workspaces ?? []; + const grantId = registration?.managerGrantId ?? selectedGrant ?? (workspaces.length === 1 ? workspaces[0]!.grantId : null); + const needsAuthorization = !grantId || registration?.errorCode === "slack_manager_reauthorize" || Boolean(grantId && !workspaces.some(workspace => workspace.grantId === grantId)); + const uncertain = registration?.status === "uncertain"; + const creating = registration?.status === "creating"; + async function run(action: () => Promise) { + if (inFlight.current) return; + inFlight.current = true; setBusy(true); onBusy(true); setError(null); + try { await action(); } + catch (failure) { setError(sanitizedSetupErrorMessage(failure, {})); await Promise.allSettled([choices.refetch(), chatEndpointsApi.get(endpoint.id).then(onSaved)]); } + finally { inFlight.current = false; setBusy(false); onBusy(false); } + } + async function authorize() { + const authorization = await chatEndpointsApi.authorizeSlackManager(endpoint.id); + const target = await prepareOAuthNavigation(authorization); + if (target.kind === "reauthentication" && authorization.handoff) savePendingCloudHandoff(authorization.handoff.session); + window.location.assign(target.url); + } + async function connect() { + if (needsAuthorization || !grantId) { + await authorize(); return; + } + if (!started) await saveDetails(); + if (confirmed || registration?.status === "failed") requestId.current = crypto.randomUUID(); + const input = { requestId: requestId.current, grantId, ...(reconnect ? { reconnect: true } : {}), ...(confirmed ? { confirmedNoAppCreated: true } : {}) }; + setConfirmed(false); + const result = await chatEndpointsApi.provisionManagedSlack(endpoint.id, input); + await choices.refetch(); + onSaved(await chatEndpointsApi.get(endpoint.id)); + if (result.authorization) window.location.assign(result.authorization.authorizationUrl); + } + const autoStarted = useRef(false); + useEffect(() => { + // Only fresh setup can start itself. Declines, uncertain creation, and saved + // registration recovery require an explicit action and never recreate an app. + if (!autoStart || autoStarted.current || disabled || !choices.data?.managedAvailable + || choices.isFetching || choices.isError || started || endpoint.setup?.slackManagerError + || workspaces.length > 1) return; + autoStarted.current = true; + void run(connect); + }); + const setupError = (code: string) => code === "slack_manifest_update_pending" + ? "Your app was created. Retry to finish its configuration." + : code === "slack_creation_uncertain" ? "Slack may have created your app. Check Slack app settings before trying again." + : slackRegistrationErrorMessage(code); + const message = error ?? (endpoint.setup?.slackManagerError ? setupError(endpoint.setup.slackManagerError) : null) ?? (choices.isError ? "Couldn't load your Slack workspaces. Try again." : registration?.errorCode ? setupError(registration.errorCode) : null); + return
+ {message &&

{message}

} + {busy || choices.isPending || creating ?

{busy ? needsAuthorization ? "Opening Slack…" : "Connecting your agent…" : creating ? "Setting up your Slack app…" : "Loading Slack…"}

+ : !choices.data?.managedAvailable ?

Managed Slack setup is unavailable. Try again later.

+ : <> + {workspaces.length === 1 &&

Add {endpoint.assignedAgentName} to {workspaces[0]!.workspaceName}.

} + {workspaces.length > 1 && } + {(!started || registration?.errorCode === "slack_manager_reauthorize") && workspaces.length > 0 &&
} + } + {uncertain &&
+ Open Slack app settings + +
} + + {choices.isError || !choices.isPending && !choices.data?.managedAvailable ? : } + +
; +} diff --git a/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx b/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx new file mode 100644 index 00000000000..a08c09376b7 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx @@ -0,0 +1,185 @@ +import { useEffect, useRef, useState } from "react"; +import { ExternalLink, Loader2 } from "lucide-react"; +import { slackRegistrationErrorMessage } from "@paperclipai/shared"; +import { chatEndpointsApi, type ChatEndpoint } from "@/api/chatEndpoints"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { SetupWizardFooter } from "@/components/SetupWizard"; +import { sanitizedSetupErrorMessage } from "../chat-setup-error"; +import { SlackSetupAdvanced } from "../SlackAppDetails"; + +/** Uses ordinary request-local state: configuration tokens never enter a query/mutation cache. */ +export function OwnSlackAppSetup({ endpoint, stage, disabled, saveDetails, onSaved, onBusy, + onManual, onManaged, onContinue, onSaveExit }: { + endpoint: ChatEndpoint; + stage: "app" | "credentials"; + disabled: boolean; + saveDetails: () => Promise; + onSaved: (endpoint: ChatEndpoint) => void; + onBusy: (busy: boolean) => void; + onManual: (existing: boolean) => Promise; + onManaged?: () => Promise; + onContinue: () => void; + onSaveExit: () => void; +}) { + const [configurationToken, setConfigurationToken] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [checkedNoApp, setCheckedNoApp] = useState(false); + const requestId = useRef(crypto.randomUUID()); + const inFlight = useRef(false); + const pendingWindow = useRef(null); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + pendingWindow.current?.close(); + pendingWindow.current = null; + }; + }, []); + const registration = endpoint.setup?.slackRegistration; + const uncertain = registration?.status === "uncertain"; + const creating = registration?.status === "creating"; + const created = Boolean(registration?.appId); + const configurationPending = registration?.errorCode === "slack_manifest_update_pending"; + + async function run(action: () => Promise) { + if (inFlight.current) return; + inFlight.current = true; + setBusy(true); + onBusy(true); + setError(null); + try { await action(); } + catch (failure) { setError(sanitizedSetupErrorMessage(failure, { configurationToken })); } + finally { + setConfigurationToken(""); + setBusy(false); + onBusy(false); + inFlight.current = false; + } + } + + async function create() { + // Reserve the window during the click so async app creation cannot trigger a popup blocker. + const installWindow = window.open("about:blank", "_blank"); + if (installWindow) { + installWindow.opener = null; + installWindow.document.title = "Preparing Slack installation"; + installWindow.document.body.textContent = "Preparing your Slack installation…"; + pendingWindow.current = installWindow; + } + let token = configurationToken.trim(); + setConfigurationToken(""); + try { + let next: ChatEndpoint; + try { + if (!created) await saveDetails(); + if (registration?.status === "failed" || checkedNoApp) requestId.current = crypto.randomUUID(); + setCheckedNoApp(false); + next = await chatEndpointsApi.createSlackApp(endpoint.id, { + requestId: requestId.current, + credentials: { configurationToken: token }, + ...(checkedNoApp ? { confirmedNoAppCreated: true } : {}), + }); + } catch (failure) { + // A response can be lost after Slack creates the app. Only saved state can advance. + const current = await chatEndpointsApi.get(endpoint.id).catch(() => null); + if (current && mounted.current) onSaved(current); + if (!current?.setup?.slackRegistration?.appId) { + throw new Error(sanitizedSetupErrorMessage(failure, { configurationToken: token })); + } + next = current; + } + token = ""; + if (!mounted.current) return; + onSaved(next); + const saved = next.setup?.slackRegistration; + if (!saved?.appId) return; + onContinue(); + // Pending/uncertain configuration and existing installations must not start OAuth. + if (saved.status !== "install" || saved.errorCode) return; + if (!installWindow || installWindow.closed) { + setError("Select Install in Slack to open the installation page."); + return; + } + const result = await chatEndpointsApi.installSlackApp(endpoint.id); + if (!mounted.current) return; + if (installWindow.closed) { + setError("Select Install in Slack to reopen the installation page."); + return; + } + installWindow.location.replace(result.authorizationUrl); + pendingWindow.current = null; + } finally { + token = ""; + pendingWindow.current?.close(); + pendingWindow.current = null; + } + } + + async function authorize() { + const result = await chatEndpointsApi.installSlackApp(endpoint.id); + // Current-tab navigation avoids popup blockers. The callback resumes this saved draft. + window.location.assign(result.authorizationUrl); + } + + return
+ {error || registration?.errorCode ?

+ {error ?? slackRegistrationErrorMessage(registration!.errorCode!)} +

: null} + {(stage === "app" && !created && !creating || configurationPending) && <> + {uncertain &&
+ Open Slack app settings + +
} + {(!uncertain || checkedNoApp) &&
+ +
+
    +
  1. In Slack app settings, find Your App Configuration Tokens and choose Generate Token.
  2. +
  3. Select the workspace where you want to install your bot.
  4. +
  5. Copy the Access Token and paste it below.
  6. +
+

Paperclip uses this token to create your app

+
+ + setConfigurationToken(event.target.value)} + aria-describedby="slack-configuration-token-help" /> +
} + } + {creating &&

Creating your Slack app. You can return to this saved setup.

} + {created &&

+ {endpoint.setup?.slackApp?.appName ?? "Your Slack app"} is created. + {stage === "credentials" && " Approve installation with your Slack account. Paperclip will link it to your signed-in Paperclip account and send you a welcome DM."} +

} + {stage === "credentials" && registration?.status === "credentials_saved" &&
+

Your installation credentials are saved. Retry connecting to finish setup. If Slack access has changed, authorize the same app again.

+ +
} + {stage === "app" &&
+ {onManaged && !registration && } + + +
} + + {configurationPending ? : stage === "app" ? : registration?.status === "credentials_saved" ? : } + +
; +} diff --git a/ui/src/pages/apps/chat/slack/SlackSetup.tsx b/ui/src/pages/apps/chat/slack/SlackSetup.tsx new file mode 100644 index 00000000000..e962d6d7cde --- /dev/null +++ b/ui/src/pages/apps/chat/slack/SlackSetup.tsx @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ComponentProps } from "react"; +import { chatEndpointsApi } from "@/api/chatEndpoints"; +import { OwnSlackAppSetup } from "./OwnSlackAppSetup"; +import { ManagedSlackSetup } from "./ManagedSlackSetup"; +export function SlackSetup(props: ComponentProps & { autoStart?: boolean; reconnect?: boolean }) { + const choices = useQuery({ queryKey: ["slack-managed-workspaces", props.endpoint.companyId], queryFn: () => chatEndpointsApi.slackSetupOptions(props.endpoint.companyId), retry: false }); + return props.endpoint.setup?.slackSetupMethod === "managed" + ? + : props.onSaved(await chatEndpointsApi.update(props.endpoint.id, { slackSetupMethod: "managed" })) : undefined} />; +} diff --git a/ui/src/pages/apps/chat/slack/setup-state.test.ts b/ui/src/pages/apps/chat/slack/setup-state.test.ts new file mode 100644 index 00000000000..b21830596b1 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/setup-state.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import type { ChatEndpoint } from "@/api/chatEndpoints"; +import { slackSetupState } from "./setup-state"; +const draft = (method?: "managed" | "automatic" | "manual" | "existing") => ({ id: "endpoint", setup: { step: "provider_setup", slackSetupMethod: method } }) as ChatEndpoint; +const state = (endpoint: ChatEndpoint | null, extra = {}) => slackSetupState({ endpoint, repairing: false, credentialsReady: false, identityReady: false, ...extra }); +describe("Slack setup capabilities and progress", () => { + it("does not flash token instructions while discovery is pending", () => { + expect(state(null).labels).toEqual(["Choose agent", "Connect"]); + }); + it("defaults only new drafts and preserves legacy manual drafts", () => { + expect(state(null, { defaultMethod: "managed" }).labels).toEqual(["Choose agent", "Connect"]); + expect(state(draft(), { defaultMethod: "managed" }).provisioned).toBe(false); + expect(state(draft("automatic"), { defaultMethod: "managed" }).labels).toHaveLength(4); + }); + it("keeps managed provider operations within the two visible stops", () => { + const endpoint = draft("managed"); + endpoint.setup!.slackRegistration = { status: "install", appId: "AAPP", managementUrl: "https://api.slack.com/apps/AAPP" }; + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + endpoint.setup!.slackRegistration.status = "credentials_saved"; + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + endpoint.providerAccountId = "TWORKSPACE"; + endpoint.setup!.slackRegistration.status = "configured"; + endpoint.setup!.slackAccount = { status: "linked", externalUserId: "UPERSON", paperclipUserId: "person", welcomeStatus: "sent" }; + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + endpoint.setup!.step = "test"; + expect(state(endpoint).complete).toBe(true); + expect(state(endpoint).toStage(1)).toBe(3); + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + }); +}); diff --git a/ui/src/pages/apps/chat/slack/setup-state.ts b/ui/src/pages/apps/chat/slack/setup-state.ts new file mode 100644 index 00000000000..9e25bc230db --- /dev/null +++ b/ui/src/pages/apps/chat/slack/setup-state.ts @@ -0,0 +1,30 @@ +import type { ChatEndpoint } from "@/api/chatEndpoints"; +import { isProvisionedSlackSetup } from "@paperclipai/shared"; +export { isProvisionedSlackSetup }; +export function slackSetupNavigation(method: string | undefined, availableStage = 1) { + const managed = method === "managed"; + return { + labels: managed ? ["Choose agent", "Connect"] : isProvisionedSlackSetup(method) + ? ["Choose agent", "App configuration access token", "Install Slack app", "Send a message"] + : ["Choose agent", "Create Slack app", "Add credentials", "Verify Slack connection", "Connect your Slack account", "Try it"], + toVisible: (step: number) => managed ? Math.min(step, 1) : step, + toStage: (step: number) => managed && step === 1 ? availableStage : step, + }; +} + +/** Logical stages stay compatible with existing draft URLs; navigation is derived here. */ +export function slackSetupState(input: { endpoint: ChatEndpoint | null; repairing: boolean; credentialsReady: boolean; identityReady: boolean; defaultMethod?: string }) { + const { endpoint, repairing, credentialsReady, identityReady } = input; + const method = endpoint ? endpoint.setup?.slackSetupMethod : input.defaultMethod ?? "managed"; + const provisioned = isProvisionedSlackSetup(method); + const complete = provisioned && (endpoint?.setup?.step === "test" || endpoint?.setup?.step === "complete"); + const finalStage = provisioned ? 3 : 5; + const availableStage = !endpoint ? 0 + : provisioned && endpoint.setup?.slackRegistration?.status === "configured" && endpoint.setup.slackAccount?.status !== "linked" && endpoint.setup.step !== "complete" ? 2 + : !repairing && (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") + ? endpoint.setup.step !== "complete" && !provisioned && !identityReady ? 4 : finalStage + : provisioned && endpoint.setup?.slackRegistration?.status === "credentials_saved" ? 2 + : endpoint.providerAccountId && !repairing ? 3 + : credentialsReady || repairing || method === "existing" || provisioned && endpoint.setup?.slackRegistration?.appId ? 2 : 1; + return { ...slackSetupNavigation(method, availableStage), provisioned, complete, finalStage, availableStage }; +} diff --git a/ui/storybook/fixtures/SlackSetupFixture.tsx b/ui/storybook/fixtures/SlackSetupFixture.tsx index be7b266de05..dc1d26a3991 100644 --- a/ui/storybook/fixtures/SlackSetupFixture.tsx +++ b/ui/storybook/fixtures/SlackSetupFixture.tsx @@ -7,10 +7,10 @@ import { type ChatEndpoint } from "@/api/chatEndpoints"; import { storybookAgents, storybookAuthSession } from "./paperclipData"; import { defaultSlackAppConfiguration } from "@paperclipai/shared"; -export type SlackSetupScenario = "choose" | "create" | "manual" | "install" | "declined" | "uncertain" | "manifest_pending" | "recovery" | "verify" | "avatar_failed" | "welcome_failed" | "success"; +export type SlackSetupScenario = "choose" | "create" | "manual" | "install" | "declined" | "approval_denied" | "uncertain" | "manifest_pending" | "recovery" | "verify" | "avatar_failed" | "welcome_failed" | "success"; /** Production wizard over a local provider fixture. No requests go to Slack. */ -export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSetupScenario }) { +export function SlackSetupFixture({ scenario = "create", managed = false, managedAvailable = true, workspaceCount = 1, optionsDelayMs = 0, optionsError = false }: { scenario?: SlackSetupScenario; managed?: boolean; managedAvailable?: boolean; workspaceCount?: number; optionsDelayMs?: number; optionsError?: boolean }) { const [ready, setReady] = useState(false); const [signalVerification, setSignalVerification] = useState<() => void>(() => () => {}); useLayoutEffect(() => { @@ -20,14 +20,16 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet const endpoint: ChatEndpoint = { id: "slack-story", companyId: "company-storybook", provider: "slack", status: "draft", assignedAgentId: agent.id, assignedAgentName: agent.name, allowUnlinkedPeople: false, - setup: { step: "provider_setup", slackSetupMethod: scenario === "manual" ? "manual" : "automatic", + setup: { step: "provider_setup", slackSetupMethod: scenario === "manual" ? "manual" : managed ? "managed" : "automatic", slackApp: defaultSlackAppConfiguration(agent.name), webhookUrl: "https://ingress.example/api/chat-webhooks/slack-story/slack", slackOAuthCallbackUri: "https://board.example/api/chat-slack/oauth/callback", }, }; + const grantId = "11111111-1111-4111-8111-111111111111"; + let authorized = workspaceCount > 0 || new URLSearchParams(window.location.search).get("slackFixtureAuthorized") === "1"; const created = () => { - endpoint.setup!.slackRegistration = { status: "install", appId: "ASTORY", managementUrl: "https://api.slack.com/apps/ASTORY" }; + endpoint.setup!.slackRegistration = { status: "install", ...(managed ? { managerGrantId: grantId } : {}), appId: "ASTORY", managementUrl: "https://api.slack.com/apps/ASTORY" }; endpoint.setup!.slackAvatar = scenario === "avatar_failed" ? { status: "failed", errorCode: "slack_avatar_upload_failed" } : { status: "uploaded", uploadedAt: new Date().toISOString() }; }; let linked = false; @@ -37,11 +39,12 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet endpoint.setup!.slackAccount = { externalUserId: "UPERSON", paperclipUserId: storybookAuthSession.user.id, status: "linked", welcomeStatus: scenario === "welcome_failed" ? "failed" : "sent", ...(scenario === "welcome_failed" ? {} : { dmChannelId: "DSTORY" }) }; endpoint.status = "verifying"; endpoint.providerAccountId = "TSTORY"; endpoint.botExternalId = "USTORY"; endpoint.botUsername = "maya"; }; - if (["install", "declined", "recovery", "manifest_pending"].includes(scenario)) created(); + if (["install", "declined", "approval_denied", "recovery", "manifest_pending"].includes(scenario)) created(); if (scenario === "manifest_pending") endpoint.setup!.slackRegistration!.errorCode = "slack_manifest_update_pending"; - if (scenario === "declined") endpoint.setup!.slackRegistration!.errorCode = "slack_install_declined"; + if (scenario === "declined") endpoint.setup!.slackRegistration!.errorCode = managed ? "slack_approval_pending" : "slack_install_declined"; + if (scenario === "approval_denied") endpoint.setup!.slackRegistration!.errorCode = "slack_approval_denied"; if (scenario === "recovery") endpoint.setup!.slackRegistration = { ...endpoint.setup!.slackRegistration!, status: "credentials_saved", errorCode: "slack_configuration_incomplete" }; - if (scenario === "uncertain") endpoint.setup!.slackRegistration = { status: "uncertain", errorCode: "slack_creation_uncertain", managementUrl: "https://api.slack.com/apps" }; + if (scenario === "uncertain") endpoint.setup!.slackRegistration = { status: "uncertain", ...(managed ? { managerGrantId: grantId } : {}), errorCode: "slack_creation_uncertain", managementUrl: "https://api.slack.com/apps" }; if (["verify", "avatar_failed", "welcome_failed", "success"].includes(scenario)) installed(); if (["avatar_failed", "welcome_failed", "success"].includes(scenario)) { endpoint.setup!.webhookVerifiedAt = new Date().toISOString(); @@ -52,12 +55,26 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet window.fetch = async (input, init) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin); const path = url.pathname; + if (path === "/api/companies/company-storybook/chat-slack/setup-options") { + if (optionsDelayMs) await new Promise(resolve => setTimeout(resolve, optionsDelayMs)); + if (optionsError) return Response.json({ error: "Slack setup unavailable" }, { status: 503 }); + return Response.json({ managedAvailable: managed && managedAvailable, defaultMethod: managed ? "managed" : "automatic", workspaces: managed && authorized ? Array.from({ length: Math.max(1, workspaceCount) }, (_, i) => ({ grantId: i ? "22222222-2222-4222-8222-222222222222" : grantId, workspaceId: `TSTORY${i}`, workspaceName: i ? "Research workspace" : "Paperclip", userId: "UPERSON" })) : [] }); + } + if (path === "/api/chat-endpoints/slack-story/slack/managed/authorize") { + authorized = true; + const returnUrl = new URL(window.location.href); + returnUrl.hash = ""; + returnUrl.searchParams.set("slackFixtureAuthorized", "1"); + return Response.json({ authorizationUrl: returnUrl.toString(), expiresAt: new Date(Date.now() + 600_000).toISOString() }); + } + if (path === "/api/chat-endpoints/slack-story/slack/managed/provision") { installed(); return Response.json({}); } if (path === "/api/companies/company-storybook/agents") return Response.json(agents); if (path === `/api/agents/${agent.id}`) return Response.json(agent); if (path === "/api/companies/company-storybook/chat-endpoints") { const input = JSON.parse(String(init?.body ?? "{}")); agent = agents.find(value => value.id === input.assignedAgentId) ?? agent; endpoint.assignedAgentId = agent.id; endpoint.assignedAgentName = agent.name; + endpoint.setup!.slackSetupMethod = input.slackSetupMethod ?? (managed ? "managed" : "automatic"); endpoint.setup!.slackApp = input.slackApp ?? defaultSlackAppConfiguration(agent.name); return Response.json(endpoint); } @@ -94,7 +111,7 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet }; setReady(true); return () => { window.fetch = original; }; - }, [scenario]); + }, [scenario, managed, managedAvailable, workspaceCount, optionsDelayMs, optionsError]); return
; } -const meta = { title: "Agents/Persistent files", component: AgentFilesStory, parameters: { layout: "fullscreen" } } satisfies Meta; +const meta = { id: "agents-persistent-files", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Persistent files", component: AgentFilesStory, parameters: { layout: "fullscreen" } } satisfies Meta; export default meta; type Story = StoryObj; export const InstructionsAndFiles: Story = {}; diff --git a/ui/storybook/stories/agent-management.stories.tsx b/ui/storybook/stories/agents/agent-management.stories.tsx similarity index 99% rename from ui/storybook/stories/agent-management.stories.tsx rename to ui/storybook/stories/agents/agent-management.stories.tsx index 7f1bee25fcc..2b877ad5cee 100644 --- a/ui/storybook/stories/agent-management.stories.tsx +++ b/ui/storybook/stories/agents/agent-management.stories.tsx @@ -38,7 +38,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; -import { storybookAgents, storybookIssues } from "../fixtures/paperclipData"; +import { storybookAgents, storybookIssues } from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; const now = new Date("2026-04-20T12:00:00.000Z"); @@ -819,7 +819,8 @@ function AgentManagementStories() { } const meta = { - title: "Product/Agent Management", + id: "product-agent-management", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management", component: AgentManagementStories, parameters: { docs: { diff --git a/ui/storybook/stories/agent-persona-pages.stories.tsx b/ui/storybook/stories/agents/agent-persona-pages.stories.tsx similarity index 96% rename from ui/storybook/stories/agent-persona-pages.stories.tsx rename to ui/storybook/stories/agents/agent-persona-pages.stories.tsx index 0ae8a727590..f220ae53f89 100644 --- a/ui/storybook/stories/agent-persona-pages.stories.tsx +++ b/ui/storybook/stories/agents/agent-persona-pages.stories.tsx @@ -14,7 +14,7 @@ import { NewAgent } from "@/pages/NewAgent"; import { AgentBasicsDialog } from "@/components/new-agent/AgentBasicsDialog"; import { queryKeys } from "@/lib/queryKeys"; import { resolveAgentAppearance } from "@paperclipai/shared"; -import { storybookAgents, storybookIssues, storybookActivityEvents, storybookLiveRuns, storybookDashboardSummary } from "../fixtures/paperclipData"; +import { storybookAgents, storybookIssues, storybookActivityEvents, storybookLiveRuns, storybookDashboardSummary } from "../../fixtures/paperclipData"; const companyId = "company-storybook"; const agents = storybookAgents.map(agent => { @@ -109,7 +109,8 @@ function PersonaPage({ path, meet = false }: { path: string; meet?: boolean }) { {meet && setDialogOpen(false)} onContinue={basics => { setDialogOpen(false); navigate(`/PAP/agents/new?name=${encodeURIComponent(basics.name)}&adapterType=${basics.adapterType}`); }} />} ; } -const meta = { title: "Agents/Personas/Full pages", parameters: { layout: "fullscreen", a11y: { test: "off" } } } satisfies Meta; +const meta = { id: "agents-personas-full-pages", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages", parameters: { layout: "fullscreen", a11y: { test: "off" } } } satisfies Meta; export default meta; type Story = StoryObj; export const AllAgents: Story = { render: () => }; diff --git a/ui/storybook/stories/agent-personas.stories.tsx b/ui/storybook/stories/agents/agent-personas.stories.tsx similarity index 95% rename from ui/storybook/stories/agent-personas.stories.tsx rename to ui/storybook/stories/agents/agent-personas.stories.tsx index d4a70696294..57aeec4a853 100644 --- a/ui/storybook/stories/agent-personas.stories.tsx +++ b/ui/storybook/stories/agents/agent-personas.stories.tsx @@ -3,15 +3,16 @@ import { expect, waitFor } from "storybook/test"; import { useEffect, useRef, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette, type CharacterState } from "@paperclipai/shared"; -import { AgentAvatar, avatarSizeClasses } from "../../src/components/AgentAvatar"; -import { AgentCharacter } from "../../src/components/AgentCharacter"; -import { AgentIdentity } from "../../src/components/AgentIdentity"; -import { Button } from "../../src/components/ui/button"; +import { AgentAvatar, avatarSizeClasses } from "../../../src/components/AgentAvatar"; +import { AgentCharacter } from "../../../src/components/AgentCharacter"; +import { AgentIdentity } from "../../../src/components/AgentIdentity"; +import { Button } from "../../../src/components/ui/button"; const appearance = appearanceForPalette("bubblegum-sky"); const agent = { id: "storybook-agent", name: "Chief of Staff", appearance }; const meta = { - title: "Agents/Personas", + id: "agents-personas", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas", component: AgentCharacter, args: { appearance, size: 256, state: "listening", label: "Chief of Staff" }, parameters: { docs: { description: { component: "Persistent cap-v1 identities. The hero alone loads ClipLab. Development Storybook renders capsule PNGs locally on demand; published Storybook packages the same PNGs during its build, including every preset and both densities. Neither needs a running API." } } }, diff --git a/ui/storybook/stories/agent-settings-redesign.stories.tsx b/ui/storybook/stories/agents/agent-settings-redesign.stories.tsx similarity index 91% rename from ui/storybook/stories/agent-settings-redesign.stories.tsx rename to ui/storybook/stories/agents/agent-settings-redesign.stories.tsx index 8a29b6c0a83..5a0d1bcd92f 100644 --- a/ui/storybook/stories/agent-settings-redesign.stories.tsx +++ b/ui/storybook/stories/agents/agent-settings-redesign.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { AgentSettingsPreview } from "../prototypes/agent-settings/AgentSettingsPreview"; +import { AgentSettingsPreview } from "../../prototypes/agent-settings/AgentSettingsPreview"; const meta = { - title: "Agents/Configuration refresh", + id: "agents-configuration-refresh", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Configuration refresh", component: AgentSettingsPreview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/built-in-agents.stories.tsx b/ui/storybook/stories/agents/built-in-agents.stories.tsx similarity index 98% rename from ui/storybook/stories/built-in-agents.stories.tsx rename to ui/storybook/stories/agents/built-in-agents.stories.tsx index b0bf66ddf84..9364b3fd3ab 100644 --- a/ui/storybook/stories/built-in-agents.stories.tsx +++ b/ui/storybook/stories/agents/built-in-agents.stories.tsx @@ -111,7 +111,8 @@ function SectionLabel({ children }: { children: React.ReactNode }) { } const meta: Meta = { - title: "Product/Built-in Agents", + id: "product-built-in-agents", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Product/Built-in Agents", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/external-agent-invite/components.stories.tsx b/ui/storybook/stories/agents/external-agent-invite/components.stories.tsx similarity index 88% rename from ui/storybook/stories/external-agent-invite/components.stories.tsx rename to ui/storybook/stories/agents/external-agent-invite/components.stories.tsx index be2808170f3..6e08aaa085e 100644 --- a/ui/storybook/stories/external-agent-invite/components.stories.tsx +++ b/ui/storybook/stories/agents/external-agent-invite/components.stories.tsx @@ -3,7 +3,8 @@ import { fn } from "storybook/test"; import { DotConnectionChecks, ExternalAgentPresetPicker } from "@/components/new-agent/ExternalAgentInviteContent"; const meta = { - title: "Onboarding/External agent invitation/Components", + id: "onboarding-external-agent-invitation-components", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Components", component: DotConnectionChecks, parameters: { layout: "centered", docs: { description: { component: "Controlled presentation states. Connection checks must be driven by server evidence. Subscription verification alone must never mark the connection ready." } } }, decorators: [Story =>
], diff --git a/ui/storybook/stories/external-agent-invite/journeys.stories.tsx b/ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx similarity index 96% rename from ui/storybook/stories/external-agent-invite/journeys.stories.tsx rename to ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx index 0c002c46722..31d87021728 100644 --- a/ui/storybook/stories/external-agent-invite/journeys.stories.tsx +++ b/ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within, waitFor } from "storybook/test"; -import { ExternalAgentInvitePreview } from "./ExternalAgentInvitePreview"; +import { ExternalAgentInvitePreview } from "../../../support/external-agent-invite/ExternalAgentInvitePreview"; const meta = { - title: "Onboarding/External agent invitation/Journeys", + id: "onboarding-external-agent-invitation-journeys", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Journeys", component: ExternalAgentInvitePreview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/new-agent-onboarding.stories.tsx b/ui/storybook/stories/agents/new-agent-onboarding.stories.tsx similarity index 97% rename from ui/storybook/stories/new-agent-onboarding.stories.tsx rename to ui/storybook/stories/agents/new-agent-onboarding.stories.tsx index c867442654e..0c7c0a963d4 100644 --- a/ui/storybook/stories/new-agent-onboarding.stories.tsx +++ b/ui/storybook/stories/agents/new-agent-onboarding.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { userEvent, within } from "storybook/test"; -import { NewAgentWizard } from "../prototypes/NewAgentWizard"; +import { NewAgentWizard } from "../../prototypes/NewAgentWizard"; const meta = { - title: "Onboarding/New agent", + id: "onboarding-new-agent", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/New agent", component: NewAgentWizard, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/primary-agent/00-review-guide.stories.tsx b/ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx similarity index 95% rename from ui/storybook/stories/primary-agent/00-review-guide.stories.tsx rename to ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx index 229bef555d5..851d249fe76 100644 --- a/ui/storybook/stories/primary-agent/00-review-guide.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx @@ -50,5 +50,6 @@ function ReviewGuide() {
; } -export default { title: "Primary Agent/00 Review guide", parameters: { layout: "fullscreen" }, render: () => } satisfies Meta; +export default { id: "primary-agent-00-review-guide", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide", parameters: { layout: "fullscreen" }, render: () => } satisfies Meta; export const StartHere: StoryObj = {}; diff --git a/ui/storybook/stories/primary-agent/01-components.stories.tsx b/ui/storybook/stories/agents/primary-agent/01-components.stories.tsx similarity index 73% rename from ui/storybook/stories/primary-agent/01-components.stories.tsx rename to ui/storybook/stories/agents/primary-agent/01-components.stories.tsx index 339a71269fe..20aad95c2f9 100644 --- a/ui/storybook/stories/primary-agent/01-components.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/01-components.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { AgentIdentity } from "@/components/AgentIdentity"; import { PrimaryAgentIndicator } from "@/components/primary-agent/PrimaryAgentPresentation"; -import { primaryDecorator, primaryParameters, usePrimaryStory, type PrimaryScenario } from "./PrimaryAgentStory"; +import { primaryDecorator, primaryParameters, usePrimaryStory, type PrimaryScenario } from "../../../support/primary-agent/PrimaryAgentStory"; function Components(_args: PrimaryScenario) { const { agents } = usePrimaryStory(); @@ -11,7 +11,8 @@ function Components(_args: PrimaryScenario) { )}
; } -export default { title: "Primary Agent/01 Components", component: Components, decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, render: args => } satisfies Meta; +export default { id: "primary-agent-01-components", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components", component: Components, decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, render: args => } satisfies Meta; type Story = StoryObj; export const CrownIndicator: Story = {}; export const NoPrimary: Story = { args: { primaryId: null } }; diff --git a/ui/storybook/stories/primary-agent/02-sidebar.stories.tsx b/ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx similarity index 76% rename from ui/storybook/stories/primary-agent/02-sidebar.stories.tsx rename to ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx index e392af89f73..ca5b0d1056c 100644 --- a/ui/storybook/stories/primary-agent/02-sidebar.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { userEvent, within } from "storybook/test"; -import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory"; -export default { title: "Primary Agent/02 Sidebar", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => } satisfies Meta; +export default { id: "primary-agent-02-sidebar", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => } satisfies Meta; type Story = StoryObj; export const IdlePrimary: Story = {}; export const ActiveColleague: Story = { parameters: { primaryAgent: { active: true } } }; diff --git a/ui/storybook/stories/primary-agent/03-agents.stories.tsx b/ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx similarity index 78% rename from ui/storybook/stories/primary-agent/03-agents.stories.tsx rename to ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx index ae4d8115a3f..17ae07fb2bc 100644 --- a/ui/storybook/stories/primary-agent/03-agents.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory"; -export default { title: "Primary Agent/03 Agents", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => } satisfies Meta; +export default { id: "primary-agent-03-agents", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => } satisfies Meta; type Story = StoryObj; export const Roster: Story = {}; export const Profile: Story = { parameters: { initialEntries: ["/PAP/agents/maia"] } }; diff --git a/ui/storybook/stories/primary-agent/04-new-task.stories.tsx b/ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx similarity index 74% rename from ui/storybook/stories/primary-agent/04-new-task.stories.tsx rename to ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx index 62a316e629e..e3d78f4d0a4 100644 --- a/ui/storybook/stories/primary-agent/04-new-task.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory"; -export default { title: "Primary Agent/04 New task", decorators: [primaryDecorator], parameters: primaryParameters, render: () => } satisfies Meta; +export default { id: "primary-agent-04-new-task", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task", decorators: [primaryDecorator], parameters: primaryParameters, render: () => } satisfies Meta; type Story = StoryObj; export const PrimaryFallback: Story = {}; export const RecentSpecialistWins: Story = { parameters: { primaryAgent: { recentAssignee: true } }, render: () => }; diff --git a/ui/storybook/stories/primary-agent/05-chat.stories.tsx b/ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx similarity index 68% rename from ui/storybook/stories/primary-agent/05-chat.stories.tsx rename to ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx index b5a2f3fa2aa..e487734f4dd 100644 --- a/ui/storybook/stories/primary-agent/05-chat.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory"; -export default { title: "Primary Agent/05 Chat", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/chats"] }, render: () => } satisfies Meta; +export default { id: "primary-agent-05-chat", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/chats"] }, render: () => } satisfies Meta; type Story = StoryObj; export const PrimaryFallback: Story = {}; export const RecentConversationWins: Story = { parameters: { primaryAgent: { recentChat: true } } }; diff --git a/ui/storybook/stories/primary-agent/06-journeys.stories.tsx b/ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx similarity index 95% rename from ui/storybook/stories/primary-agent/06-journeys.stories.tsx rename to ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx index 0172759b91f..c4496e5118e 100644 --- a/ui/storybook/stories/primary-agent/06-journeys.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within, waitFor } from "storybook/test"; -import { PrimaryPage, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; +import { PrimaryPage, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory"; async function openActions(canvasElement: HTMLElement, name: string, profile = false) { const page = within(canvasElement.ownerDocument.body); @@ -11,7 +11,8 @@ async function openActions(canvasElement: HTMLElement, name: string, profile = f return page; } const maiaProfile = { initialEntries: ["/PAP/agents/maia"] }; -export default { title: "Primary Agent/06 Journeys", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/agents/alex"] }, render: () => } satisfies Meta; +export default { id: "primary-agent-06-journeys", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/agents/alex"] }, render: () => } satisfies Meta; type Story = StoryObj; async function confirmPrimaryChange(canvasElement: HTMLElement) { const page = within(canvasElement.ownerDocument.body); diff --git a/ui/storybook/stories/primary-agent/07-confirmation.stories.tsx b/ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx similarity index 82% rename from ui/storybook/stories/primary-agent/07-confirmation.stories.tsx rename to ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx index 73329989d76..be3e59897d7 100644 --- a/ui/storybook/stories/primary-agent/07-confirmation.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory"; export default { - title: "Primary Agent/07 Confirmation", + id: "primary-agent-07-confirmation", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/agents/alex"] }, render: () => , diff --git a/ui/storybook/stories/summary-slot-card.stories.tsx b/ui/storybook/stories/agents/summary-slot-card.stories.tsx similarity index 98% rename from ui/storybook/stories/summary-slot-card.stories.tsx rename to ui/storybook/stories/agents/summary-slot-card.stories.tsx index 9a91ef710f2..2742160f79b 100644 --- a/ui/storybook/stories/summary-slot-card.stories.tsx +++ b/ui/storybook/stories/agents/summary-slot-card.stories.tsx @@ -325,7 +325,8 @@ const DESKTOP = 640; const MOBILE = 375; const meta: Meta = { - title: "Summaries/SummarySlotCard", + id: "summaries-summaryslotcard", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Summaries/SummarySlotCard", component: CardHarness, parameters: { layout: "padded" }, }; diff --git a/ui/storybook/stories/action-card.stories.tsx b/ui/storybook/stories/app-permissions/action-card.stories.tsx similarity index 95% rename from ui/storybook/stories/action-card.stories.tsx rename to ui/storybook/stories/app-permissions/action-card.stories.tsx index 869b070ac91..5222edf27ef 100644 --- a/ui/storybook/stories/action-card.stories.tsx +++ b/ui/storybook/stories/app-permissions/action-card.stories.tsx @@ -21,7 +21,8 @@ const baseProps = { }; const meta: Meta = { - title: "Tools & Access/ActionCard", + id: "tools-access-actioncard", + title: "Apps, channels, and extensions/App access and action permissions/Tools & Access/ActionCard", component: ActionCard, }; export default meta; diff --git a/ui/storybook/stories/action-permissions-test.stories.tsx b/ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx similarity index 98% rename from ui/storybook/stories/action-permissions-test.stories.tsx rename to ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx index 38f2601ca3d..235a8fde921 100644 --- a/ui/storybook/stories/action-permissions-test.stories.tsx +++ b/ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx @@ -177,7 +177,8 @@ function PermissionsTestStory({ result, tool = TOOL, appName = "Asana", query }: } const meta = { - title: "Apps/Action permissions test", + id: "apps-action-permissions-test", + title: "Apps, channels, and extensions/App access and action permissions/Apps/Action permissions test", parameters: { layout: "fullscreen" }, } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/app-permissions/apps-managed-accounts.stories.tsx b/ui/storybook/stories/app-permissions/apps-managed-accounts.stories.tsx new file mode 100644 index 00000000000..7fffdf2e6ef --- /dev/null +++ b/ui/storybook/stories/app-permissions/apps-managed-accounts.stories.tsx @@ -0,0 +1,7 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ManagedAggregatorAccounts } from "../../prototypes/ManagedAggregatorAccounts"; +const meta = { id: "apps-managed-accounts", + title: "Apps, channels, and extensions/App access and action permissions/Apps/Managed accounts", component: ManagedAggregatorAccounts, parameters: { layout: "fullscreen", docs: { description: { component: "Production Apps page using production API routes and a disposable PostgreSQL database. Upstream Arcade, Composio and Executor responses are fixtures. Start tests/aggregator-accounts/test-drive.ts, then use the upstream controls and each gateway’s Refresh action." } } } } satisfies Meta; +export default meta; +type Story = StoryObj; +export const FullStackTestDrive: Story = {}; diff --git a/ui/storybook/stories/permitted-vs-installed.stories.tsx b/ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx similarity index 98% rename from ui/storybook/stories/permitted-vs-installed.stories.tsx rename to ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx index 6d5874bc630..07bb33c8104 100644 --- a/ui/storybook/stories/permitted-vs-installed.stories.tsx +++ b/ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx @@ -122,7 +122,8 @@ function connection(id: string, name: string, installs: InstallState): ToolConne } const meta: Meta = { - title: "Tools/Permitted vs Installed", + id: "tools-permitted-vs-installed", + title: "Apps, channels, and extensions/App access and action permissions/Tools/Permitted vs Installed", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/apps-managed-accounts.stories.tsx b/ui/storybook/stories/apps-managed-accounts.stories.tsx deleted file mode 100644 index fdff3c42e03..00000000000 --- a/ui/storybook/stories/apps-managed-accounts.stories.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ManagedAggregatorAccounts } from "../prototypes/ManagedAggregatorAccounts"; -const meta = { title: "Apps/Managed accounts", component: ManagedAggregatorAccounts, parameters: { layout: "fullscreen", docs: { description: { component: "Production Apps page using production API routes and a disposable PostgreSQL database. Upstream Arcade, Composio and Executor responses are fixtures. Start tests/aggregator-accounts/test-drive.ts, then use the upstream controls and each gateway’s Refresh action." } } } } satisfies Meta; -export default meta; -type Story = StoryObj; -export const FullStackTestDrive: Story = {}; diff --git a/ui/storybook/stories/budget-finance.stories.tsx b/ui/storybook/stories/budgets-costs/budget-finance.stories.tsx similarity index 99% rename from ui/storybook/stories/budget-finance.stories.tsx rename to ui/storybook/stories/budgets-costs/budget-finance.stories.tsx index 21cbe1df0c2..df59337d580 100644 --- a/ui/storybook/stories/budget-finance.stories.tsx +++ b/ui/storybook/stories/budgets-costs/budget-finance.stories.tsx @@ -765,7 +765,8 @@ function BudgetFinanceMatrix() { } const meta = { - title: "Product/Budget & Finance", + id: "product-budget-finance", + title: "Oversight and operation/Costs and budgets/Product/Budget & Finance", component: BudgetFinanceMatrix, parameters: { docs: { diff --git a/ui/storybook/stories/costs/subscriptions.stories.tsx b/ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx similarity index 98% rename from ui/storybook/stories/costs/subscriptions.stories.tsx rename to ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx index 3173cb6259a..6d55bb84345 100644 --- a/ui/storybook/stories/costs/subscriptions.stories.tsx +++ b/ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx @@ -100,7 +100,8 @@ function SubscriptionCostsStory({ unknownPrice = false, stale = false, costGaps return ready ? <> : null; } -const meta = { title: "Costs/Subscriptions", component: SubscriptionCostsStory, parameters: { +const meta = { id: "costs-subscriptions", + title: "Oversight and operation/Costs and budgets/Costs/Subscriptions", component: SubscriptionCostsStory, parameters: { layout: "padded", docs: { description: { component: "Production Costs page with API spend, monthly subscription estimates, user and project costs. View details to edit monthly or annual prices. Provider calls are fixtures." } }, } } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/agentmail-onboarding.stories.tsx b/ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx similarity index 98% rename from ui/storybook/stories/agentmail-onboarding.stories.tsx rename to ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx index 30607a45286..0542a1c4bfc 100644 --- a/ui/storybook/stories/agentmail-onboarding.stories.tsx +++ b/ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx @@ -6,7 +6,7 @@ import { ChatSetupSidebar } from "@/components/chat/ChatSetupNavigation"; import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext"; import { useCompany } from "@/context/CompanyContext"; import { useLocation, useNavigate } from "@/lib/router"; -import { storybookAgents } from "../fixtures/paperclipData"; +import { storybookAgents } from "../../fixtures/paperclipData"; const COMPANY = "company-storybook"; const CONNECTION = "agentmail-storybook-account"; @@ -40,7 +40,8 @@ function AgentMailJourney({ savedConnection = true }: { savedConnection?: boolea type Scenario = { noSavedKeys?: boolean; scopedKey?: boolean; missingBoundary?: boolean; denySetup?: boolean; initialTaken?: boolean; noCustomDomain?: boolean; checkFailed?: boolean }; const meta = { - title: "Connections/AgentMail setup", + id: "connections-agentmail-setup", + title: "Apps, channels, and extensions/External chat and email/Connections/AgentMail setup", component: AgentMailJourney, parameters: { layout: "fullscreen" }, args: { savedConnection: true }, diff --git a/ui/storybook/stories/agentmail-settings.stories.tsx b/ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx similarity index 97% rename from ui/storybook/stories/agentmail-settings.stories.tsx rename to ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx index 0d22f49ddcb..cb992253cab 100644 --- a/ui/storybook/stories/agentmail-settings.stories.tsx +++ b/ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx @@ -6,7 +6,8 @@ import type { EmailEndpointSummary } from "@paperclipai/shared"; const COMPANY = "company-storybook"; const ENDPOINT = "agentmail-settings-preview"; const meta = { - title: "Connections/AgentMail settings", + id: "connections-agentmail-settings", + title: "Apps, channels, and extensions/External chat and email/Connections/AgentMail settings", component: EmailEndpointSettings, parameters: { layout: "padded" }, args: { endpointId: ENDPOINT, companyId: COMPANY, assignedAgentName: "Ralph" }, diff --git a/ui/storybook/stories/agentmail.stories.tsx b/ui/storybook/stories/chat-channels/agentmail.stories.tsx similarity index 99% rename from ui/storybook/stories/agentmail.stories.tsx rename to ui/storybook/stories/chat-channels/agentmail.stories.tsx index 9e56b4fef70..0089529b4db 100644 --- a/ui/storybook/stories/agentmail.stories.tsx +++ b/ui/storybook/stories/chat-channels/agentmail.stories.tsx @@ -545,7 +545,8 @@ function EmailTaskExperience({ ); } const meta = { - title: "Connections/AgentMail tasks", + id: "connections-agentmail-tasks", + title: "Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks", component: EmailTaskExperience, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/chat-connection-actions.stories.tsx b/ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx similarity index 93% rename from ui/storybook/stories/chat-connection-actions.stories.tsx rename to ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx index b96e48bafd7..dc1793dd292 100644 --- a/ui/storybook/stories/chat-connection-actions.stories.tsx +++ b/ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx @@ -10,7 +10,8 @@ const endpoint: ChatEndpoint = { }; const meta = { - title: "Connections/Chat connection actions", + id: "connections-chat-connection-actions", + title: "Apps, channels, and extensions/External chat and email/Connections/Chat connection actions", component: ConnectorCard, parameters: { layout: "padded" }, args: { diff --git a/ui/storybook/stories/chat-management/chat-management.stories.tsx b/ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx similarity index 98% rename from ui/storybook/stories/chat-management/chat-management.stories.tsx rename to ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx index 6160849cbdc..b6246aaba58 100644 --- a/ui/storybook/stories/chat-management/chat-management.stories.tsx +++ b/ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx @@ -9,7 +9,7 @@ import { PluginLauncherProvider } from "@/plugins/launchers"; import { ChatEndpointDetail } from "@/pages/apps/chat/ChatEndpointDetail"; import { useCompany } from "@/context/CompanyContext"; import type { ChatEndpoint, ChatEndpointResource, ChatIdentityLink } from "@/api/chatEndpoints"; -import { storybookAgents } from "../../fixtures/paperclipData"; +import { storybookAgents } from "../../../fixtures/paperclipData"; type Scenario = "populated" | "empty" | "error" | "channel-added" | "unlinked" | "invitation-expired"; function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab?: string; scenario?: Scenario }) { @@ -105,7 +105,8 @@ function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab? } const meta = { - title: "Connections/Chat management", + id: "connections-chat-management", + title: "Apps, channels, and extensions/External chat and email/Connections/Chat management", component: ChatManagementPage, parameters: { layout: "fullscreen", a11y: { test: "off" } }, args: { tab: "settings", scenario: "populated" }, diff --git a/ui/storybook/stories/github-bot-management/management.stories.tsx b/ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx similarity index 97% rename from ui/storybook/stories/github-bot-management/management.stories.tsx rename to ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx index 0d32d841935..38b2ed87ee0 100644 --- a/ui/storybook/stories/github-bot-management/management.stories.tsx +++ b/ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx @@ -32,11 +32,12 @@ import { configuration, reviews, conversations, -} from "./fixtures"; -import { FixtureApi, type FixtureState } from "./fixture-api"; +} from "../../../support/github-bot-management/fixtures"; +import { FixtureApi, type FixtureState } from "../../../support/github-bot-management/fixture-api"; const meta = { - title: "Connectors/GitHub bot management", + id: "connectors-github-bot-management", + title: "Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management", parameters: { layout: "fullscreen", initialEntries: [`/PAP/apps/chat/${endpoint.id}/settings`], diff --git a/ui/storybook/stories/github-chat.stories.tsx b/ui/storybook/stories/chat-channels/github-chat.stories.tsx similarity index 98% rename from ui/storybook/stories/github-chat.stories.tsx rename to ui/storybook/stories/chat-channels/github-chat.stories.tsx index 5e6d09b6e25..b32fb9383d8 100644 --- a/ui/storybook/stories/github-chat.stories.tsx +++ b/ui/storybook/stories/chat-channels/github-chat.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { GitHubChatPreview } from "../prototypes/github-chat/GitHubChatPreview"; +import { GitHubChatPreview } from "../../prototypes/github-chat/GitHubChatPreview"; const meta = { - title: "Historical/GitHub chat prototypes", + id: "historical-github-chat-prototypes", + title: "Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes", component: GitHubChatPreview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/github-connections.stories.tsx b/ui/storybook/stories/chat-channels/github-connections.stories.tsx similarity index 96% rename from ui/storybook/stories/github-connections.stories.tsx rename to ui/storybook/stories/chat-channels/github-connections.stories.tsx index ed15b4ef80e..a176546e78d 100644 --- a/ui/storybook/stories/github-connections.stories.tsx +++ b/ui/storybook/stories/chat-channels/github-connections.stories.tsx @@ -10,7 +10,7 @@ import { queryKeys } from "@/lib/queryKeys"; import { Browse } from "@/pages/apps/Browse"; import { AppsConnect } from "@/pages/apps/AppsConnect"; import { ChatEndpointSetup } from "@/pages/apps/chat/ChatEndpointSetup"; -import { storybookAgents } from "../fixtures/paperclipData"; +import { storybookAgents } from "../../fixtures/paperclipData"; const companyId = "company-storybook"; // Hosted Storybooks live below a branch path; reuse their bundled GitHub marks. @@ -78,7 +78,8 @@ function GitHubConnections({ saved = false, chatEnabled = true }) { } const meta = { - title: "Connections/GitHub and Code Review Bot", + id: "connections-github-and-code-review-bot", + title: "Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot", component: GitHubConnections, play: async ({ canvasElement }) => { await userEvent.type(await within(canvasElement).findByRole("searchbox", { name: "Search connectors" }), "GitHub"); diff --git a/ui/storybook/stories/imessage-photon.stories.tsx b/ui/storybook/stories/chat-channels/imessage-photon.stories.tsx similarity index 98% rename from ui/storybook/stories/imessage-photon.stories.tsx rename to ui/storybook/stories/chat-channels/imessage-photon.stories.tsx index 13a227a48d8..00c7b2c4621 100644 --- a/ui/storybook/stories/imessage-photon.stories.tsx +++ b/ui/storybook/stories/chat-channels/imessage-photon.stories.tsx @@ -11,7 +11,7 @@ import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble"; import { Button } from "@/components/ui/button"; import type { ChatEndpoint, ChatIdentityLink } from "@/api/chatEndpoints"; import type { TaskChatMessageItem } from "@/components/task-chat/task-chat-model"; -import { storybookAgents } from "../fixtures/paperclipData"; +import { storybookAgents } from "../../fixtures/paperclipData"; type Screen = "catalog" | "agent" | "credentials" | "access" | "task" | "reconnect"; type Scenario = { screen?: Screen; dedicated?: boolean; outage?: boolean; noLine?: boolean; loading?: boolean; connecting?: boolean }; @@ -96,7 +96,8 @@ function Host(props: { screen?: Screen }) { } const meta = { - title: "Connections/iMessage Photon", component: Host, + id: "connections-imessage-photon", + title: "Apps, channels, and extensions/External chat and email/Connections/iMessage Photon", component: Host, parameters: { layout: "fullscreen" }, beforeEach: ({ parameters }) => { const scenario = (parameters.photonScenario ?? {}) as Scenario; diff --git a/ui/storybook/stories/slack-automatic-setup.stories.tsx b/ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx similarity index 95% rename from ui/storybook/stories/slack-automatic-setup.stories.tsx rename to ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx index 4ed65ff6032..813937e17b7 100644 --- a/ui/storybook/stories/slack-automatic-setup.stories.tsx +++ b/ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { SlackSetupFixture } from "../fixtures/SlackSetupFixture"; +import { SlackSetupFixture } from "../../fixtures/SlackSetupFixture"; const start = "/PAP/apps/chat/connect?provider=slack&purpose=chat"; const meta = { - title: "Connections/Slack/Automatic setup", + id: "connections-slack-automatic-setup", + title: "Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup", component: SlackSetupFixture, parameters: { layout: "fullscreen", initialEntries: [`${start}&resume=slack-story`], docs: { description: { component: "Production Slack setup pages in journey order. Automatic stories use the same four-step wizard; manual setup retains account linking. Slack creation, consent, verification, and identity discovery are simulated; use synthetic tokens only." } } }, diff --git a/ui/storybook/stories/slack-avatar-settings.stories.tsx b/ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx similarity index 67% rename from ui/storybook/stories/slack-avatar-settings.stories.tsx rename to ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx index 96ce07e8110..011d9367cf9 100644 --- a/ui/storybook/stories/slack-avatar-settings.stories.tsx +++ b/ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { SlackAvatarSettingsPreview } from "../prototypes/slack-avatar/SlackAvatarSettingsPreview"; +import { SlackAvatarSettingsPreview } from "../../prototypes/slack-avatar/SlackAvatarSettingsPreview"; const meta = { - title: "Connections/Slack/Avatar in Settings", + id: "connections-slack-avatar-in-settings", + title: "Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings", component: SlackAvatarSettingsPreview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/slack-communication-settings.stories.tsx b/ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx similarity index 86% rename from ui/storybook/stories/slack-communication-settings.stories.tsx rename to ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx index fa28a1145dc..7e6d39a63dc 100644 --- a/ui/storybook/stories/slack-communication-settings.stories.tsx +++ b/ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { SlackAvatarSettingsPreview } from "../prototypes/slack-avatar/SlackAvatarSettingsPreview"; +import { SlackAvatarSettingsPreview } from "../../prototypes/slack-avatar/SlackAvatarSettingsPreview"; const meta = { - title: "Connections/Slack/Communication in Settings", + id: "connections-slack-communication-in-settings", + title: "Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings", component: SlackAvatarSettingsPreview, parameters: { layout: "fullscreen", docs: { description: { component: "Production communication field in a Settings fixture. Saves use local story state; instructions apply to new tasks. Reply detail and progress are built-in behavior." } } }, } satisfies Meta; diff --git a/ui/storybook/stories/slack-managed-setup.stories.tsx b/ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx similarity index 96% rename from ui/storybook/stories/slack-managed-setup.stories.tsx rename to ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx index 78638174cf1..5af30cc73ad 100644 --- a/ui/storybook/stories/slack-managed-setup.stories.tsx +++ b/ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within, waitFor } from "storybook/test"; -import { SlackSetupFixture } from "../fixtures/SlackSetupFixture"; +import { SlackSetupFixture } from "../../fixtures/SlackSetupFixture"; const start = "/PAP/apps/chat/connect?provider=slack&purpose=chat"; const meta = { - title: "Connections/Slack/Managed setup", component: SlackSetupFixture, + id: "connections-slack-managed-setup", + title: "Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup", component: SlackSetupFixture, args: { managed: true }, parameters: { layout: "fullscreen", initialEntries: [`${start}&resume=slack-story`] }, render: args => , diff --git a/ui/storybook/stories/slack-tools.stories.tsx b/ui/storybook/stories/chat-channels/slack-tools.stories.tsx similarity index 91% rename from ui/storybook/stories/slack-tools.stories.tsx rename to ui/storybook/stories/chat-channels/slack-tools.stories.tsx index 18d7b994119..c5eba3392a2 100644 --- a/ui/storybook/stories/slack-tools.stories.tsx +++ b/ui/storybook/stories/chat-channels/slack-tools.stories.tsx @@ -3,7 +3,7 @@ import { SLACK_TOOLS, type SlackSearchStatus } from "@paperclipai/shared"; import { SlackCapabilitiesView, SlackSearchView, -} from "../../src/pages/apps/chat/SlackToolSettings"; +} from "../../../src/pages/apps/chat/SlackToolSettings"; const base: SlackSearchStatus = { canConfigure: true, configured: false, @@ -52,7 +52,8 @@ function Preview({ ); } export default { - title: "Connections/Slack/Task tools", + id: "connections-slack-task-tools", + title: "Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools", component: Preview, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/decision-models/01-settings.stories.tsx b/ui/storybook/stories/companies/decision-models/01-settings.stories.tsx similarity index 83% rename from ui/storybook/stories/decision-models/01-settings.stories.tsx rename to ui/storybook/stories/companies/decision-models/01-settings.stories.tsx index 51848e78396..f29a32edc68 100644 --- a/ui/storybook/stories/decision-models/01-settings.stories.tsx +++ b/ui/storybook/stories/companies/decision-models/01-settings.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { fn } from "storybook/test"; import { DecisionModelSettingsView } from "@/components/decision-models/DecisionModelSettings"; -import { choices, configured, result, unconfigured } from "./fixtures"; +import { choices, configured, result, unconfigured } from "../../../support/decision-models/fixtures"; -const meta = { title: "Decision models/01 Settings", component: DecisionModelSettingsView, +const meta = { id: "decision-models-01-settings", + title: "Getting started and access/Companies and portability/Decision models/01 Settings", component: DecisionModelSettingsView, decorators: [Story =>
], args: { settings: configured, choices, onSave: fn(), onTest: fn(), onAdd: fn() }, } satisfies Meta; diff --git a/ui/storybook/stories/decision-models/02-history.stories.tsx b/ui/storybook/stories/companies/decision-models/02-history.stories.tsx similarity index 81% rename from ui/storybook/stories/decision-models/02-history.stories.tsx rename to ui/storybook/stories/companies/decision-models/02-history.stories.tsx index 8ee9f752baa..5380d559d76 100644 --- a/ui/storybook/stories/decision-models/02-history.stories.tsx +++ b/ui/storybook/stories/companies/decision-models/02-history.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { DecisionHistoryTable } from "@/components/decision-models/DecisionHistory"; -import { entry } from "./fixtures"; +import { entry } from "../../../support/decision-models/fixtures"; -const meta = { title: "Decision models/02 History", component: DecisionHistoryTable, +const meta = { id: "decision-models-02-history", + title: "Getting started and access/Companies and portability/Decision models/02 History", component: DecisionHistoryTable, decorators: [Story =>
], args: { entries: [entry] }, } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/decision-models/03-journey.stories.tsx b/ui/storybook/stories/companies/decision-models/03-journey.stories.tsx similarity index 96% rename from ui/storybook/stories/decision-models/03-journey.stories.tsx rename to ui/storybook/stories/companies/decision-models/03-journey.stories.tsx index 8a409367d7f..2140d99c977 100644 --- a/ui/storybook/stories/decision-models/03-journey.stories.tsx +++ b/ui/storybook/stories/companies/decision-models/03-journey.stories.tsx @@ -7,7 +7,7 @@ import { Layout } from "@/components/Layout"; import { CompanySettings } from "@/pages/CompanySettings"; import { Costs } from "@/pages/Costs"; import { PluginLauncherProvider } from "@/plugins/launchers"; -import { choices, companyId, configured, entry, result, unconfigured } from "./fixtures"; +import { choices, companyId, configured, entry, result, unconfigured } from "../../../support/decision-models/fixtures"; import type { DecisionHistoryEntry } from "@paperclipai/shared"; /** Production pages and shell; only API responses and paid provider execution are simulated. */ @@ -68,7 +68,8 @@ function Journey({ alreadyConfigured = false }: { alreadyConfigured?: boolean }) } /> : null; } -const meta = { title: "Decision models/03 Journey", component: Journey, parameters: { +const meta = { id: "decision-models-03-journey", + title: "Getting started and access/Companies and portability/Decision models/03 Journey", component: Journey, parameters: { layout: "fullscreen", initialEntries: ["/PAP/company/settings"], docs: { story: { inline: false }, description: { component: "Select an existing shared connection, preserve the background setting, save, run the sample, and follow View usage. Paid calls are fixtures." } }, } } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/fast-responses/01-settings.stories.tsx b/ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx similarity index 91% rename from ui/storybook/stories/fast-responses/01-settings.stories.tsx rename to ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx index 4ed8d04936f..e306ab47450 100644 --- a/ui/storybook/stories/fast-responses/01-settings.stories.tsx +++ b/ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { fn } from "storybook/test"; import { FastResponseSettingsView } from "@/components/fast-responses/FastResponseSettings"; -import { choices } from "../decision-models/fixtures"; +import { choices } from "../../../support/decision-models/fixtures"; import type { FastResponseSettings, FastResponseTestResult, @@ -36,7 +36,8 @@ export const result: FastResponseTestResult = { }, }; const meta = { - title: "Fast responses/01 Settings", + id: "fast-responses-01-settings", + title: "Getting started and access/Companies and portability/Fast responses/01 Settings", excludeStories: ["empty", "configured", "result"], component: FastResponseSettingsView, decorators: [ diff --git a/ui/storybook/stories/fast-responses/02-history.stories.tsx b/ui/storybook/stories/companies/fast-responses/02-history.stories.tsx similarity index 91% rename from ui/storybook/stories/fast-responses/02-history.stories.tsx rename to ui/storybook/stories/companies/fast-responses/02-history.stories.tsx index 646074f0059..e3880534fb4 100644 --- a/ui/storybook/stories/fast-responses/02-history.stories.tsx +++ b/ui/storybook/stories/companies/fast-responses/02-history.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { FastResponseHistoryEntry } from "@paperclipai/shared"; import { DecisionHistoryTable } from "@/components/decision-models/DecisionHistory"; -import { entry } from "../decision-models/fixtures"; +import { entry } from "../../../support/decision-models/fixtures"; const receipt: FastResponseHistoryEntry = { ...entry, feature: "message", @@ -16,7 +16,8 @@ const receipt: FastResponseHistoryEntry = { costStatus: "reported", }; const meta = { - title: "Fast responses/02 History", + id: "fast-responses-02-history", + title: "Getting started and access/Companies and portability/Fast responses/02 History", component: DecisionHistoryTable, decorators: [ (Story) => ( diff --git a/ui/storybook/stories/AppLogoRegistry.stories.tsx b/ui/storybook/stories/connection-setup/AppLogoRegistry.stories.tsx similarity index 86% rename from ui/storybook/stories/AppLogoRegistry.stories.tsx rename to ui/storybook/stories/connection-setup/AppLogoRegistry.stories.tsx index 1c002a6bab4..218ef2d7f97 100644 --- a/ui/storybook/stories/AppLogoRegistry.stories.tsx +++ b/ui/storybook/stories/connection-setup/AppLogoRegistry.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { AppLogo } from "@/pages/apps/AppLogo"; -import manifest from "../../public/brands/apps/manifest.json"; +import manifest from "../../../public/brands/apps/manifest.json"; const sizes = [24, 28, 32, 36, 44, 48]; const launchOrder = ["gmail", "google-calendar", "slack", "stripe"]; @@ -31,7 +31,8 @@ function Registry() { ); } -const meta = { title: "Apps/Canonical icon registry", component: Registry, parameters: { layout: "fullscreen" } } satisfies Meta; +const meta = { id: "apps-canonical-icon-registry", + title: "Apps, channels, and extensions/Connection setup/Apps/Canonical icon registry", component: Registry, parameters: { layout: "fullscreen" } } satisfies Meta; export default meta; type Story = StoryObj; export const Light: Story = { globals: { theme: "light" } }; diff --git a/ui/storybook/stories/ArcadeConnection.stories.tsx b/ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx similarity index 94% rename from ui/storybook/stories/ArcadeConnection.stories.tsx rename to ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx index 04688e63b62..15186c52ec4 100644 --- a/ui/storybook/stories/ArcadeConnection.stories.tsx +++ b/ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx @@ -1,9 +1,10 @@ import { userEvent, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { RemoteMcpConnectionReview } from "../prototypes/RemoteMcpConnectionReview"; +import { RemoteMcpConnectionReview } from "../../prototypes/RemoteMcpConnectionReview"; const meta = { - title: "Apps/Connections/Arcade", + id: "apps-connections-arcade", + title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade", component: RemoteMcpConnectionReview, args: { provider: "arcade", scenario: "journey" }, parameters: { layout: "fullscreen", docs: { description: { component: "Interactive design review using application views with in-memory provider responses. No real authentication, tool calls, or credential persistence. Representative catalogs are examples; production discovery supplies the actual tools." } } }, diff --git a/ui/storybook/stories/ComposioConnection.stories.tsx b/ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx similarity index 94% rename from ui/storybook/stories/ComposioConnection.stories.tsx rename to ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx index fc5940e9e5c..1919775505b 100644 --- a/ui/storybook/stories/ComposioConnection.stories.tsx +++ b/ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx @@ -1,9 +1,10 @@ import { userEvent, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { RemoteMcpConnectionReview } from "../prototypes/RemoteMcpConnectionReview"; +import { RemoteMcpConnectionReview } from "../../prototypes/RemoteMcpConnectionReview"; const meta = { - title: "Apps/Connections/Composio", + id: "apps-connections-composio", + title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Composio", component: RemoteMcpConnectionReview, args: { provider: "composio", scenario: "journey" }, parameters: { layout: "fullscreen", docs: { description: { component: "Interactive design review using application views with in-memory provider responses. No real authentication, tool calls, or credential persistence. Representative catalogs are examples; production discovery supplies the actual tools." } } }, diff --git a/ui/storybook/stories/ConnectionProviderChoice.stories.tsx b/ui/storybook/stories/connection-setup/ConnectionProviderChoice.stories.tsx similarity index 96% rename from ui/storybook/stories/ConnectionProviderChoice.stories.tsx rename to ui/storybook/stories/connection-setup/ConnectionProviderChoice.stories.tsx index 911cc7d26fc..4257a9628b1 100644 --- a/ui/storybook/stories/ConnectionProviderChoice.stories.tsx +++ b/ui/storybook/stories/connection-setup/ConnectionProviderChoice.stories.tsx @@ -9,7 +9,7 @@ import { } from "@paperclipai/shared"; import { RemoteMcpAccountChoice } from "@/features/connections/remote-mcp/RemoteMcpAccountChoice"; import { QuestionForm } from "@/components/task-chat/QuestionForm"; -import { RemoteMcpConnectionReview } from "../prototypes/RemoteMcpConnectionReview"; +import { RemoteMcpConnectionReview } from "../../prototypes/RemoteMcpConnectionReview"; function ProviderChoice({ existing = false, @@ -109,7 +109,8 @@ function ProviderChoice({ ); } const meta = { - title: "Apps/Connections/Provider choice", + id: "apps-connections-provider-choice", + title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Provider choice", component: ProviderChoice, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/ExecutorConnection.stories.tsx b/ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx similarity index 93% rename from ui/storybook/stories/ExecutorConnection.stories.tsx rename to ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx index c4ed3881dc0..4390599ef1a 100644 --- a/ui/storybook/stories/ExecutorConnection.stories.tsx +++ b/ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx @@ -1,9 +1,10 @@ import { userEvent, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { RemoteMcpConnectionReview } from "../prototypes/RemoteMcpConnectionReview"; +import { RemoteMcpConnectionReview } from "../../prototypes/RemoteMcpConnectionReview"; const meta = { - title: "Apps/Connections/Executor", + id: "apps-connections-executor", + title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Executor", component: RemoteMcpConnectionReview, args: { provider: "executor", scenario: "journey" }, parameters: { layout: "fullscreen", docs: { description: { component: "Interactive design review using application views with in-memory provider responses. No real authentication, tool calls, or credential persistence. Representative catalogs are examples; production discovery supplies the actual tools." } } }, diff --git a/ui/storybook/stories/ZapierConnection.stories.tsx b/ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx similarity index 92% rename from ui/storybook/stories/ZapierConnection.stories.tsx rename to ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx index 3199dca11d7..bb2d43cb3d0 100644 --- a/ui/storybook/stories/ZapierConnection.stories.tsx +++ b/ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { RemoteMcpConnectionReview } from "../prototypes/RemoteMcpConnectionReview"; +import { RemoteMcpConnectionReview } from "../../prototypes/RemoteMcpConnectionReview"; const meta = { - title: "Apps/Connections/Zapier", + id: "apps-connections-zapier", + title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier", component: RemoteMcpConnectionReview, args: { provider: "zapier", scenario: "journey" }, parameters: { layout: "fullscreen", docs: { description: { component: "Interactive design review using application views with in-memory provider responses. No real authentication, tool calls, or credential persistence. Representative catalogs are examples; production discovery supplies the actual tools." } } }, diff --git a/ui/storybook/stories/agent-setup-prompt.stories.tsx b/ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx similarity index 97% rename from ui/storybook/stories/agent-setup-prompt.stories.tsx rename to ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx index 8ee95576a40..ac6538d07c6 100644 --- a/ui/storybook/stories/agent-setup-prompt.stories.tsx +++ b/ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx @@ -10,8 +10,8 @@ import { GitHubSetupPrompt } from "@/pages/apps/chat/GitHubSetupPrompt"; import { McpConfigHelpDialog } from "@/pages/tools/McpConfigHelpDialog"; import { AgentInstructions } from "@/components/routine-triggers/WebhookFields"; import { IssueContinuationHandoff } from "@/components/IssueContinuationHandoff"; -import { webhookAgentInstructions } from "../fixtures/routineTriggerWizard"; -import { storybookContinuationHandoff } from "../fixtures/paperclipData"; +import { webhookAgentInstructions } from "../../fixtures/routineTriggerWizard"; +import { storybookContinuationHandoff } from "../../fixtures/paperclipData"; const slackPrompt = buildSlackSetupPrompt(import.meta.env.VITE_PAPERCLIP_INSTANCE_URL ?? ""); const apiPrompt = `Help me connect my agent to the Paperclip API. @@ -23,7 +23,8 @@ Guide me through creating a dedicated agent API key. Store it securely in my age Configure the base URL and authentication, then verify the connection with a read-only request. Show me what worked and ask before making any changes to my company.`; const meta = { - title: "Components/Agent setup prompt", + id: "components-agent-setup-prompt", + title: "Apps, channels, and extensions/Connection setup/Components/Agent setup prompt", component: AgentSetupPrompt, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/apps-catalog-source-selection.stories.tsx b/ui/storybook/stories/connection-setup/apps-catalog-source-selection.stories.tsx similarity index 95% rename from ui/storybook/stories/apps-catalog-source-selection.stories.tsx rename to ui/storybook/stories/connection-setup/apps-catalog-source-selection.stories.tsx index a665276d96b..243d44d550d 100644 --- a/ui/storybook/stories/apps-catalog-source-selection.stories.tsx +++ b/ui/storybook/stories/connection-setup/apps-catalog-source-selection.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { ConnectorCatalogNavigation } from "../prototypes/ConnectorCatalogNavigation"; +import { ConnectorCatalogNavigation } from "../../prototypes/ConnectorCatalogNavigation"; const meta = { - title: "Apps/Catalog source selection", + id: "apps-catalog-source-selection", + title: "Apps, channels, and extensions/Connection setup/Apps/Catalog source selection", component: ConnectorCatalogNavigation, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/apps-connect-discoverability.stories.tsx b/ui/storybook/stories/connection-setup/apps-connect-discoverability.stories.tsx similarity index 97% rename from ui/storybook/stories/apps-connect-discoverability.stories.tsx rename to ui/storybook/stories/connection-setup/apps-connect-discoverability.stories.tsx index 6d8d96e1625..a0221b3cb15 100644 --- a/ui/storybook/stories/apps-connect-discoverability.stories.tsx +++ b/ui/storybook/stories/connection-setup/apps-connect-discoverability.stories.tsx @@ -58,7 +58,8 @@ function PasteConfigHost() { } const meta: Meta = { - title: "Apps/Connect discoverability (PAP-11091)", + id: "apps-connect-discoverability-pap-11091", + title: "Apps, channels, and extensions/Connection setup/Apps/Connect discoverability (PAP-11091)", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/apps-connect-name.stories.tsx b/ui/storybook/stories/connection-setup/apps-connect-name.stories.tsx similarity index 95% rename from ui/storybook/stories/apps-connect-name.stories.tsx rename to ui/storybook/stories/connection-setup/apps-connect-name.stories.tsx index c4511fd2a98..534ed375a32 100644 --- a/ui/storybook/stories/apps-connect-name.stories.tsx +++ b/ui/storybook/stories/connection-setup/apps-connect-name.stories.tsx @@ -82,7 +82,8 @@ function KeyStepHost({ customName }: { customName?: string }) { } const meta: Meta = { - title: "Apps/Connect name at create time (PAP-11283)", + id: "apps-connect-name-at-create-time-pap-11283", + title: "Apps, channels, and extensions/Connection setup/Apps/Connect name at create time (PAP-11283)", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/browser-use-settings.stories.tsx b/ui/storybook/stories/connection-setup/browser-use-settings.stories.tsx similarity index 92% rename from ui/storybook/stories/browser-use-settings.stories.tsx rename to ui/storybook/stories/connection-setup/browser-use-settings.stories.tsx index 6f1feba1f73..f8be4abe552 100644 --- a/ui/storybook/stories/browser-use-settings.stories.tsx +++ b/ui/storybook/stories/connection-setup/browser-use-settings.stories.tsx @@ -6,7 +6,7 @@ import type { ToolConnection, } from "@paperclipai/shared"; import { BrowserUseSettingsPanel } from "@/pages/apps/app-detail/BrowserUseSettingsPanel"; -import { BrowserStoryProviders, mockBrowserUse } from "../fixtures/browser-use"; +import { BrowserStoryProviders, mockBrowserUse } from "../../fixtures/browser-use"; const connection = { id: "browser-use-cloud-connection", companyId: "storybook-company", @@ -27,7 +27,8 @@ const grants = { grants: [grant("shared", "organization")], } as ConnectionGrantsResponse; const meta = { - title: "Connections/Browser Use Cloud/Settings", + id: "connections-browser-use-cloud-settings", + title: "Apps, channels, and extensions/Connection setup/Connections/Browser Use Cloud/Settings", component: BrowserUseSettingsPanel, parameters: { layout: "centered", diff --git a/ui/storybook/stories/connection-instructions.stories.tsx b/ui/storybook/stories/connection-setup/connection-instructions.stories.tsx similarity index 97% rename from ui/storybook/stories/connection-instructions.stories.tsx rename to ui/storybook/stories/connection-setup/connection-instructions.stories.tsx index ca63f1d6af7..77bb5d0c59c 100644 --- a/ui/storybook/stories/connection-instructions.stories.tsx +++ b/ui/storybook/stories/connection-setup/connection-instructions.stories.tsx @@ -13,7 +13,7 @@ import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow" import { AgentConnectionInstructions } from "@/features/connections/ConnectionInstructions"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { GUIDANCE_COMPANY, GUIDANCE_CONNECTION, guidanceAgents, installConnectionGuidanceFixtures } from "../fixtures/connectionInstructions"; +import { GUIDANCE_COMPANY, GUIDANCE_CONNECTION, guidanceAgents, installConnectionGuidanceFixtures } from "../../fixtures/connectionInstructions"; const HONCHO = getConnectableAppDefinition("honcho")!.agentInstructions!.text; const NOTION = "Use Notion as the source for our published product decisions. Look up the relevant decision before proposing a change, and link to the page you used. Ask before changing a published decision. If a page is unavailable, say which context is missing and continue with the information you have."; @@ -62,7 +62,8 @@ function ConnectionInstructionsPrototype({ initialView = "connection", provider } const meta = { - title: "Design explorations/Connections/Agent instructions", + id: "design-explorations-connections-agent-instructions", + title: "Apps, channels, and extensions/Connection setup/Design explorations/Connections/Agent instructions", component: ConnectionInstructionsPrototype, args: { providedInstructions: HONCHO }, parameters: { layout: "fullscreen", docs: { description: { component: "Production Layout, ConnectionSetupFlow, AppDetail, identity/access controls, and searchable Actions list with local API fixtures. The production instructions editor saves and reloads through mocked APIs. Baseline stories omit template metadata. No provider calls." } } }, diff --git a/ui/storybook/stories/enterpret-connect-flow.stories.tsx b/ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx similarity index 98% rename from ui/storybook/stories/enterpret-connect-flow.stories.tsx rename to ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx index d051444e9c3..b75fa183255 100644 --- a/ui/storybook/stories/enterpret-connect-flow.stories.tsx +++ b/ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx @@ -225,7 +225,8 @@ function MethodCopyHost() { } const meta: Meta = { - title: "Apps/Enterpret connector", + id: "apps-enterpret-connector", + title: "Apps, channels, and extensions/Connection setup/Apps/Enterpret connector", parameters: { layout: "fullscreen", docs: { diff --git a/ui/storybook/stories/fireflies-pr.stories.tsx b/ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx similarity index 98% rename from ui/storybook/stories/fireflies-pr.stories.tsx rename to ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx index 18878d8832f..c54e23d0f62 100644 --- a/ui/storybook/stories/fireflies-pr.stories.tsx +++ b/ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx @@ -13,7 +13,7 @@ import { TriggersSection } from "@/components/routine-sections/editable-sections import { RoutineDetailContext, type RoutineDetailContextValue } from "@/components/routine-sections/context"; import { Button } from "@/components/ui/button"; import { useNavigate } from "@/lib/router"; -import { WebhookReview, webhook, baseRoutine } from "../fixtures/routineWebhooks"; +import { WebhookReview, webhook, baseRoutine } from "../../fixtures/routineWebhooks"; const fireflies = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "fireflies")!; const demoSecret = "storybook-only-signing-secret"; @@ -23,7 +23,8 @@ function Frame({ children }: { children: ReactNode }) { return

PR #13890 · Production components with simulated data. No provider requests or real credentials.

{children}
; } const meta = { - title: "PR reviews/Fireflies and app webhooks", + id: "pr-reviews-fireflies-and-app-webhooks", + title: "Apps, channels, and extensions/Connection setup/PR reviews/Fireflies and app webhooks", parameters: { layout: "fullscreen", docs: { description: { component: "Coverage of PR #13890: Fireflies catalog/artwork and shared connection flow, preserved action permissions, generic webhook wizard and saved settings, and both advanced trigger editors. All network mutations are simulated. Use the toolbar to switch theme or viewport. Legacy stories only demonstrate resuming existing configurations; new webhooks use Another app or script." } } }, afterEach: ({ id }) => { document.body.dataset.firefliesStoryReady = id; }, beforeEach: () => { diff --git a/ui/storybook/stories/in-feed-connections.stories.tsx b/ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx similarity index 99% rename from ui/storybook/stories/in-feed-connections.stories.tsx rename to ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx index dbcff615386..ecd1ad3f3fd 100644 --- a/ui/storybook/stories/in-feed-connections.stories.tsx +++ b/ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx @@ -9,7 +9,7 @@ import { ConnectionSetupFlow, ConnectionSetupCompletionScreen, AccessStep, OAuth import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard"; -import { storybookAgentMap, storybookAgents } from "../fixtures/paperclipData"; +import { storybookAgentMap, storybookAgents } from "../../fixtures/paperclipData"; import { useNavigate } from "@/lib/router"; import { pendingConnectionIntentInteraction as pending, @@ -37,7 +37,8 @@ const connection = { type Scenario = { email?: boolean; access?: boolean; ai?: boolean; missingAiAccount?: "anthropic" | "openai"; ownerOnly?: boolean; checking?: boolean; count?: number; loading?: boolean; loadError?: boolean; completeError?: boolean; submitting?: boolean; denied?: boolean }; const meta: Meta = { - title: "Connections/In-task connections", + id: "connections-in-task-connections", + title: "Apps, channels, and extensions/Connection setup/Connections/In-task connections", parameters: { layout: "padded" }, afterEach: ({ id }) => { document.body.dataset.inFeedStoryReady = id; }, beforeEach: ({ parameters }) => { diff --git a/ui/storybook/stories/managed-cloud-oauth-handoff.stories.tsx b/ui/storybook/stories/connection-setup/managed-cloud-oauth-handoff.stories.tsx similarity index 87% rename from ui/storybook/stories/managed-cloud-oauth-handoff.stories.tsx rename to ui/storybook/stories/connection-setup/managed-cloud-oauth-handoff.stories.tsx index 110597cc6a5..3f42764f63b 100644 --- a/ui/storybook/stories/managed-cloud-oauth-handoff.stories.tsx +++ b/ui/storybook/stories/connection-setup/managed-cloud-oauth-handoff.stories.tsx @@ -2,7 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { ManagedOAuthHandoffState } from "@/pages/apps/PaperclipCloudOAuthHandoff"; const meta: Meta = { - title: "Apps/Managed Cloud OAuth handoff", + id: "apps-managed-cloud-oauth-handoff", + title: "Apps, channels, and extensions/Connection setup/Apps/Managed Cloud OAuth handoff", component: ManagedOAuthHandoffState, parameters: { layout: "fullscreen" }, args: { diff --git a/ui/storybook/stories/memory-connectors.stories.tsx b/ui/storybook/stories/connection-setup/memory-connectors.stories.tsx similarity index 98% rename from ui/storybook/stories/memory-connectors.stories.tsx rename to ui/storybook/stories/connection-setup/memory-connectors.stories.tsx index c5eae9e3018..8585a9711c7 100644 --- a/ui/storybook/stories/memory-connectors.stories.tsx +++ b/ui/storybook/stories/connection-setup/memory-connectors.stories.tsx @@ -91,7 +91,8 @@ function MemoryReview({ provider = "mem0", scenario = "catalog" }: { provider?:
; } -const meta = { title: "Apps/Connections/Memory", component: MemoryReview, parameters: { layout: "fullscreen" } } satisfies Meta; +const meta = { id: "apps-connections-memory", + title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Memory", component: MemoryReview, parameters: { layout: "fullscreen" } } satisfies Meta; export default meta; type Story = StoryObj; export const CatalogAndSetup: Story = { args: { scenario: "catalog" } }; diff --git a/ui/storybook/stories/notion-connect-flow.stories.tsx b/ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx similarity index 98% rename from ui/storybook/stories/notion-connect-flow.stories.tsx rename to ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx index 0ba848c88ac..458c2a6c49c 100644 --- a/ui/storybook/stories/notion-connect-flow.stories.tsx +++ b/ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx @@ -339,7 +339,8 @@ function VercelConnectProvenanceHost() { } const meta: Meta = { - title: "Apps/Notion MCP connect flow (PAP-16650)", + id: "apps-notion-mcp-connect-flow-pap-16650", + title: "Apps, channels, and extensions/Connection setup/Apps/Notion MCP connect flow (PAP-16650)", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/personal-connection-identity.stories.tsx b/ui/storybook/stories/connection-setup/personal-connection-identity.stories.tsx similarity index 98% rename from ui/storybook/stories/personal-connection-identity.stories.tsx rename to ui/storybook/stories/connection-setup/personal-connection-identity.stories.tsx index a2f771e637f..71619ea4e0c 100644 --- a/ui/storybook/stories/personal-connection-identity.stories.tsx +++ b/ui/storybook/stories/connection-setup/personal-connection-identity.stories.tsx @@ -152,7 +152,8 @@ function IdentitiesHarness({ } const meta: Meta = { - title: "Reviews/PAP-17835 Personal connection identity", + id: "reviews-pap-17835-personal-connection-identity", + title: "Apps, channels, and extensions/Connection setup/Reviews/PAP-17835 Personal connection identity", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/ux-labs.stories.tsx b/ui/storybook/stories/developer-labs/ux-labs.stories.tsx similarity index 94% rename from ui/storybook/stories/ux-labs.stories.tsx rename to ui/storybook/stories/developer-labs/ux-labs.stories.tsx index 1a6d0e85afb..f72ae0db93c 100644 --- a/ui/storybook/stories/ux-labs.stories.tsx +++ b/ui/storybook/stories/developer-labs/ux-labs.stories.tsx @@ -13,7 +13,8 @@ function StoryFrame({ children }: { children: React.ReactNode }) { } const meta = { - title: "UX Labs/Converted Test Pages", + id: "ux-labs-converted-test-pages", + title: "Contributor surfaces/Developer previews and diagnostic labs/UX Labs/Converted Test Pages", parameters: { docs: { description: { diff --git a/ui/storybook/stories/artifact-cards/commit.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/commit.stories.tsx similarity index 86% rename from ui/storybook/stories/artifact-cards/commit.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/commit.stories.tsx index 1927ac77f84..91c6e0cda64 100644 --- a/ui/storybook/stories/artifact-cards/commit.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/commit.stories.tsx @@ -6,10 +6,11 @@ import { identityControls, textData, diffControls, -} from "./storyConfig"; +} from "../../../support/artifact-cards/storyConfig"; const meta = { - title: "Explorations/Artifact cards/Commit", + id: "explorations-artifact-cards-commit", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Commit", component: CommitCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-cards/data.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/data.stories.tsx similarity index 85% rename from ui/storybook/stories/artifact-cards/data.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/data.stories.tsx index b203310bfe1..38842f75c12 100644 --- a/ui/storybook/stories/artifact-cards/data.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/data.stories.tsx @@ -6,10 +6,11 @@ import { identityControls, textData, objectData, -} from "./storyConfig"; +} from "../../../support/artifact-cards/storyConfig"; const meta = { - title: "Explorations/Artifact cards/Data", + id: "explorations-artifact-cards-data", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Data", component: DataCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-cards/document.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/document.stories.tsx similarity index 85% rename from ui/storybook/stories/artifact-cards/document.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/document.stories.tsx index 00fac027ea0..968afbd5e5b 100644 --- a/ui/storybook/stories/artifact-cards/document.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/document.stories.tsx @@ -6,10 +6,11 @@ import { identityControls, textData, numberData, -} from "./storyConfig"; +} from "../../../support/artifact-cards/storyConfig"; const meta = { - title: "Explorations/Artifact cards/Document", + id: "explorations-artifact-cards-document", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Document", component: DocumentCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-cards/file.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/file.stories.tsx similarity index 87% rename from ui/storybook/stories/artifact-cards/file.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/file.stories.tsx index bf9df2f7605..5183567678d 100644 --- a/ui/storybook/stories/artifact-cards/file.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/file.stories.tsx @@ -6,10 +6,11 @@ import { identityControls, textData, objectData, -} from "./storyConfig"; +} from "../../../support/artifact-cards/storyConfig"; const meta = { - title: "Explorations/Artifact cards/File", + id: "explorations-artifact-cards-file", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/File", component: FileCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-cards/image.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/image.stories.tsx similarity index 80% rename from ui/storybook/stories/artifact-cards/image.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/image.stories.tsx index e6f7c0b258e..33efe33b3c5 100644 --- a/ui/storybook/stories/artifact-cards/image.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/image.stories.tsx @@ -6,11 +6,12 @@ import { identityControls, textData, numberData, -} from "./storyConfig"; -import posterUrl from "../../fixtures/artifact-media/paper-trail.png?url"; +} from "../../../support/artifact-cards/storyConfig"; +import posterUrl from "../../../fixtures/artifact-media/paper-trail.png?url"; const meta = { - title: "Explorations/Artifact cards/Image", + id: "explorations-artifact-cards-image", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Image", component: ImageCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-cards/link-preview.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/link-preview.stories.tsx similarity index 82% rename from ui/storybook/stories/artifact-cards/link-preview.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/link-preview.stories.tsx index 7f230a559ca..baaa04c4994 100644 --- a/ui/storybook/stories/artifact-cards/link-preview.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/link-preview.stories.tsx @@ -5,10 +5,11 @@ import { parameters, identityControls, textData, -} from "./storyConfig"; +} from "../../../support/artifact-cards/storyConfig"; const meta = { - title: "Explorations/Artifact cards/Link preview", + id: "explorations-artifact-cards-link-preview", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Link preview", component: LinkPreviewCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-cards/pull-request.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/pull-request.stories.tsx similarity index 90% rename from ui/storybook/stories/artifact-cards/pull-request.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/pull-request.stories.tsx index e7cda58f736..f992cfc6f07 100644 --- a/ui/storybook/stories/artifact-cards/pull-request.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/pull-request.stories.tsx @@ -7,10 +7,11 @@ import { textData, numberData, diffControls, -} from "./storyConfig"; +} from "../../../support/artifact-cards/storyConfig"; const meta = { - title: "Explorations/Artifact cards/Pull request", + id: "explorations-artifact-cards-pull-request", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Pull request", component: PullRequestCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-cards/video.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/video.stories.tsx similarity index 72% rename from ui/storybook/stories/artifact-cards/video.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/video.stories.tsx index 8a1f9d3cf1d..902e01eb147 100644 --- a/ui/storybook/stories/artifact-cards/video.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/video.stories.tsx @@ -5,12 +5,13 @@ import { parameters, identityControls, textData, -} from "./storyConfig"; -import posterUrl from "../../fixtures/artifact-media/paper-trail.png?url"; -import videoUrl from "../../fixtures/artifact-media/paper-trail.mp4?url"; +} from "../../../support/artifact-cards/storyConfig"; +import posterUrl from "../../../fixtures/artifact-media/paper-trail.png?url"; +import videoUrl from "../../../fixtures/artifact-media/paper-trail.mp4?url"; const meta = { - title: "Explorations/Artifact cards/Video", + id: "explorations-artifact-cards-video", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Video", component: VideoCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-upload/01-components.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-upload/01-components.stories.tsx similarity index 86% rename from ui/storybook/stories/artifact-upload/01-components.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-upload/01-components.stories.tsx index 2e33e82d78b..7e6490d781c 100644 --- a/ui/storybook/stories/artifact-upload/01-components.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-upload/01-components.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { UploadPreview } from "./UploadPreview"; -import { showFileDrag } from "./fixtures"; +import { UploadPreview } from "../../../support/artifact-upload/UploadPreview"; +import { showFileDrag } from "../../../support/artifact-upload/fixtures"; const meta = { - title: "Artifact upload/01 Artifacts pane", component: UploadPreview, + id: "artifact-upload-01-artifacts-pane", + title: "Tasks and conversations/Documents, attachments, and work products/Artifact upload/01 Artifacts pane", component: UploadPreview, parameters: { layout: "fullscreen", docs: { description: { component: "The upload icon sits in the tab header without text or a background. The entire Artifacts panel, including its header, padding, existing cards and empty space, accepts files. Drag-over stories enter through the actual header drag handler and highlight the complete visible panel, including after scrolling. Use the upload icon for one or several files. Markdown becomes a document; other files use artifact cards. Uploads are simulated in memory and reset when the story reloads. The Uploading story holds its example pending; Retry completes the sample CSV upload. No task or execution state changes." }, story: { inline: false } } }, argTypes: { state: { control: "select", options: ["empty", "populated", "uploading", "error", "long"] } }, render: (args, context) =>
, diff --git a/ui/storybook/stories/artifact-upload/02-task-journey.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-upload/02-task-journey.stories.tsx similarity index 92% rename from ui/storybook/stories/artifact-upload/02-task-journey.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-upload/02-task-journey.stories.tsx index 6068e2d1848..6dd1cfa0233 100644 --- a/ui/storybook/stories/artifact-upload/02-task-journey.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-upload/02-task-journey.stories.tsx @@ -10,8 +10,8 @@ import { ArrowLeft, Box, X } from "lucide-react"; import { PluginLauncherProvider } from "@/plugins/launchers"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useSidebar } from "@/context/SidebarContext"; -import { UploadPreview } from "./UploadPreview"; -import { issue, showFileDrag, type PreviewState } from "./fixtures"; +import { UploadPreview } from "../../../support/artifact-upload/UploadPreview"; +import { issue, showFileDrag, type PreviewState } from "../../../support/artifact-upload/fixtures"; function TaskPage({ state = "empty", panelOpenInitially = false }: { state?: PreviewState; panelOpenInitially?: boolean }) { const { setBreadcrumbs } = useBreadcrumbs(); @@ -45,7 +45,8 @@ function Journey(props: { state?: PreviewState; panelOpenInitially?: boolean }) } const meta = { - title: "Artifact upload/02 Paused task journey", component: Journey, + id: "artifact-upload-02-paused-task-journey", + title: "Tasks and conversations/Documents, attachments, and work products/Artifact upload/02 Paused task journey", component: Journey, parameters: { layout: "fullscreen", initialEntries: ["/PAP/issues/PAP-214"], docs: { description: { component: "Start on the paused, unassigned PAP-214 placeholder in the production app shell. Upload or drop files in Artifacts, open a document in another tab, then return to Artifacts. On mobile, open Artifacts from the task header. Uses production chat bubbles, paused takeover, panel frame, desktop/mobile tabs, artifact list and document viewer. The story composes these components for review; upload persistence is simulated. No live issue is modified or resumed." }, story: { inline: false } } }, render: (args, context) => , } satisfies Meta; diff --git a/ui/storybook/stories/artifacts.stories.tsx b/ui/storybook/stories/documents-artifacts/artifacts.stories.tsx similarity index 99% rename from ui/storybook/stories/artifacts.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifacts.stories.tsx index 10a8723c525..ea0cbb85d83 100644 --- a/ui/storybook/stories/artifacts.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifacts.stories.tsx @@ -475,7 +475,8 @@ const SELECTED_GROUP_ARTIFACTS: CompanyArtifact[] = [ ]; const meta: Meta = { - title: "Pages/Artifacts", + id: "pages-artifacts", + title: "Tasks and conversations/Documents, attachments, and work products/Pages/Artifacts", }; export default meta; diff --git a/ui/storybook/stories/csv-preview.stories.tsx b/ui/storybook/stories/documents-artifacts/csv-preview.stories.tsx similarity index 95% rename from ui/storybook/stories/csv-preview.stories.tsx rename to ui/storybook/stories/documents-artifacts/csv-preview.stories.tsx index 40dc7e2b302..2c275cef749 100644 --- a/ui/storybook/stories/csv-preview.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/csv-preview.stories.tsx @@ -6,7 +6,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { TextAttachmentPreview } from "@/components/task-side-panel/TaskAttachmentPanel"; const meta: Meta = { - title: "Components/CSV File Tab", + id: "components-csv-file-tab", + title: "Tasks and conversations/Documents, attachments, and work products/Components/CSV File Tab", component: TextAttachmentPreview, parameters: { layout: "fullscreen" }, decorators: [(Story) =>
], diff --git a/ui/storybook/stories/document-annotations.stories.tsx b/ui/storybook/stories/documents-artifacts/document-annotations.stories.tsx similarity index 99% rename from ui/storybook/stories/document-annotations.stories.tsx rename to ui/storybook/stories/documents-artifacts/document-annotations.stories.tsx index 63a18f0c6e7..deae38c99ba 100644 --- a/ui/storybook/stories/document-annotations.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/document-annotations.stories.tsx @@ -504,7 +504,8 @@ function StatesShowcase({ focusedThreadId = "open-1" }: { focusedThreadId?: stri } const meta = { - title: "Product/Documents/Annotations", + id: "product-documents-annotations", + title: "Tasks and conversations/Documents, attachments, and work products/Product/Documents/Annotations", component: StatesShowcase, parameters: { docs: { diff --git a/ui/storybook/stories/document-comments.stories.tsx b/ui/storybook/stories/documents-artifacts/document-comments.stories.tsx similarity index 97% rename from ui/storybook/stories/document-comments.stories.tsx rename to ui/storybook/stories/documents-artifacts/document-comments.stories.tsx index 0e03550df28..befef1c6b10 100644 --- a/ui/storybook/stories/document-comments.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/document-comments.stories.tsx @@ -169,7 +169,8 @@ function DocumentCommentsMatrix() { } const meta = { - title: "Product/Document Comments", + id: "product-document-comments", + title: "Tasks and conversations/Documents, attachments, and work products/Product/Document Comments", component: DocumentCommentsMatrix, parameters: { docs: { diff --git a/ui/storybook/stories/file-viewer.stories.tsx b/ui/storybook/stories/documents-artifacts/file-viewer.stories.tsx similarity index 98% rename from ui/storybook/stories/file-viewer.stories.tsx rename to ui/storybook/stories/documents-artifacts/file-viewer.stories.tsx index aa3ddaa437f..cbed714bd0e 100644 --- a/ui/storybook/stories/file-viewer.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/file-viewer.stories.tsx @@ -212,7 +212,8 @@ function buildFetchStub( } const meta: Meta = { - title: "Components/File Viewer Sheet", + id: "components-file-viewer-sheet", + title: "Tasks and conversations/Documents, attachments, and work products/Components/File Viewer Sheet", component: FileViewerSheet, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/html-artifacts/01-preview.stories.tsx b/ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx similarity index 87% rename from ui/storybook/stories/html-artifacts/01-preview.stories.tsx rename to ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx index 953713b37ae..9059baf18ce 100644 --- a/ui/storybook/stories/html-artifacts/01-preview.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { TextAttachmentPreview } from "@/components/task-side-panel/TaskAttachmentPanel"; -import { reportHtml, securityHtml } from "./fixtures"; +import { reportHtml, securityHtml } from "../../../support/html-artifacts/fixtures"; function Preview({ html = reportHtml, title = "repository-usage.html", width = 800 }: { html?: string; title?: string; width?: number }) { return
@@ -11,7 +11,8 @@ function Preview({ html = reportHtml, title = "repository-usage.html", width = 8 } const meta = { - title: "HTML artifacts/01 Preview", + id: "html-artifacts-01-preview", + title: "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/01 Preview", component: Preview, args: { html: reportHtml, title: "repository-usage.html", width: 800 }, parameters: { layout: "fullscreen", docs: { description: { component: "Production attachment viewer. Inline CSS and JavaScript run in an opaque-origin sandbox. Remote dependencies and API requests are blocked. The original file is available through Download or Raw." }, story: { inline: false } } }, diff --git a/ui/storybook/stories/html-artifacts/02-workspace.stories.tsx b/ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx similarity index 84% rename from ui/storybook/stories/html-artifacts/02-workspace.stories.tsx rename to ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx index eedb1f17d0d..71e58e8b143 100644 --- a/ui/storybook/stories/html-artifacts/02-workspace.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx @@ -6,7 +6,7 @@ import { TaskWorkspaceFilePanel } from "@/components/task-side-panel/TaskWorkspa import { FileViewerSheet } from "@/components/FileViewerSheet"; import { FileViewerProvider } from "@/context/FileViewerContext"; import { queryKeys } from "@/lib/queryKeys"; -import { reportHtml } from "./fixtures"; +import { reportHtml } from "../../../support/html-artifacts/fixtures"; const issueId = "html-workspace-story"; const state = { path: "reports/repository-usage.html", workspace: "project" as const, workspaceId: null, projectId: null, line: null, column: null }; @@ -32,7 +32,8 @@ function Workspace({ sheet = false }: { sheet?: boolean }) {
; } -const meta = { title: "HTML artifacts/02 Workspace", component: Workspace, render: (args, context) => , parameters: { layout: "fullscreen", docs: { story: { inline: false } } } } satisfies Meta; +const meta = { id: "html-artifacts-02-workspace", + title: "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/02 Workspace", component: Workspace, render: (args, context) => , parameters: { layout: "fullscreen", docs: { story: { inline: false } } } } satisfies Meta; export default meta; type Story = StoryObj; export const TaskPanel: Story = {}; diff --git a/ui/storybook/stories/html-artifacts/03-task-journey.stories.tsx b/ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx similarity index 93% rename from ui/storybook/stories/html-artifacts/03-task-journey.stories.tsx rename to ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx index 80458df3e00..6b9df5170fb 100644 --- a/ui/storybook/stories/html-artifacts/03-task-journey.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx @@ -10,9 +10,9 @@ import { IssueArtifactFile } from "@/components/artifacts/IssueArtifactCard"; import { TextAttachmentContext } from "@/context/TextAttachmentContext"; import { PluginLauncherProvider } from "@/plugins/launchers"; import { queryKeys } from "@/lib/queryKeys"; -import { createIssue, storybookAgents } from "../../fixtures/paperclipData"; -import { reportHtml } from "./fixtures"; -import reportDownload from "./report.html?url"; +import { createIssue, storybookAgents } from "../../../fixtures/paperclipData"; +import { reportHtml } from "../../../support/html-artifacts/fixtures"; +import reportDownload from "../../../support/html-artifacts/report.html?url"; const issue = createIssue({ id: "html-artifact-journey", identifier: "DEMO-101", title: "Find popular repositories that use Paperclip a lot", status: "done", completedAt: new Date("2026-10-07T12:54:00Z"), currentExecutionWorkspace: null, executionWorkspaceId: null }); const attachment: IssueAttachment = { @@ -63,7 +63,8 @@ function Journey(props: { openInitially?: boolean }) { ; } const meta = { - title: "HTML artifacts/03 Task journey", component: Journey, + id: "html-artifacts-03-task-journey", + title: "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/03 Task journey", component: Journey, parameters: { layout: "fullscreen", initialEntries: ["/PAP/issues/html-artifact-journey"], docs: { description: { component: "Open the HTML artifact from the task's report card or Artifacts tab, filter the report, then switch to Raw beside Download. Uses the production app shell, artifact card, task panel and HTML renderer with simulated report data." }, story: { inline: false } } }, beforeEach: () => { const originalFetch = window.fetch; diff --git a/ui/storybook/stories/rich-work-product-cards.stories.tsx b/ui/storybook/stories/documents-artifacts/rich-work-product-cards.stories.tsx similarity index 96% rename from ui/storybook/stories/rich-work-product-cards.stories.tsx rename to ui/storybook/stories/documents-artifacts/rich-work-product-cards.stories.tsx index a85c39c499e..5127de63abc 100644 --- a/ui/storybook/stories/rich-work-product-cards.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/rich-work-product-cards.stories.tsx @@ -1,12 +1,13 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { IssueAttachment, IssueWorkProduct } from "@paperclipai/shared"; import { expect } from "storybook/test"; -import { RichWorkProductCard } from "../../src/components/task-chat/RichWorkProductCard"; -import { TaskChatBubble } from "../../src/components/task-chat/TaskChatBubble"; -import type { TaskChatMessageItem } from "../../src/components/task-chat/task-chat-model"; +import { RichWorkProductCard } from "../../../src/components/task-chat/RichWorkProductCard"; +import { TaskChatBubble } from "../../../src/components/task-chat/TaskChatBubble"; +import type { TaskChatMessageItem } from "../../../src/components/task-chat/task-chat-model"; const meta = { - title: "Task Chat/Rich Work Product Cards", + id: "task-chat-rich-work-product-cards", + title: "Tasks and conversations/Documents, attachments, and work products/Task Chat/Rich Work Product Cards", component: RichWorkProductCard, parameters: { layout: "centered" }, } satisfies Meta; diff --git a/ui/storybook/stories/task-artifact-gallery.stories.tsx b/ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx similarity index 92% rename from ui/storybook/stories/task-artifact-gallery.stories.tsx rename to ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx index 79a021486e2..f2a2cbcee71 100644 --- a/ui/storybook/stories/task-artifact-gallery.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx @@ -10,14 +10,14 @@ import { queryKeys } from "@/lib/queryKeys"; import { isImageLikeOutput, isVideoLikeOutput } from "@/lib/issue-output"; import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble"; import { workProductHref } from "@/lib/issue-artifacts"; -import { createIssue, storybookAgents } from "../fixtures/paperclipData"; -import manila from "../fixtures/artifact-media/manila-ledger.mp4?url"; -import night from "../fixtures/artifact-media/night-pills.mp4?url"; -import tickets from "../fixtures/artifact-media/ticket-board.mp4?url"; -import type from "../fixtures/artifact-media/big-type.mp4?url"; -import trail from "../fixtures/artifact-media/paper-trail.mp4?url"; -import manilaImage from "../fixtures/artifact-media/manila-ledger.png?url"; -import nightImage from "../fixtures/artifact-media/night-pills.png?url"; +import { createIssue, storybookAgents } from "../../fixtures/paperclipData"; +import manila from "../../fixtures/artifact-media/manila-ledger.mp4?url"; +import night from "../../fixtures/artifact-media/night-pills.mp4?url"; +import tickets from "../../fixtures/artifact-media/ticket-board.mp4?url"; +import type from "../../fixtures/artifact-media/big-type.mp4?url"; +import trail from "../../fixtures/artifact-media/paper-trail.mp4?url"; +import manilaImage from "../../fixtures/artifact-media/manila-ledger.png?url"; +import nightImage from "../../fixtures/artifact-media/night-pills.png?url"; const issue = createIssue({ id: "artifact-gallery-story", identifier: "DEMO-101", title: "Explore Paperclip Ships styles", status: "done" }); const date = new Date("2026-09-22T18:03:00Z"); @@ -93,7 +93,8 @@ function GalleryStory({ scenario = "videos", width = 480 }: { scenario?: Scenari ); } const meta = { - title: "Tasks/Artifact Gallery", component: GalleryStory, parameters: { layout: "fullscreen" }, + id: "tasks-artifact-gallery", + title: "Tasks and conversations/Documents, attachments, and work products/Tasks/Artifact Gallery", component: GalleryStory, parameters: { layout: "fullscreen" }, args: { scenario: "videos", width: 480 }, beforeEach: ({ args }) => { const originalFetch = window.fetch; diff --git a/ui/storybook/stories/task-text-files.stories.tsx b/ui/storybook/stories/documents-artifacts/task-text-files.stories.tsx similarity index 91% rename from ui/storybook/stories/task-text-files.stories.tsx rename to ui/storybook/stories/documents-artifacts/task-text-files.stories.tsx index e1db636b869..7e0c73ec666 100644 --- a/ui/storybook/stories/task-text-files.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/task-text-files.stories.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { IssueAttachment } from "@paperclipai/shared"; import { TaskSidePanel } from "@/components/task-side-panel/TaskSidePanel"; import { queryKeys } from "@/lib/queryKeys"; -import { createIssue } from "../fixtures/paperclipData"; +import { createIssue } from "../../fixtures/paperclipData"; const markdown = "# Account review charter\n\nInvestigate whether new and active accounts succeeded.\n\n## Rules\n\n- Read customer data only.\n- Keep private information out of reports.\n- Report failures with evidence.\n\n```text\nReview → Evidence → Report\n```"; const plain = "Daily review summary\n\nAccounts reviewed: 20\nCompleted successfully: 18\nNeeds investigation: 2\n\n"; @@ -40,7 +40,8 @@ function TextFilesStory({ initial = "list", width = 640 }: { initial?: "list" | ; } -const meta = { title: "Tasks/Text file tabs", component: TextFilesStory, parameters: { layout: "fullscreen" }, render: (args, context) => } satisfies Meta; +const meta = { id: "tasks-text-file-tabs", + title: "Tasks and conversations/Documents, attachments, and work products/Tasks/Text file tabs", component: TextFilesStory, parameters: { layout: "fullscreen" }, render: (args, context) => } satisfies Meta; export default meta; type Story = StoryObj; export const OpenFromArtifacts: Story = {}; diff --git a/ui/storybook/stories/forms-editors.stories.tsx b/ui/storybook/stories/foundations/forms-editors.stories.tsx similarity index 99% rename from ui/storybook/stories/forms-editors.stories.tsx rename to ui/storybook/stories/foundations/forms-editors.stories.tsx index 7f1a723acda..e418820fd13 100644 --- a/ui/storybook/stories/forms-editors.stories.tsx +++ b/ui/storybook/stories/foundations/forms-editors.stories.tsx @@ -20,7 +20,7 @@ import { ScheduleEditor, describeSchedule } from "@/components/ScheduleEditor"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { buildExecutionPolicy } from "@/lib/issue-execution-policy"; -import { createIssue, storybookAgents } from "../fixtures/paperclipData"; +import { createIssue, storybookAgents } from "../../fixtures/paperclipData"; function Section({ eyebrow, @@ -712,7 +712,8 @@ function RoutineRunDialogStory() { } const meta = { - title: "Components/Forms & Editors", + id: "components-forms-editors", + title: "Foundations/Components/Forms & Editors", parameters: { docs: { description: { diff --git a/ui/storybook/stories/foundations.stories.tsx b/ui/storybook/stories/foundations/foundations.stories.tsx similarity index 99% rename from ui/storybook/stories/foundations.stories.tsx rename to ui/storybook/stories/foundations/foundations.stories.tsx index 94c1a6874e4..86d1c6eb810 100644 --- a/ui/storybook/stories/foundations.stories.tsx +++ b/ui/storybook/stories/foundations/foundations.stories.tsx @@ -281,6 +281,7 @@ function FoundationsMatrix() { } const meta = { + id: "foundations-primitive-matrix", title: "Foundations/Primitive Matrix", component: FoundationsMatrix, parameters: { diff --git a/ui/storybook/stories/primitives-coverage.stories.tsx b/ui/storybook/stories/foundations/primitives-coverage.stories.tsx similarity index 99% rename from ui/storybook/stories/primitives-coverage.stories.tsx rename to ui/storybook/stories/foundations/primitives-coverage.stories.tsx index 164f97f9361..98d99b099ad 100644 --- a/ui/storybook/stories/primitives-coverage.stories.tsx +++ b/ui/storybook/stories/foundations/primitives-coverage.stories.tsx @@ -69,6 +69,7 @@ import { Skeleton } from "@/components/ui/skeleton"; * static, deterministic state so the visual snapshot suite baselines it. */ const meta = { + id: "foundations-primitive-coverage", title: "Foundations/Primitive Coverage", parameters: { docs: { diff --git a/ui/storybook/stories/status-language.stories.tsx b/ui/storybook/stories/foundations/status-language.stories.tsx similarity index 99% rename from ui/storybook/stories/status-language.stories.tsx rename to ui/storybook/stories/foundations/status-language.stories.tsx index b39ab4b99c5..82d356b9fb8 100644 --- a/ui/storybook/stories/status-language.stories.tsx +++ b/ui/storybook/stories/foundations/status-language.stories.tsx @@ -17,7 +17,7 @@ import { QuotaBar } from "@/components/QuotaBar"; import { StatusBadge } from "@/components/StatusBadge"; import { StatusIcon } from "@/components/StatusIcon"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { createIssue } from "../fixtures/paperclipData"; +import { createIssue } from "../../fixtures/paperclipData"; function Section({ eyebrow, @@ -589,6 +589,7 @@ function StatusLanguage() { } const meta = { + id: "foundations-status-language", title: "Foundations/Status Language", component: StatusLanguage, parameters: { diff --git a/ui/storybook/stories/access-profiles.stories.tsx b/ui/storybook/stories/gateways-profiles/access-profiles.stories.tsx similarity index 99% rename from ui/storybook/stories/access-profiles.stories.tsx rename to ui/storybook/stories/gateways-profiles/access-profiles.stories.tsx index 4ad365bdd65..c236a5bfae0 100644 --- a/ui/storybook/stories/access-profiles.stories.tsx +++ b/ui/storybook/stories/gateways-profiles/access-profiles.stories.tsx @@ -474,7 +474,8 @@ function SeededAgentTools() { } const meta: Meta = { - title: "Tools/Access profiles", + id: "tools-access-profiles", + title: "Apps, channels, and extensions/Tool gateways and access profiles/Tools/Access profiles", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/gateways-tab.stories.tsx b/ui/storybook/stories/gateways-profiles/gateways-tab.stories.tsx similarity index 98% rename from ui/storybook/stories/gateways-tab.stories.tsx rename to ui/storybook/stories/gateways-profiles/gateways-tab.stories.tsx index 1a24d272db1..1eac81d5aa1 100644 --- a/ui/storybook/stories/gateways-tab.stories.tsx +++ b/ui/storybook/stories/gateways-profiles/gateways-tab.stories.tsx @@ -242,7 +242,8 @@ function GatewaysHost({ gateways }: { gateways: ToolMcpGatewayWithTokens[] }) { } const meta: Meta = { - title: "Tools/Gateways (PAP-11182)", + id: "tools-gateways-pap-11182", + title: "Apps, channels, and extensions/Tool gateways and access profiles/Tools/Gateways (PAP-11182)", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/public-mcp-connections.stories.tsx b/ui/storybook/stories/gateways-profiles/public-mcp-connections.stories.tsx similarity index 90% rename from ui/storybook/stories/public-mcp-connections.stories.tsx rename to ui/storybook/stories/gateways-profiles/public-mcp-connections.stories.tsx index fd6623e7599..0f45100710d 100644 --- a/ui/storybook/stories/public-mcp-connections.stories.tsx +++ b/ui/storybook/stories/gateways-profiles/public-mcp-connections.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, waitFor, within } from "storybook/test"; import { AssistantConnectionsPage } from "@/pages/McpConnect"; -import { installPublicMcpFixture } from "../fixtures/publicMcp"; +import { installPublicMcpFixture } from "../../fixtures/publicMcp"; const meta = { - title: "Assistant connections/Manage connections", component: AssistantConnectionsPage, + id: "assistant-connections-manage-connections", + title: "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Manage connections", component: AssistantConnectionsPage, parameters: { layout: "padded", initialEntries: ["/assistant-connections"], docs: { description: { component: "Production /assistant-connections page. Revocation changes only the in-memory story fixture." } } }, beforeEach: ({ parameters }) => installPublicMcpFixture(parameters.fixture), } satisfies Meta; diff --git a/ui/storybook/stories/public-mcp-consent.stories.tsx b/ui/storybook/stories/gateways-profiles/public-mcp-consent.stories.tsx similarity index 98% rename from ui/storybook/stories/public-mcp-consent.stories.tsx rename to ui/storybook/stories/gateways-profiles/public-mcp-consent.stories.tsx index 5a305fbfac0..543046702af 100644 --- a/ui/storybook/stories/public-mcp-consent.stories.tsx +++ b/ui/storybook/stories/gateways-profiles/public-mcp-consent.stories.tsx @@ -2,10 +2,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; import { Route, Routes } from "@/lib/router"; import { McpConnectPage, AssistantConnectionsPage } from "@/pages/McpConnect"; -import { consentSubmission, installPublicMcpFixture, request } from "../fixtures/publicMcp"; +import { consentSubmission, installPublicMcpFixture, request } from "../../fixtures/publicMcp"; const meta = { - title: "Assistant connections/Consent", + id: "assistant-connections-consent", + title: "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Consent", component: McpConnectPage, parameters: { layout: "padded", initialEntries: [`/mcp-connect/${request.id}`], docs: { description: { component: "Production consent page at /mcp-connect/:id. API fixtures are isolated. Successful consent stays in this preview; no OAuth credentials are issued." } } }, beforeEach: ({ parameters }) => installPublicMcpFixture(parameters.fixture), diff --git a/ui/storybook/stories/public-mcp-device.stories.tsx b/ui/storybook/stories/gateways-profiles/public-mcp-device.stories.tsx similarity index 88% rename from ui/storybook/stories/public-mcp-device.stories.tsx rename to ui/storybook/stories/gateways-profiles/public-mcp-device.stories.tsx index 93b92b0891c..758bf5e7d3c 100644 --- a/ui/storybook/stories/public-mcp-device.stories.tsx +++ b/ui/storybook/stories/gateways-profiles/public-mcp-device.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; import { McpDevicePage } from "@/pages/McpConnect"; -import { installPublicMcpFixture } from "../fixtures/publicMcp"; +import { installPublicMcpFixture } from "../../fixtures/publicMcp"; const meta = { - title: "Assistant connections/Device authorization", component: McpDevicePage, + id: "assistant-connections-device-authorization", + title: "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Device authorization", component: McpDevicePage, parameters: { layout: "padded", fixture: {}, docs: { description: { component: "Production device consent. The assistant's private device code stays in its credential client; only the human verification code appears here. These isolated fixtures issue no credentials." } } }, args: { initialCode: "BCDF-GHJK" }, beforeEach: ({ parameters }) => installPublicMcpFixture(parameters.fixture), diff --git a/ui/storybook/stories/public-mcp-journey.stories.tsx b/ui/storybook/stories/gateways-profiles/public-mcp-journey.stories.tsx similarity index 94% rename from ui/storybook/stories/public-mcp-journey.stories.tsx rename to ui/storybook/stories/gateways-profiles/public-mcp-journey.stories.tsx index a4c43830ffb..9b4a481e1ad 100644 --- a/ui/storybook/stories/public-mcp-journey.stories.tsx +++ b/ui/storybook/stories/gateways-profiles/public-mcp-journey.stories.tsx @@ -6,8 +6,8 @@ import { Link, Route, Routes } from "@/lib/router"; import { Browse } from "@/pages/apps/Browse"; import { AssistantConnection } from "@/pages/apps/AssistantConnection"; import { InstanceExperimentalSettings } from "@/pages/InstanceExperimentalSettings"; -import { PublicMcpPresenter } from "./public-mcp-presenter"; -import { connectedUser, installPublicMcpFixture } from "../fixtures/publicMcp"; +import { PublicMcpPresenter } from "../../support/public-mcp-presenter"; +import { connectedUser, installPublicMcpFixture } from "../../fixtures/publicMcp"; function ConnectionJourney({ assistant = "opencode" }: { assistant?: "codex" | "claude" | "opencode" | "other" }) { return @@ -21,7 +21,8 @@ function ConnectionJourney({ assistant = "opencode" }: { assistant?: "codex" | " } const meta = { - title: "Assistant connections/Start here", + id: "assistant-connections-start-here", + title: "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Start here", component: ConnectionJourney, parameters: { layout: "fullscreen", initialEntries: ["/PAP/apps"], fixture: { enabled: false, empty: true }, diff --git a/ui/storybook/stories/public-mcp-setting.stories.tsx b/ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx similarity index 90% rename from ui/storybook/stories/public-mcp-setting.stories.tsx rename to ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx index a6cb3716cc1..b0da6f0b013 100644 --- a/ui/storybook/stories/public-mcp-setting.stories.tsx +++ b/ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within, waitFor } from "storybook/test"; import { InstanceExperimentalSettings } from "@/pages/InstanceExperimentalSettings"; -import { installPublicMcpFixture } from "../fixtures/publicMcp"; +import { installPublicMcpFixture } from "../../fixtures/publicMcp"; const meta = { - title: "Assistant connections/Experimental setting", component: InstanceExperimentalSettings, + id: "assistant-connections-experimental-setting", + title: "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Experimental setting", component: InstanceExperimentalSettings, parameters: { layout: "padded", initialEntries: ["/PAP/company/settings/instance/experimental"], docs: { description: { component: "The production Experimental page, including surrounding settings. Changes are local to this story." } } }, beforeEach: ({ parameters }) => installPublicMcpFixture(parameters.fixture), } satisfies Meta; diff --git a/ui/storybook/stories/blocked-inbox.stories.tsx b/ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx similarity index 98% rename from ui/storybook/stories/blocked-inbox.stories.tsx rename to ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx index b4a5841cd2d..727d3ce83a7 100644 --- a/ui/storybook/stories/blocked-inbox.stories.tsx +++ b/ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx @@ -6,7 +6,7 @@ import { BlockedInboxView } from "@/components/BlockedInboxView"; import { BlockedReasonChip } from "@/components/BlockedReasonChip"; import { defaultIssueFilterState } from "@/lib/issue-filters"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookIssues } from "../fixtures/paperclipData"; +import { storybookIssues } from "../../fixtures/paperclipData"; const companyId = "company-storybook"; const blockedViewDefaults = { @@ -267,7 +267,8 @@ function BlockedTabEmptyState() { } const meta = { - title: "Product/Inbox/Blocked tab", + id: "product-inbox-blocked-tab", + title: "Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab", component: BlockedTabSurface, parameters: { docs: { diff --git a/ui/storybook/stories/decision-card.stories.tsx b/ui/storybook/stories/inbox-search/decision-card.stories.tsx similarity index 98% rename from ui/storybook/stories/decision-card.stories.tsx rename to ui/storybook/stories/inbox-search/decision-card.stories.tsx index 6e051ad72d5..fa96bd89ed3 100644 --- a/ui/storybook/stories/decision-card.stories.tsx +++ b/ui/storybook/stories/inbox-search/decision-card.stories.tsx @@ -91,7 +91,8 @@ const shared = { }; const meta: Meta = { - title: "Decisions/DecisionCard", + id: "decisions-decisioncard", + title: "Tasks and conversations/Inbox, decisions, and search/Decisions/DecisionCard", component: DecisionCard, render: (args) => (
diff --git a/ui/storybook/stories/decisions-desk.stories.tsx b/ui/storybook/stories/inbox-search/decisions-desk.stories.tsx similarity index 99% rename from ui/storybook/stories/decisions-desk.stories.tsx rename to ui/storybook/stories/inbox-search/decisions-desk.stories.tsx index a2faf41e8f2..8f8645dd134 100644 --- a/ui/storybook/stories/decisions-desk.stories.tsx +++ b/ui/storybook/stories/inbox-search/decisions-desk.stories.tsx @@ -444,7 +444,8 @@ const INTERACTION_AUDIENCE_ITEMS: AttentionItem[] = [ ]; const meta: Meta = { - title: "Pages/Decisions Desk", + id: "pages-decisions-desk", + title: "Tasks and conversations/Inbox, decisions, and search/Pages/Decisions Desk", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/search.stories.tsx b/ui/storybook/stories/inbox-search/search.stories.tsx similarity index 99% rename from ui/storybook/stories/search.stories.tsx rename to ui/storybook/stories/inbox-search/search.stories.tsx index 4a3291df9a5..0d261a95416 100644 --- a/ui/storybook/stories/search.stories.tsx +++ b/ui/storybook/stories/inbox-search/search.stories.tsx @@ -39,7 +39,7 @@ import { CommandSeparator, } from "@/components/ui/command"; import { StatusBadge } from "@/components/StatusBadge"; -import { storybookAgents, storybookProjects, storybookIssues } from "../fixtures/paperclipData"; +import { storybookAgents, storybookProjects, storybookIssues } from "../../fixtures/paperclipData"; const agentsById = new Map(storybookAgents.map((agent) => [agent.id, agent])); @@ -716,7 +716,8 @@ function SearchStories() { } const meta = { - title: "Product/Search & Command K", + id: "product-search-command-k", + title: "Tasks and conversations/Inbox, decisions, and search/Product/Search & Command K", component: SearchStories, parameters: { docs: { diff --git a/ui/storybook/stories/what-needs-me.stories.tsx b/ui/storybook/stories/inbox-search/what-needs-me.stories.tsx similarity index 99% rename from ui/storybook/stories/what-needs-me.stories.tsx rename to ui/storybook/stories/inbox-search/what-needs-me.stories.tsx index fca76fdc23d..292e363df0c 100644 --- a/ui/storybook/stories/what-needs-me.stories.tsx +++ b/ui/storybook/stories/inbox-search/what-needs-me.stories.tsx @@ -488,7 +488,8 @@ function Queue({ } const meta: Meta = { - title: "Pages/Decisions", + id: "pages-decisions", + title: "Tasks and conversations/Inbox, decisions, and search/Pages/Decisions", component: Queue, parameters: { layout: "fullscreen" }, }; diff --git a/ui/storybook/stories/dev-ops-surfaces.stories.tsx b/ui/storybook/stories/instance-operations/dev-ops-surfaces.stories.tsx similarity index 95% rename from ui/storybook/stories/dev-ops-surfaces.stories.tsx rename to ui/storybook/stories/instance-operations/dev-ops-surfaces.stories.tsx index 6690392d15d..2bf35725d25 100644 --- a/ui/storybook/stories/dev-ops-surfaces.stories.tsx +++ b/ui/storybook/stories/instance-operations/dev-ops-surfaces.stories.tsx @@ -54,7 +54,8 @@ function DevOpsSurfacesStory() { } const meta = { - title: "Product/Dev Ops Surfaces", + id: "product-dev-ops-surfaces", + title: "Oversight and operation/Instance operations/Product/Dev Ops Surfaces", component: DevOpsSurfacesStory, parameters: { docs: { diff --git a/ui/storybook/stories/announcement-card.stories.tsx b/ui/storybook/stories/navigation-preferences/announcement-card.stories.tsx similarity index 93% rename from ui/storybook/stories/announcement-card.stories.tsx rename to ui/storybook/stories/navigation-preferences/announcement-card.stories.tsx index b684e28107d..5c0a53dba44 100644 --- a/ui/storybook/stories/announcement-card.stories.tsx +++ b/ui/storybook/stories/navigation-preferences/announcement-card.stories.tsx @@ -3,7 +3,8 @@ import { AnnouncementCard } from "@/components/AnnouncementCard"; import { announcementPreview, announcementAnimationPreview, announcementAnimationPreviewSrc } from "@/lib/announcement-preview"; const meta = { - title: "Announcements/AnnouncementCard", + id: "announcements-announcementcard", + title: "Oversight and operation/Navigation, profile, and announcements/Announcements/AnnouncementCard", component: AnnouncementCard, args: { announcement: announcementPreview, imageSrc: "/announcement-preview.svg", onDismiss: () => {} }, decorators: [(Story) =>
], diff --git a/ui/storybook/stories/mobile-task-tabs.stories.tsx b/ui/storybook/stories/navigation-preferences/mobile-task-tabs.stories.tsx similarity index 97% rename from ui/storybook/stories/mobile-task-tabs.stories.tsx rename to ui/storybook/stories/navigation-preferences/mobile-task-tabs.stories.tsx index b2f96dc95cd..39c5e0cc20b 100644 --- a/ui/storybook/stories/mobile-task-tabs.stories.tsx +++ b/ui/storybook/stories/navigation-preferences/mobile-task-tabs.stories.tsx @@ -48,7 +48,8 @@ function Prototype({ initialTabs = basicTabs, activeTabId = "plan" }: { initialT } const meta = { - title: "Prototypes/Task detail/Mobile tabs", + id: "prototypes-task-detail-mobile-tabs", + title: "Oversight and operation/Navigation, profile, and announcements/Prototypes/Task detail/Mobile tabs", parameters: { layout: "fullscreen", docs: { description: { component: "A task toolbar with a vertical open-tab overview. The current document stays readable; titles wrap in the overview, selection is explicit, close actions stay visible, and controls have 44px touch targets. Inspired by Apple’s toolbar and safe-area guidance: https://developer.apple.com/design/human-interface-guidelines/toolbars and https://developer.apple.com/design/human-interface-guidelines/layout." } }, diff --git a/ui/storybook/stories/navigation-layout.stories.tsx b/ui/storybook/stories/navigation-preferences/navigation-layout.stories.tsx similarity index 98% rename from ui/storybook/stories/navigation-layout.stories.tsx rename to ui/storybook/stories/navigation-preferences/navigation-layout.stories.tsx index 4eca73816f6..05316942295 100644 --- a/ui/storybook/stories/navigation-layout.stories.tsx +++ b/ui/storybook/stories/navigation-preferences/navigation-layout.stories.tsx @@ -39,7 +39,7 @@ import { storybookIssues, storybookProjects, storybookSidebarBadges, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; function Section({ eyebrow, @@ -332,7 +332,8 @@ function NavigationLayoutStories() { } const meta = { - title: "Product/Navigation & Layout", + id: "product-navigation-layout", + title: "Oversight and operation/Navigation, profile, and announcements/Product/Navigation & Layout", component: NavigationLayoutStories, // Sidebar mounts PluginLauncherOutlet unconditionally once a company is // selected; without this provider the story raced company selection and diff --git a/ui/storybook/stories/onboarding-agent-arc.stories.tsx b/ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx similarity index 99% rename from ui/storybook/stories/onboarding-agent-arc.stories.tsx rename to ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx index 8671277b425..47189737929 100644 --- a/ui/storybook/stories/onboarding-agent-arc.stories.tsx +++ b/ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx @@ -12,11 +12,11 @@ import { STORYBOOK_COMPANY_ID, clearOnboardingDraft, seedOnboardingDraft, -} from "../fixtures/onboardingDraft"; +} from "../../fixtures/onboardingDraft"; import { resetOnboardingFixtureState, setOnboardingFixtureState, -} from "../fixtures/onboardingEnvironment"; +} from "../../fixtures/onboardingEnvironment"; /** * The onboarding wizard's agent arc: create the agent, connect a model, review. @@ -32,7 +32,8 @@ import { * reached at all. */ const meta = { - title: "Onboarding/Agent arc", + id: "onboarding-agent-arc", + title: "Getting started and access/Onboarding and first work/Onboarding/Agent arc", parameters: { layout: "centered" }, } satisfies Meta; diff --git a/ui/storybook/stories/onboarding-character.stories.tsx b/ui/storybook/stories/onboarding/onboarding-character.stories.tsx similarity index 90% rename from ui/storybook/stories/onboarding-character.stories.tsx rename to ui/storybook/stories/onboarding/onboarding-character.stories.tsx index ca6d8c462e6..b69cce348da 100644 --- a/ui/storybook/stories/onboarding-character.stories.tsx +++ b/ui/storybook/stories/onboarding/onboarding-character.stories.tsx @@ -1,11 +1,12 @@ import { useState, type ReactNode } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { AGENT_PALETTE_IDS, appearanceForPalette, type AgentPaletteId } from "@paperclipai/shared"; -import { OnboardingCharacter } from "../../src/components/onboarding/OnboardingCharacter"; -import { Button } from "../../src/components/ui/button"; +import { OnboardingCharacter } from "../../../src/components/onboarding/OnboardingCharacter"; +import { Button } from "../../../src/components/ui/button"; const meta = { - title: "Onboarding/Character", + id: "onboarding-character", + title: "Getting started and access/Onboarding and first work/Onboarding/Character", component: OnboardingCharacter, args: { appearance: appearanceForPalette("bubblegum-sky"), awake: false }, parameters: { docs: { description: { component: "The onboarding hero: the persona character (the studio's App export) on the shared ClipLab v0.2.0 engine. Gray and dozing until Review, where it plays the one-shot sleepy → wink → idle transition while its palette fades in, then settles into the idle loop. Reduced motion skips the sequence." } } }, diff --git a/ui/storybook/stories/onboarding-connect-model.stories.tsx b/ui/storybook/stories/onboarding/onboarding-connect-model.stories.tsx similarity index 95% rename from ui/storybook/stories/onboarding-connect-model.stories.tsx rename to ui/storybook/stories/onboarding/onboarding-connect-model.stories.tsx index b3c15e07399..065bc194575 100644 --- a/ui/storybook/stories/onboarding-connect-model.stories.tsx +++ b/ui/storybook/stories/onboarding/onboarding-connect-model.stories.tsx @@ -18,7 +18,8 @@ import { ConnectModelPreview } from "@/components/onboarding/ConnectModelPreview * it reads as one row changing its terms. */ const meta = { - title: "Onboarding/Connect a model", + id: "onboarding-connect-a-model", + title: "Getting started and access/Onboarding and first work/Onboarding/Connect a model", parameters: { layout: "centered" }, } satisfies Meta; diff --git a/ui/storybook/stories/overview.stories.tsx b/ui/storybook/stories/overview/overview.stories.tsx similarity index 97% rename from ui/storybook/stories/overview.stories.tsx rename to ui/storybook/stories/overview/overview.stories.tsx index c7e1ab8f647..4da748cfce2 100644 --- a/ui/storybook/stories/overview.stories.tsx +++ b/ui/storybook/stories/overview/overview.stories.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; -import catalog from "../../../maps/storybook-map/catalog.json"; +import catalog from "../../../../maps/storybook-map/catalog.json"; type IndexEntry = { id: string; type: string; importPath: string }; @@ -83,6 +83,7 @@ function StorybookGuide() { } const meta = { + id: "overview-storybook-guide", title: "Overview/Storybook Guide", component: StorybookGuide, parameters: { docs: { description: { component: "Feature-based navigation from the downstream catalog, with separate product, component, and fixture classifications. Story links come from this build's index." } } }, diff --git a/ui/storybook/stories/new-project-repos.stories.tsx b/ui/storybook/stories/projects/new-project-repos.stories.tsx similarity index 92% rename from ui/storybook/stories/new-project-repos.stories.tsx rename to ui/storybook/stories/projects/new-project-repos.stories.tsx index 3d3bc78466f..74179ac965e 100644 --- a/ui/storybook/stories/new-project-repos.stories.tsx +++ b/ui/storybook/stories/projects/new-project-repos.stories.tsx @@ -1,11 +1,12 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { NewProjectPrototype } from "../prototypes/project-repos/ProjectReposPrototype"; +import { NewProjectPrototype } from "../../prototypes/project-repos/ProjectReposPrototype"; -import { crowdedRepoIds, reviewViewports } from "../prototypes/project-repos/fixtures"; +import { crowdedRepoIds, reviewViewports } from "../../prototypes/project-repos/fixtures"; const meta = { - title: "Proposals/Project repos/New project", + id: "proposals-project-repos-new-project", + title: "Projects and execution/Projects and repositories/Proposals/Project repos/New project", component: NewProjectPrototype, tags: ["!autodocs"], parameters: { layout: "fullscreen", viewport: { options: reviewViewports }, docs: { description: { component: "Interactive design proposal only. The project name starts empty and focused, with an outlined folder icon. The source repo region scrolls while the title, name, and actions remain visible. GitHub setup uses the existing ConnectionSetupFlow; Continue to GitHub simulates a successful provider return. Creation and repository changes stay in memory." } } }, diff --git a/ui/storybook/stories/project-execution-workspace-strategy.stories.tsx b/ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx similarity index 92% rename from ui/storybook/stories/project-execution-workspace-strategy.stories.tsx rename to ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx index c47bd8ed7a6..2cfdbe3effb 100644 --- a/ui/storybook/stories/project-execution-workspace-strategy.stories.tsx +++ b/ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx @@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import type { Project } from "@paperclipai/shared"; import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "@/components/ProjectProperties"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookProjects } from "../fixtures/paperclipData"; +import { storybookProjects } from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; const boardProject = storybookProjects.find((project) => project.id === "project-board-ui") ?? storybookProjects[0]!; @@ -28,7 +28,8 @@ function Hydrate({ children }: { children: ReactNode }) { } const meta: Meta = { - title: "Workspaces/Project runtime provision command", + id: "workspaces-project-runtime-provision-command", + title: "Projects and execution/Projects and repositories/Workspaces/Project runtime provision command", component: ProjectProperties, parameters: { layout: "padded" }, }; diff --git a/ui/storybook/stories/project-repos-configuration.stories.tsx b/ui/storybook/stories/projects/project-repos-configuration.stories.tsx similarity index 87% rename from ui/storybook/stories/project-repos-configuration.stories.tsx rename to ui/storybook/stories/projects/project-repos-configuration.stories.tsx index 6015a5475a3..1149d465441 100644 --- a/ui/storybook/stories/project-repos-configuration.stories.tsx +++ b/ui/storybook/stories/projects/project-repos-configuration.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ProjectConfigurationPrototype } from "../prototypes/project-repos/ProjectConfigurationPage"; +import { ProjectConfigurationPrototype } from "../../prototypes/project-repos/ProjectConfigurationPage"; -import { crowdedRepoIds, reviewViewports } from "../prototypes/project-repos/fixtures"; +import { crowdedRepoIds, reviewViewports } from "../../prototypes/project-repos/fixtures"; const meta = { - title: "Proposals/Project repos/Configuration", + id: "proposals-project-repos-configuration", + title: "Projects and execution/Projects and repositories/Proposals/Project repos/Configuration", component: ProjectConfigurationPrototype, tags: ["!autodocs"], parameters: { layout: "fullscreen", viewport: { options: reviewViewports }, docs: { description: { component: "Full Onboarding configuration page, based on the Bull staging reference: sidebar, header, tabs, actual ProjectProperties, and the proposed source repo editor in the Codebase slot. Add/remove repos, save or discard, connect another GitHub account, and preserve existing text URLs. All writes and provider sign-in are simulated in Storybook." } } }, diff --git a/ui/storybook/stories/projects-goals-workspaces.stories.tsx b/ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx similarity index 99% rename from ui/storybook/stories/projects-goals-workspaces.stories.tsx rename to ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx index c10f5958366..7eb9e5fbd2f 100644 --- a/ui/storybook/stories/projects-goals-workspaces.stories.tsx +++ b/ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx @@ -27,7 +27,7 @@ import { storybookIssues, storybookProjectWorkspaces, storybookProjects, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; const boardProject = storybookProjects.find((project) => project.id === "project-board-ui") ?? storybookProjects[0]!; @@ -497,7 +497,8 @@ function ProjectsGoalsWorkspacesStories() { } const meta = { - title: "Product/Projects Goals Workspaces", + id: "product-projects-goals-workspaces", + title: "Projects and execution/Projects and repositories/Product/Projects Goals Workspaces", component: ProjectsGoalsWorkspacesStories, parameters: { docs: { diff --git a/ui/storybook/stories/agent-chat-unanswered-questions.stories.tsx b/ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx similarity index 93% rename from ui/storybook/stories/agent-chat-unanswered-questions.stories.tsx rename to ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx index 6d25d088789..f292721632b 100644 --- a/ui/storybook/stories/agent-chat-unanswered-questions.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within, waitFor } from "storybook/test"; -import { QuestionChat, question } from "../fixtures/unansweredQuestionChat"; +import { QuestionChat, question } from "../../fixtures/unansweredQuestionChat"; -const meta = { title: "Chat & Comments/Agent Chat Unanswered Questions", parameters: { layout: "fullscreen" }, component: QuestionChat, +const meta = { id: "chat-comments-agent-chat-unanswered-questions", + title: "Tasks and conversations/Questions and approvals/Chat & Comments/Agent Chat Unanswered Questions", parameters: { layout: "fullscreen" }, component: QuestionChat, beforeEach: () => { localStorage.removeItem(`paperclip:task-question-dismissals:user-board:${question.issueId}`); for (const key of Object.keys(localStorage)) { diff --git a/ui/storybook/stories/composer-question-flow.stories.tsx b/ui/storybook/stories/questions-and-approvals/composer-question-flow.stories.tsx similarity index 98% rename from ui/storybook/stories/composer-question-flow.stories.tsx rename to ui/storybook/stories/questions-and-approvals/composer-question-flow.stories.tsx index 4fd6af0f0e4..34ec7fba1e1 100644 --- a/ui/storybook/stories/composer-question-flow.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/composer-question-flow.stories.tsx @@ -85,7 +85,8 @@ function InteractiveComposerQuestions() { } const meta = { - title: "Composer/Question flow", + id: "composer-question-flow", + title: "Tasks and conversations/Questions and approvals/Composer/Question flow", component: InteractiveComposerQuestions, parameters: { layout: "padded", diff --git a/ui/storybook/stories/connection-reviews.stories.tsx b/ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx similarity index 98% rename from ui/storybook/stories/connection-reviews.stories.tsx rename to ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx index 360435d1f9b..825c61b0a8b 100644 --- a/ui/storybook/stories/connection-reviews.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx @@ -17,7 +17,7 @@ import { toolsApi } from "@/api/tools"; import { issuesApi } from "@/api/issues"; import { ReviewQueueCard } from "@/pages/apps/ReviewQueueCard"; import type { ToolActionRequestListItem } from "@paperclipai/shared"; -import { storybookAgentMap } from "../fixtures/paperclipData"; +import { storybookAgentMap } from "../../fixtures/paperclipData"; const readRequest: RequestConfirmationInteraction = { ...pendingToolActionWriteInteraction, @@ -44,7 +44,8 @@ const readRequest: RequestConfirmationInteraction = { }, }; const meta = { - title: "Chat & Comments/Connection Reviews", + id: "chat-comments-connection-reviews", + title: "Tasks and conversations/Questions and approvals/Chat & Comments/Connection Reviews", parameters: { layout: "fullscreen" }, } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/issue-plan-decompositions.stories.tsx b/ui/storybook/stories/questions-and-approvals/issue-plan-decompositions.stories.tsx similarity index 94% rename from ui/storybook/stories/issue-plan-decompositions.stories.tsx rename to ui/storybook/stories/questions-and-approvals/issue-plan-decompositions.stories.tsx index e53f33c44f4..0e3ab134d01 100644 --- a/ui/storybook/stories/issue-plan-decompositions.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/issue-plan-decompositions.stories.tsx @@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import type { AcceptedPlanDecompositionSummary } from "@paperclipai/shared"; import { IssuePlanDecompositionsSection } from "@/components/IssuePlanDecompositionsSection"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookAgentMap } from "../fixtures/paperclipData"; +import { storybookAgentMap } from "../../fixtures/paperclipData"; const issueId = "issue-plan-decomposition-story"; const issueIdentifier = "PAP-6831"; @@ -82,7 +82,8 @@ function HydratedSection({ } const meta = { - title: "Issue Detail/Plan Decompositions", + id: "issue-detail-plan-decompositions", + title: "Tasks and conversations/Questions and approvals/Issue Detail/Plan Decompositions", component: HydratedSection, args: { decompositions: [], diff --git a/ui/storybook/stories/issue-thread-interactions.stories.tsx b/ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx similarity index 99% rename from ui/storybook/stories/issue-thread-interactions.stories.tsx rename to ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx index 371082cbdab..d72c03559b7 100644 --- a/ui/storybook/stories/issue-thread-interactions.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx @@ -79,7 +79,7 @@ import type { RequestItemVerdictValue, SuggestTasksInteraction, } from "@/lib/issue-thread-interactions"; -import { storybookAgentMap } from "../fixtures/paperclipData"; +import { storybookAgentMap } from "../../fixtures/paperclipData"; const boardUserLabels = new Map([ [issueThreadInteractionFixtureMeta.currentUserId, "Riley Board"], @@ -432,7 +432,8 @@ function AutoOpenDeclineRequestConfirmationCard({ } const meta = { - title: "Chat & Comments/Issue Thread Interactions", + id: "chat-comments-issue-thread-interactions", + title: "Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions", parameters: { docs: { description: { diff --git a/ui/storybook/stories/rich-acp-interactions.stories.tsx b/ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx similarity index 98% rename from ui/storybook/stories/rich-acp-interactions.stories.tsx rename to ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx index 5f17004f7d5..929b9bbd2cb 100644 --- a/ui/storybook/stories/rich-acp-interactions.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx @@ -19,7 +19,7 @@ import type { AskUserQuestionsInteraction, RequestConfirmationInteraction, } from "@/lib/issue-thread-interactions"; -import { storybookAgentMap } from "../fixtures/paperclipData"; +import { storybookAgentMap } from "../../fixtures/paperclipData"; // Production components and production projection, with callbacks kept in memory. // No provider, runner, or control-plane mutation is made by these stories. @@ -181,7 +181,8 @@ function ProviderNotice() { } const meta = { - title: "Chat & Comments/Rich ACP interactions", + id: "chat-comments-rich-acp-interactions", + title: "Tasks and conversations/Questions and approvals/Chat & Comments/Rich ACP interactions", parameters: { layout: "fullscreen", docs: { description: { component: "Credential-free production UI fixtures. Callback receipts establish UI payload fidelity only; they do not claim provider execution or server persistence." } } }, } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx b/ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx similarity index 97% rename from ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx rename to ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx index b8f896f8e44..2f35277bfc9 100644 --- a/ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx @@ -19,8 +19,8 @@ import { pendingToolActionWriteInteraction, issueThreadInteractionFixtureMeta, } from "@/fixtures/issueThreadInteractionFixtures"; -import { composerAgentAppearance, composerAgents } from "../prototypes/composer-model-picker/fixtures"; -import { storybookAgentMap } from "../fixtures/paperclipData"; +import { composerAgentAppearance, composerAgents } from "../../prototypes/composer-model-picker/fixtures"; +import { storybookAgentMap } from "../../fixtures/paperclipData"; const agentMap = new Map(composerAgents.map((agent) => [agent.id, { id: agent.id, @@ -143,7 +143,8 @@ function InteractionAboveComposer({ interaction, mobile = false }: { interaction } const meta = { - title: "Composer/Interaction above composer", + id: "composer-interaction-above-composer", + title: "Tasks and conversations/Questions and approvals/Composer/Interaction above composer", component: InteractionAboveComposer, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/task-chat-unanswered-questions.stories.tsx b/ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx similarity index 89% rename from ui/storybook/stories/task-chat-unanswered-questions.stories.tsx rename to ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx index a5b698086f3..286a3090528 100644 --- a/ui/storybook/stories/task-chat-unanswered-questions.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within, waitFor } from "storybook/test"; -import { QuestionChat } from "../fixtures/unansweredQuestionChat"; +import { QuestionChat } from "../../fixtures/unansweredQuestionChat"; const meta = { - title: "Chat & Comments/Task Chat Unanswered Questions", + id: "chat-comments-task-chat-unanswered-questions", + title: "Tasks and conversations/Questions and approvals/Chat & Comments/Task Chat Unanswered Questions", parameters: { layout: "fullscreen" }, component: QuestionChat, args: { conversationMode: false }, diff --git a/ui/storybook/stories/cloud-access-recovery.stories.tsx b/ui/storybook/stories/recovery/cloud-access-recovery.stories.tsx similarity index 85% rename from ui/storybook/stories/cloud-access-recovery.stories.tsx rename to ui/storybook/stories/recovery/cloud-access-recovery.stories.tsx index 4d01137a244..9db96e84bd6 100644 --- a/ui/storybook/stories/cloud-access-recovery.stories.tsx +++ b/ui/storybook/stories/recovery/cloud-access-recovery.stories.tsx @@ -2,7 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { CloudAccessError } from "@/components/CloudAccessGate"; const meta: Meta = { - title: "App/Connection recovery", + id: "app-connection-recovery", + title: "Projects and execution/Recovery/App/Connection recovery", component: CloudAccessError, parameters: { layout: "fullscreen" }, args: { temporary: true, retrying: false, onRetry: () => undefined }, diff --git a/ui/storybook/stories/execution-blocker-notice.stories.tsx b/ui/storybook/stories/recovery/execution-blocker-notice.stories.tsx similarity index 91% rename from ui/storybook/stories/execution-blocker-notice.stories.tsx rename to ui/storybook/stories/recovery/execution-blocker-notice.stories.tsx index e97cc80ada3..138e3cdb26c 100644 --- a/ui/storybook/stories/execution-blocker-notice.stories.tsx +++ b/ui/storybook/stories/recovery/execution-blocker-notice.stories.tsx @@ -8,7 +8,8 @@ client.setQueryData(queryKeys.issues.runs("storybook-recovery"), [ { runId: "stopped-run", agentId: "agent", status: "failed" }, ]); const meta = { - title: "Task chat/Recovery notice", + id: "task-chat-recovery-notice", + title: "Projects and execution/Recovery/Task chat/Recovery notice", component: ExecutionBlockerNotice, decorators: [Story =>
], args: { diff --git a/ui/storybook/stories/execution-recovery.stories.tsx b/ui/storybook/stories/recovery/execution-recovery.stories.tsx similarity index 98% rename from ui/storybook/stories/execution-recovery.stories.tsx rename to ui/storybook/stories/recovery/execution-recovery.stories.tsx index 609f5456c62..b057457b4bc 100644 --- a/ui/storybook/stories/execution-recovery.stories.tsx +++ b/ui/storybook/stories/recovery/execution-recovery.stories.tsx @@ -6,7 +6,7 @@ import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn"; import { TaskChatLiveRunPill } from "@/components/task-chat/TaskChatLiveRunPill"; import { Textarea } from "@/components/ui/textarea"; import { queryKeys } from "@/lib/queryKeys"; -import { createIssue, storybookLiveRuns } from "../fixtures/paperclipData"; +import { createIssue, storybookLiveRuns } from "../../fixtures/paperclipData"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ExecutionProjection } from "@paperclipai/shared"; import { expect, userEvent, within } from "storybook/test"; @@ -60,7 +60,8 @@ function QuietExecution({ execution }: { execution: ExecutionProjection }) { ); } const meta = { - title: "Tasks/Execution recovery", + id: "tasks-execution-recovery", + title: "Projects and execution/Recovery/Tasks/Execution recovery", component: QuietExecution, args: { execution: base }, decorators: [ diff --git a/ui/storybook/stories/issue-blocked-notice.stories.tsx b/ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx similarity index 98% rename from ui/storybook/stories/issue-blocked-notice.stories.tsx rename to ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx index a84bed3544a..e13cb2a9f64 100644 --- a/ui/storybook/stories/issue-blocked-notice.stories.tsx +++ b/ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx @@ -33,7 +33,8 @@ function Frame({ label, children }: { label: string; children: React.ReactNode } } const meta = { - title: "Product/Issue/Blocked notice", + id: "product-issue-blocked-notice", + title: "Projects and execution/Recovery/Product/Issue/Blocked notice", component: IssueBlockedNotice, // Each story supplies its own props via `render`; this default satisfies the // component's one required prop (`blockers`) so the story args type-checks. diff --git a/ui/storybook/stories/model-capacity-retry.stories.tsx b/ui/storybook/stories/recovery/model-capacity-retry.stories.tsx similarity index 89% rename from ui/storybook/stories/model-capacity-retry.stories.tsx rename to ui/storybook/stories/recovery/model-capacity-retry.stories.tsx index 4ef94229a7d..4385292d116 100644 --- a/ui/storybook/stories/model-capacity-retry.stories.tsx +++ b/ui/storybook/stories/recovery/model-capacity-retry.stories.tsx @@ -2,7 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { IssueScheduledRetryCard } from "@/components/IssueScheduledRetryCard"; const meta = { - title: "Tasks/Model capacity retry", + id: "tasks-model-capacity-retry", + title: "Projects and execution/Recovery/Tasks/Model capacity retry", component: IssueScheduledRetryCard, decorators: [Story =>
], args: { diff --git a/ui/storybook/stories/recovery-notice-preview.stories.tsx b/ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx similarity index 91% rename from ui/storybook/stories/recovery-notice-preview.stories.tsx rename to ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx index a5f5c872484..5a4e4de344c 100644 --- a/ui/storybook/stories/recovery-notice-preview.stories.tsx +++ b/ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx @@ -1,9 +1,10 @@ import { expect, userEvent, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { RecoveryNoticePreview } from "../prototypes/recovery-notice/RecoveryNoticePreview"; +import { RecoveryNoticePreview } from "../../prototypes/recovery-notice/RecoveryNoticePreview"; const meta = { - title: "Design previews/Recovery notice", + id: "design-previews-recovery-notice", + title: "Projects and execution/Recovery/Design previews/Recovery notice", component: RecoveryNoticePreview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/scheduled-retry.stories.tsx b/ui/storybook/stories/recovery/scheduled-retry.stories.tsx similarity index 97% rename from ui/storybook/stories/scheduled-retry.stories.tsx rename to ui/storybook/stories/recovery/scheduled-retry.stories.tsx index a18e72c06d7..4a9acd8f5df 100644 --- a/ui/storybook/stories/scheduled-retry.stories.tsx +++ b/ui/storybook/stories/recovery/scheduled-retry.stories.tsx @@ -6,7 +6,7 @@ import { storybookExecutionWorkspaces, storybookIssueDocuments, storybookIssues, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const issueDocumentSummaries = storybookIssueDocuments.map(({ body: _body, ...summary }) => summary); @@ -144,7 +144,8 @@ function ScheduledRetrySurfaceStories() { } const meta = { - title: "Product/Issue Scheduled retry surfaces", + id: "product-issue-scheduled-retry-surfaces", + title: "Projects and execution/Recovery/Product/Issue Scheduled retry surfaces", component: ScheduledRetrySurfaceStories, parameters: { docs: { diff --git a/ui/storybook/stories/source-issue-recovery.stories.tsx b/ui/storybook/stories/recovery/source-issue-recovery.stories.tsx similarity index 99% rename from ui/storybook/stories/source-issue-recovery.stories.tsx rename to ui/storybook/stories/recovery/source-issue-recovery.stories.tsx index 6687556f838..fe49dfc09b9 100644 --- a/ui/storybook/stories/source-issue-recovery.stories.tsx +++ b/ui/storybook/stories/recovery/source-issue-recovery.stories.tsx @@ -5,7 +5,7 @@ import { Eye, ExternalLink, OctagonAlert, RefreshCw, TriangleAlert } from "lucid import { IssueRecoveryActionCard } from "@/components/IssueRecoveryActionCard"; import { IssueRow } from "@/components/IssueRow"; import { IssueBlockedNotice } from "@/components/IssueBlockedNotice"; -import { storybookAgentMap, storybookAgents, createIssue } from "../fixtures/paperclipData"; +import { storybookAgentMap, storybookAgents, createIssue } from "../../fixtures/paperclipData"; const claudeAgent = storybookAgents.find((agent) => agent.name.toLowerCase().startsWith("claude")) ?? storybookAgents[0]!; const codexAgent = storybookAgents.find((agent) => agent.name.toLowerCase().startsWith("codex")) ?? storybookAgents[0]!; @@ -574,7 +574,8 @@ function InboxRowPanel() { } const meta = { - title: "Paperclip/Source Issue Recovery", + id: "paperclip-source-issue-recovery", + title: "Projects and execution/Recovery/Paperclip/Source Issue Recovery", component: AllStatesPanel, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/source-resolved-fold.stories.tsx b/ui/storybook/stories/recovery/source-resolved-fold.stories.tsx similarity index 98% rename from ui/storybook/stories/source-resolved-fold.stories.tsx rename to ui/storybook/stories/recovery/source-resolved-fold.stories.tsx index ff39164c370..ba714b96e8e 100644 --- a/ui/storybook/stories/source-resolved-fold.stories.tsx +++ b/ui/storybook/stories/recovery/source-resolved-fold.stories.tsx @@ -54,7 +54,8 @@ function DefaultPanel() { } const meta = { - title: "Paperclip/Source-resolved Fold", + id: "paperclip-source-resolved-fold", + title: "Projects and execution/Recovery/Paperclip/Source-resolved Fold", component: DefaultPanel, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/workspace-branch-recovery/01-component.stories.tsx b/ui/storybook/stories/recovery/workspace-branch-recovery/01-component.stories.tsx similarity index 90% rename from ui/storybook/stories/workspace-branch-recovery/01-component.stories.tsx rename to ui/storybook/stories/recovery/workspace-branch-recovery/01-component.stories.tsx index 8e2fdd6d576..cdc9698d708 100644 --- a/ui/storybook/stories/workspace-branch-recovery/01-component.stories.tsx +++ b/ui/storybook/stories/recovery/workspace-branch-recovery/01-component.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; import { WorkspaceBaseRefRecoveryNotice } from "@/components/WorkspaceBaseRefRecoveryNotice"; -import { evidence, simulateRepair } from "./fixtures"; +import { evidence, simulateRepair } from "../../../support/workspace-branch-recovery/fixtures"; const meta = { - title: "Workspace branch recovery/01 Repair card", + id: "workspace-branch-recovery-01-repair-card", + title: "Projects and execution/Recovery/Workspace branch recovery/01 Repair card", component: WorkspaceBaseRefRecoveryNotice, args: { ...evidence, onRepair: simulateRepair }, parameters: { layout: "fullscreen", docs: { description: { component: "Proposed inline repair using the reusable component. Repository data and save/retry responses are simulated here; this story does not change a live task. The default-branch action appears only with a verified repository default." } } }, diff --git a/ui/storybook/stories/workspace-branch-recovery/02-task-journey.stories.tsx b/ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx similarity index 92% rename from ui/storybook/stories/workspace-branch-recovery/02-task-journey.stories.tsx rename to ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx index 48f827ca789..f30e7a75a23 100644 --- a/ui/storybook/stories/workspace-branch-recovery/02-task-journey.stories.tsx +++ b/ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx @@ -8,7 +8,7 @@ import { WorkspaceBaseRefRecoveryNotice } from "@/components/WorkspaceBaseRefRec import { StatusBadge } from "@/components/StatusBadge"; import { PluginLauncherProvider } from "@/plugins/launchers"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; -import { evidence, simulateRepair } from "./fixtures"; +import { evidence, simulateRepair } from "../../../support/workspace-branch-recovery/fixtures"; function TaskConversation() { const [retried, setRetried] = useState(false); @@ -33,7 +33,8 @@ function Journey() { return }>} />; } const meta = { - title: "Workspace branch recovery/02 Task journey", component: Journey, + id: "workspace-branch-recovery-02-task-journey", + title: "Projects and execution/Recovery/Workspace branch recovery/02 Task journey", component: Journey, parameters: { layout: "fullscreen", initialEntries: ["/PAP/issues/PAP-24"], docs: { description: { component: "Interactive proposal in the production app shell, using the shared task bubbles, composer and repair component. Save/retry and branch lookup are simulated. Use master or choose release/next; a missing branch shows an inline error. No live task is modified." }, story: { inline: false } } }, } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/routine-detail-c.stories.tsx b/ui/storybook/stories/routines/routine-detail-c.stories.tsx similarity index 98% rename from ui/storybook/stories/routine-detail-c.stories.tsx rename to ui/storybook/stories/routines/routine-detail-c.stories.tsx index d85a6924b66..f536ef04caf 100644 --- a/ui/storybook/stories/routine-detail-c.stories.tsx +++ b/ui/storybook/stories/routines/routine-detail-c.stories.tsx @@ -33,7 +33,7 @@ import { ActivitySection, } from "@/components/routine-sections/operate-sections"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookAgents, storybookProjects } from "../fixtures/paperclipData"; +import { storybookAgents, storybookProjects } from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; const ROUTINE_ID = "routine-storybook"; @@ -440,7 +440,8 @@ function RoutineCShell({ } const meta: Meta = { - title: "Product/Routines · Detail (variation C)", + id: "product-routines-·-detail-variation-c", + title: "Automation and structured work/Routines, schedules, and triggers/Product/Routines · Detail (variation C)", component: RoutineCShell, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/routine-secrets.stories.tsx b/ui/storybook/stories/routines/routine-secrets.stories.tsx similarity index 97% rename from ui/storybook/stories/routine-secrets.stories.tsx rename to ui/storybook/stories/routines/routine-secrets.stories.tsx index 42bf771b2ba..8117b6ea4dc 100644 --- a/ui/storybook/stories/routine-secrets.stories.tsx +++ b/ui/storybook/stories/routines/routine-secrets.stories.tsx @@ -15,7 +15,7 @@ import { RoutineHistoryTab } from "@/components/RoutineHistoryTab"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { useCompany } from "@/context/CompanyContext"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookCompanies, storybookSecrets } from "../fixtures/paperclipData"; +import { storybookCompanies, storybookSecrets } from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; @@ -47,7 +47,8 @@ function StorybookRoutineFixtures({ } const meta: Meta = { - title: "Product/Routines · Secrets tab", + id: "product-routines-·-secrets-tab", + title: "Automation and structured work/Routines, schedules, and triggers/Product/Routines · Secrets tab", parameters: { layout: "fullscreen", a11y: { test: "off" }, diff --git a/ui/storybook/stories/routine-webhooks-ux.stories.tsx b/ui/storybook/stories/routines/routine-webhooks-ux.stories.tsx similarity index 98% rename from ui/storybook/stories/routine-webhooks-ux.stories.tsx rename to ui/storybook/stories/routines/routine-webhooks-ux.stories.tsx index a991e3598b7..093addf2e44 100644 --- a/ui/storybook/stories/routine-webhooks-ux.stories.tsx +++ b/ui/storybook/stories/routines/routine-webhooks-ux.stories.tsx @@ -1,6 +1,6 @@ -import { RoutineTriggerCard } from "../fixtures/routineTriggerCard"; -import { RoutineTriggerWizard, defaultTriggerDraft, type TriggerDraft } from "../fixtures/routineTriggerWizard"; -import { CopyField } from "../fixtures/routineWebhookFields"; +import { RoutineTriggerCard } from "../../fixtures/routineTriggerCard"; +import { RoutineTriggerWizard, defaultTriggerDraft, type TriggerDraft } from "../../fixtures/routineTriggerWizard"; +import { CopyField } from "../../fixtures/routineWebhookFields"; import { useCallback, useEffect, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; @@ -14,7 +14,7 @@ import { RoutineDetail } from "@/pages/RoutineDetail"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { Link, useNavigate, useParams } from "@/lib/router"; import { cn } from "@/lib/utils"; -import { WebhookReview } from "../fixtures/routineWebhooks"; +import { WebhookReview } from "../../fixtures/routineWebhooks"; // Deliberately Storybook-only: no production components or webhook APIs are changed. const endpoint = "https://acme.paperclip.example/api/routine-triggers/public/0123456789abcdef01234567/fire"; @@ -191,7 +191,8 @@ function Prototype(props: Props) { return } />; } const meta = { - title: "Product/Routines/Webhooks UX preview", + id: "product-routines-webhooks-ux-preview", + title: "Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks UX preview", component: Prototype, parameters: { layout: "fullscreen" }, args: { stage: "setup", sender: "custom", page: "triggers", triggerKind: "choose", wizardStep: 0, scheduleSaved: false, checkResult: "waiting" }, diff --git a/ui/storybook/stories/routine-webhooks.stories.tsx b/ui/storybook/stories/routines/routine-webhooks.stories.tsx similarity index 95% rename from ui/storybook/stories/routine-webhooks.stories.tsx rename to ui/storybook/stories/routines/routine-webhooks.stories.tsx index 3d6da63bf4f..5769c0258d2 100644 --- a/ui/storybook/stories/routine-webhooks.stories.tsx +++ b/ui/storybook/stories/routines/routine-webhooks.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { WebhookReview } from "../fixtures/routineWebhooks"; +import { WebhookReview } from "../../fixtures/routineWebhooks"; const meta = { - title: "Product/Routines/Webhooks", + id: "product-routines-webhooks", + title: "Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks", component: WebhookReview, parameters: { layout: "fullscreen" }, args: { signingMode: "bearer", state: "configured" }, diff --git a/ui/storybook/stories/routines-list-groups.stories.tsx b/ui/storybook/stories/routines/routines-list-groups.stories.tsx similarity index 96% rename from ui/storybook/stories/routines-list-groups.stories.tsx rename to ui/storybook/stories/routines/routines-list-groups.stories.tsx index cf4da482678..03955004ac3 100644 --- a/ui/storybook/stories/routines-list-groups.stories.tsx +++ b/ui/storybook/stories/routines/routines-list-groups.stories.tsx @@ -91,7 +91,8 @@ function GroupedList() { } const meta: Meta = { - title: "Product/Routines · List (grouped cards)", + id: "product-routines-·-list-grouped-cards", + title: "Automation and structured work/Routines, schedules, and triggers/Product/Routines · List (grouped cards)", component: GroupedList, parameters: { layout: "fullscreen" }, }; diff --git a/ui/storybook/stories/agent-connection-reuse.stories.tsx b/ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx similarity index 95% rename from ui/storybook/stories/agent-connection-reuse.stories.tsx rename to ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx index d3f98a949d0..2fd484a1c5c 100644 --- a/ui/storybook/stories/agent-connection-reuse.stories.tsx +++ b/ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx @@ -9,7 +9,7 @@ import { resetOnboardingFixtureState, setOnboardingFixtureState, STORYBOOK_SANDBOX_ENVIRONMENT_ID, -} from "../fixtures/onboardingEnvironment"; +} from "../../fixtures/onboardingEnvironment"; function ConnectionStory({ adapterType = "claude_local", @@ -48,7 +48,8 @@ function ConnectionStory({ ); } const meta = { - title: "Onboarding/Saved connections", + id: "onboarding-saved-connections", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/Onboarding/Saved connections", component: ConnectionStory, parameters: { layout: "centered", diff --git a/ui/storybook/stories/ai-connection-usage.stories.tsx b/ui/storybook/stories/runs-adapters/ai-connection-usage.stories.tsx similarity index 93% rename from ui/storybook/stories/ai-connection-usage.stories.tsx rename to ui/storybook/stories/runs-adapters/ai-connection-usage.stories.tsx index d3b47a3a386..849cc67ac65 100644 --- a/ui/storybook/stories/ai-connection-usage.stories.tsx +++ b/ui/storybook/stories/runs-adapters/ai-connection-usage.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, waitFor, within } from "storybook/test"; -import { AiConnectionUsageReview, USAGE_REVIEW_SCENARIOS } from "../prototypes/AiConnectionUsageReview"; +import { AiConnectionUsageReview, USAGE_REVIEW_SCENARIOS } from "../../prototypes/AiConnectionUsageReview"; const { codex, claude, grok, openRouter, unavailable } = USAGE_REVIEW_SCENARIOS; const meta = { - title: "AI Connections/Usage limits", + id: "ai-connections-usage-limits", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Usage limits", component: AiConnectionUsageReview, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/ai-connections.stories.tsx b/ui/storybook/stories/runs-adapters/ai-connections.stories.tsx similarity index 97% rename from ui/storybook/stories/ai-connections.stories.tsx rename to ui/storybook/stories/runs-adapters/ai-connections.stories.tsx index 10f3419dcee..100856e459b 100644 --- a/ui/storybook/stories/ai-connections.stories.tsx +++ b/ui/storybook/stories/runs-adapters/ai-connections.stories.tsx @@ -1,17 +1,18 @@ -import { AiReviewFrame, aiReviewContext } from "../prototypes/AiReviewFrame"; -import { AiTaskConnectionReview } from "../prototypes/AiTaskConnectionReview"; +import { AiReviewFrame, aiReviewContext } from "../../prototypes/AiReviewFrame"; +import { AiTaskConnectionReview } from "../../prototypes/AiTaskConnectionReview"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { AiConnectionsReview } from "../prototypes/AiConnectionsReview"; -import { AiConnectorPages } from "../prototypes/AiConnectorPages"; +import { AiConnectionsReview } from "../../prototypes/AiConnectionsReview"; +import { AiConnectorPages } from "../../prototypes/AiConnectorPages"; import { AI_REVIEW_BINDING, AI_REVIEW_CONNECTIONS, AI_REVIEW_REQUIREMENT, -} from "../fixtures/aiConnections"; +} from "../../fixtures/aiConnections"; const meta = { - title: "AI Connections/Review", + id: "ai-connections-review", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Review", component: AiConnectionsReview, parameters: { layout: "fullscreen" }, decorators: [(Story, context) => ( diff --git a/ui/storybook/stories/connection-pool-connector.stories.tsx b/ui/storybook/stories/runs-adapters/connection-pool-connector.stories.tsx similarity index 85% rename from ui/storybook/stories/connection-pool-connector.stories.tsx rename to ui/storybook/stories/runs-adapters/connection-pool-connector.stories.tsx index d6dac52e8e9..dac2a11391b 100644 --- a/ui/storybook/stories/connection-pool-connector.stories.tsx +++ b/ui/storybook/stories/runs-adapters/connection-pool-connector.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { ConnectionPoolConnectorHost } from "../prototypes/ConnectionPoolConnectorHost"; -const meta = { title: "Connectors/Pool host", component: ConnectionPoolConnectorHost, parameters: { layout: "fullscreen" } } satisfies Meta; +import { ConnectionPoolConnectorHost } from "../../prototypes/ConnectionPoolConnectorHost"; +const meta = { id: "connectors-pool-host", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/Connectors/Pool host", component: ConnectionPoolConnectorHost, parameters: { layout: "fullscreen" } } satisfies Meta; export default meta; type Story = StoryObj; export const Catalog: Story = {}; diff --git a/ui/storybook/stories/connection-pools.stories.tsx b/ui/storybook/stories/runs-adapters/connection-pools.stories.tsx similarity index 92% rename from ui/storybook/stories/connection-pools.stories.tsx rename to ui/storybook/stories/runs-adapters/connection-pools.stories.tsx index 0f480d7156c..54ee9f59d36 100644 --- a/ui/storybook/stories/connection-pools.stories.tsx +++ b/ui/storybook/stories/runs-adapters/connection-pools.stories.tsx @@ -10,7 +10,7 @@ import { ComposerRunSettingsPicker } from "@/components/task-chat/ComposerRunSet import type { ComposerRunSettings } from "@/components/task-chat/composer-run-settings"; import { RunRetryDetails } from "@/components/RunRetryDetails"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookAgents, storybookIssues } from "../fixtures/paperclipData"; +import { storybookAgents, storybookIssues } from "../../fixtures/paperclipData"; const companyId = "company-storybook", agentId = "agent-codex"; const poolId = "10000000-0000-4000-8000-000000000001"; @@ -74,7 +74,8 @@ function PoolSurfaces({ scenario = "overview" }: { scenario?: Scenario }) { {(overview || ["run-codex", "run-claude", "fallback", "retry"].includes(scenario)) &&

Run account and runtime

} ; } -const meta = { title: "AI Connections/Connection pools", component: PoolSurfaces, parameters: { layout: "fullscreen", docs: { description: { component: "All user-facing Core additions for the private connection-pool plugin. These stories mount the production selector, composer, quota banners, and run details/retry metadata. Routing requires manual operator configuration; there is no settings toggle. Accounts are fixtures; no live provider calls or instance mutations occur." } } }, render: args => } satisfies Meta; +const meta = { id: "ai-connections-connection-pools", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Connection pools", component: PoolSurfaces, parameters: { layout: "fullscreen", docs: { description: { component: "All user-facing Core additions for the private connection-pool plugin. These stories mount the production selector, composer, quota banners, and run details/retry metadata. Routing requires manual operator configuration; there is no settings toggle. Accounts are fixtures; no live provider calls or instance mutations occur." } } }, render: args => } satisfies Meta; export default meta; type Story = StoryObj; export const AllCoreSurfaces: Story = { args: { scenario: "overview" } }; diff --git a/ui/storybook/stories/dot-runner-connection.stories.tsx b/ui/storybook/stories/runs-adapters/dot-runner-connection.stories.tsx similarity index 94% rename from ui/storybook/stories/dot-runner-connection.stories.tsx rename to ui/storybook/stories/runs-adapters/dot-runner-connection.stories.tsx index fd00dd211b6..18b0cbb3ac5 100644 --- a/ui/storybook/stories/dot-runner-connection.stories.tsx +++ b/ui/storybook/stories/runs-adapters/dot-runner-connection.stories.tsx @@ -33,7 +33,8 @@ function ConnectionForm() {
; } const meta = { - title: "Assistant connections/Dot Runner", component: ConnectionForm, + id: "assistant-connections-dot-runner", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/Assistant connections/Dot Runner", component: ConnectionForm, parameters: { layout: "padded", docs: { description: { component: "Production Dot pairing component. API responses, pairing code and account state are synthetic. This preview does not qualify a real Dot account." } } }, beforeEach: ({ parameters }) => installFixture(Boolean(parameters.connected)), } satisfies Meta; diff --git a/ui/storybook/stories/local-ai-subscription.stories.tsx b/ui/storybook/stories/runs-adapters/local-ai-subscription.stories.tsx similarity index 92% rename from ui/storybook/stories/local-ai-subscription.stories.tsx rename to ui/storybook/stories/runs-adapters/local-ai-subscription.stories.tsx index b5c8ddb8dda..57a7cf82cc9 100644 --- a/ui/storybook/stories/local-ai-subscription.stories.tsx +++ b/ui/storybook/stories/runs-adapters/local-ai-subscription.stories.tsx @@ -3,7 +3,8 @@ import { expect, userEvent, waitFor, within } from "storybook/test"; import { LocalProviderLoginInstructions } from "@/components/AdapterLoginChrome"; const meta = { - title: "AI Connections/Provider routing/07 Subscription sign-in", + id: "ai-connections-provider-routing-07-subscription-sign-in", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/07 Subscription sign-in", component: LocalProviderLoginInstructions, parameters: { layout: "centered" }, } satisfies Meta; diff --git a/ui/storybook/stories/provider-routing-00-overview.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-00-overview.stories.tsx similarity index 52% rename from ui/storybook/stories/provider-routing-00-overview.stories.tsx rename to ui/storybook/stories/runs-adapters/provider-routing-00-overview.stories.tsx index 868505913ab..32cbb80c225 100644 --- a/ui/storybook/stories/provider-routing-00-overview.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-00-overview.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ReviewIndex } from "../prototypes/provider-routing/ReviewIndex"; -import { ReviewFrame } from "../prototypes/provider-routing/shared"; -import { reviewLifecycle } from "../prototypes/provider-routing/story-support"; +import { ReviewIndex } from "../../prototypes/provider-routing/ReviewIndex"; +import { ReviewFrame } from "../../prototypes/provider-routing/shared"; +import { reviewLifecycle } from "../../prototypes/provider-routing/story-support"; const meta = { - title: "AI Connections/Provider routing/00 Overview", + id: "ai-connections-provider-routing-00-overview", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/00 Overview", component: ReviewIndex, parameters: { layout: "fullscreen" }, ...reviewLifecycle, diff --git a/ui/storybook/stories/provider-routing-01-onboarding.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-01-onboarding.stories.tsx similarity index 89% rename from ui/storybook/stories/provider-routing-01-onboarding.stories.tsx rename to ui/storybook/stories/runs-adapters/provider-routing-01-onboarding.stories.tsx index 1fa29f8a343..ee0cf983ab7 100644 --- a/ui/storybook/stories/provider-routing-01-onboarding.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-01-onboarding.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { Onboarding } from "../prototypes/provider-routing/AgentSetup"; -import { ReviewFrame } from "../prototypes/provider-routing/shared"; -import { reviewLifecycle } from "../prototypes/provider-routing/story-support"; +import { Onboarding } from "../../prototypes/provider-routing/AgentSetup"; +import { ReviewFrame } from "../../prototypes/provider-routing/shared"; +import { reviewLifecycle } from "../../prototypes/provider-routing/story-support"; const meta = { - title: "AI Connections/Provider routing/01 Onboarding", + id: "ai-connections-provider-routing-01-onboarding", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/01 Onboarding", component: Onboarding, parameters: { layout: "fullscreen" }, ...reviewLifecycle, diff --git a/ui/storybook/stories/provider-routing-02-connect.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-02-connect.stories.tsx similarity index 88% rename from ui/storybook/stories/provider-routing-02-connect.stories.tsx rename to ui/storybook/stories/runs-adapters/provider-routing-02-connect.stories.tsx index 8d0f42ca24c..04294737078 100644 --- a/ui/storybook/stories/provider-routing-02-connect.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-02-connect.stories.tsx @@ -1,14 +1,15 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { ProviderCatalogReview } from "../prototypes/provider-routing/ConnectionCatalog"; -import { ConnectionSetup } from "../prototypes/provider-routing/ConnectionSetup"; -import { ReviewFrame } from "../prototypes/provider-routing/shared"; +import { ProviderCatalogReview } from "../../prototypes/provider-routing/ConnectionCatalog"; +import { ConnectionSetup } from "../../prototypes/provider-routing/ConnectionSetup"; +import { ReviewFrame } from "../../prototypes/provider-routing/shared"; import { choose, reviewLifecycle, -} from "../prototypes/provider-routing/story-support"; +} from "../../prototypes/provider-routing/story-support"; const meta = { - title: "AI Connections/Provider routing/02 Connect", + id: "ai-connections-provider-routing-02-connect", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/02 Connect", component: ConnectionSetup, parameters: { layout: "fullscreen" }, ...reviewLifecycle, diff --git a/ui/storybook/stories/provider-routing-03-agent.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-03-agent.stories.tsx similarity index 96% rename from ui/storybook/stories/provider-routing-03-agent.stories.tsx rename to ui/storybook/stories/runs-adapters/provider-routing-03-agent.stories.tsx index 2b45c5ddfd8..dd7b66339d1 100644 --- a/ui/storybook/stories/provider-routing-03-agent.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-03-agent.stories.tsx @@ -1,13 +1,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, waitFor, within } from "storybook/test"; -import { AgentSetup } from "../prototypes/provider-routing/AgentSetup"; -import { ReviewFrame } from "../prototypes/provider-routing/shared"; +import { AgentSetup } from "../../prototypes/provider-routing/AgentSetup"; +import { ReviewFrame } from "../../prototypes/provider-routing/shared"; import { choose, reviewLifecycle, -} from "../prototypes/provider-routing/story-support"; +} from "../../prototypes/provider-routing/story-support"; const meta = { - title: "AI Connections/Provider routing/03 Agent", + id: "ai-connections-provider-routing-03-agent", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/03 Agent", component: AgentSetup, parameters: { layout: "fullscreen" }, ...reviewLifecycle, diff --git a/ui/storybook/stories/provider-routing-04-manage.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-04-manage.stories.tsx similarity index 89% rename from ui/storybook/stories/provider-routing-04-manage.stories.tsx rename to ui/storybook/stories/runs-adapters/provider-routing-04-manage.stories.tsx index 7e5299a7456..3b40f1b84ea 100644 --- a/ui/storybook/stories/provider-routing-04-manage.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-04-manage.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { Management } from "../prototypes/provider-routing/Management"; -import { ReviewFrame } from "../prototypes/provider-routing/shared"; -import { reviewLifecycle } from "../prototypes/provider-routing/story-support"; +import { Management } from "../../prototypes/provider-routing/Management"; +import { ReviewFrame } from "../../prototypes/provider-routing/shared"; +import { reviewLifecycle } from "../../prototypes/provider-routing/story-support"; const meta = { - title: "AI Connections/Provider routing/04 Manage", + id: "ai-connections-provider-routing-04-manage", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/04 Manage", component: Management, parameters: { layout: "fullscreen" }, ...reviewLifecycle, diff --git a/ui/storybook/stories/provider-routing-05-recovery.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-05-recovery.stories.tsx similarity index 87% rename from ui/storybook/stories/provider-routing-05-recovery.stories.tsx rename to ui/storybook/stories/runs-adapters/provider-routing-05-recovery.stories.tsx index 222c79ec859..e1e9b33a8f1 100644 --- a/ui/storybook/stories/provider-routing-05-recovery.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-05-recovery.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { Recovery } from "../prototypes/provider-routing/Recovery"; -import { ReviewFrame } from "../prototypes/provider-routing/shared"; -import { reviewLifecycle } from "../prototypes/provider-routing/story-support"; +import { Recovery } from "../../prototypes/provider-routing/Recovery"; +import { ReviewFrame } from "../../prototypes/provider-routing/shared"; +import { reviewLifecycle } from "../../prototypes/provider-routing/story-support"; const meta = { - title: "AI Connections/Provider routing/05 Recovery", + id: "ai-connections-provider-routing-05-recovery", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/05 Recovery", component: Recovery, parameters: { layout: "fullscreen" }, ...reviewLifecycle, diff --git a/ui/storybook/stories/provider-routing-06-production.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx similarity index 97% rename from ui/storybook/stories/provider-routing-06-production.stories.tsx rename to ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx index 774e3481073..2454daf4179 100644 --- a/ui/storybook/stories/provider-routing-06-production.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx @@ -15,8 +15,8 @@ import type { AiManagedConnectionSummary, } from "@paperclipai/shared"; import { expect, userEvent, waitFor, within } from "storybook/test"; -import { ProviderCatalogReview } from "../prototypes/provider-routing/ConnectionCatalog"; -import { ReviewFrame } from "../prototypes/provider-routing/shared"; +import { ProviderCatalogReview } from "../../prototypes/provider-routing/ConnectionCatalog"; +import { ReviewFrame } from "../../prototypes/provider-routing/shared"; const companyId = "00000000-0000-4000-8000-000000000001"; const accounts: AiManagedConnectionSummary[] = [ @@ -161,7 +161,8 @@ function AgentConnectionModes() { ; } const meta = { - title: "AI Connections/Provider routing/06 Production components", + id: "ai-connections-provider-routing-06-production-components", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/06 Production components", parameters: { layout: "fullscreen" }, decorators: [ (Story) => ( diff --git a/ui/storybook/stories/session-goals.stories.tsx b/ui/storybook/stories/runs-adapters/session-goals.stories.tsx similarity index 97% rename from ui/storybook/stories/session-goals.stories.tsx rename to ui/storybook/stories/runs-adapters/session-goals.stories.tsx index b9634405186..6658a1ae754 100644 --- a/ui/storybook/stories/session-goals.stories.tsx +++ b/ui/storybook/stories/runs-adapters/session-goals.stories.tsx @@ -159,7 +159,8 @@ function SessionGoalStates() { } const meta = { - title: "Composer/Session goals", + id: "composer-session-goals", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/Composer/Session goals", component: SessionGoalStates, parameters: { docs: { diff --git a/ui/storybook/stories/credential-access/01-notice.stories.tsx b/ui/storybook/stories/secrets/credential-access/01-notice.stories.tsx similarity index 91% rename from ui/storybook/stories/credential-access/01-notice.stories.tsx rename to ui/storybook/stories/secrets/credential-access/01-notice.stories.tsx index 91ea2eb8a6d..3e541190fae 100644 --- a/ui/storybook/stories/credential-access/01-notice.stories.tsx +++ b/ui/storybook/stories/secrets/credential-access/01-notice.stories.tsx @@ -2,7 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker"; const meta = { - title: "Task credential access/01 Notice", + id: "task-credential-access-01-notice", + title: "Apps, channels, and extensions/Secrets and proposals/Task credential access/01 Notice", component: TaskChatMarker, parameters: { layout: "centered" }, decorators: [Story =>
], diff --git a/ui/storybook/stories/credential-access/02-task-page.stories.tsx b/ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx similarity index 82% rename from ui/storybook/stories/credential-access/02-task-page.stories.tsx rename to ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx index e60aa8f9773..5914d100ef0 100644 --- a/ui/storybook/stories/credential-access/02-task-page.stories.tsx +++ b/ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { CredentialTaskPage } from "./CredentialTaskPage"; +import { CredentialTaskPage } from "../../../support/credential-access/CredentialTaskPage"; const meta = { - title: "Task credential access/02 Task page", + id: "task-credential-access-02-task-page", + title: "Apps, channels, and extensions/Secrets and proposals/Task credential access/02 Task page", component: CredentialTaskPage, parameters: { layout: "fullscreen", waitForViewport: true, docs: { description: { component: "Production task detail and app shell. The latest credential failure stays expanded in the conversation and names the selected credential. The settings link uses the existing agent runtime route. Example data is local to Storybook." } }, diff --git a/ui/storybook/stories/environment-variables-editor.stories.tsx b/ui/storybook/stories/secrets/environment-variables-editor.stories.tsx similarity index 98% rename from ui/storybook/stories/environment-variables-editor.stories.tsx rename to ui/storybook/stories/secrets/environment-variables-editor.stories.tsx index b84afbd1801..e0aaa83dc11 100644 --- a/ui/storybook/stories/environment-variables-editor.stories.tsx +++ b/ui/storybook/stories/secrets/environment-variables-editor.stories.tsx @@ -91,7 +91,8 @@ function Editor({ } const meta: Meta = { - title: "Product/Environment Variables Editor", + id: "product-environment-variables-editor", + title: "Apps, channels, and extensions/Secrets and proposals/Product/Environment Variables Editor", parameters: { layout: "fullscreen", a11y: { test: "off" } }, }; export default meta; diff --git a/ui/storybook/stories/secrets.stories.tsx b/ui/storybook/stories/secrets/secrets.stories.tsx similarity index 98% rename from ui/storybook/stories/secrets.stories.tsx rename to ui/storybook/stories/secrets/secrets.stories.tsx index 27fb6592397..fb039058f1f 100644 --- a/ui/storybook/stories/secrets.stories.tsx +++ b/ui/storybook/stories/secrets/secrets.stories.tsx @@ -15,7 +15,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Badge } from "@/components/ui/badge"; import { useCompany } from "@/context/CompanyContext"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookCompanies, storybookSecrets } from "../fixtures/paperclipData"; +import { storybookCompanies, storybookSecrets } from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; @@ -118,7 +118,8 @@ function StorybookSecretsFixtures({ children }: { children: ReactNode }) { } const meta: Meta = { - title: "Product/Secrets", + id: "product-secrets", + title: "Apps, channels, and extensions/Secrets and proposals/Product/Secrets", parameters: { layout: "fullscreen", a11y: { diff --git a/ui/storybook/stories/user-secrets.stories.tsx b/ui/storybook/stories/secrets/user-secrets.stories.tsx similarity index 98% rename from ui/storybook/stories/user-secrets.stories.tsx rename to ui/storybook/stories/secrets/user-secrets.stories.tsx index 2244ba4a20e..43544b079e0 100644 --- a/ui/storybook/stories/user-secrets.stories.tsx +++ b/ui/storybook/stories/secrets/user-secrets.stories.tsx @@ -140,7 +140,8 @@ function Section({ title, children }: { title: string; children: ReactNode }) { } const meta: Meta = { - title: "Product/User secrets", + id: "product-user-secrets", + title: "Apps, channels, and extensions/Secrets and proposals/Product/User secrets", parameters: { layout: "fullscreen", a11y: { test: "off" } }, }; export default meta; diff --git a/ui/storybook/stories/agent-skill-row.stories.tsx b/ui/storybook/stories/skills/agent-skill-row.stories.tsx similarity index 95% rename from ui/storybook/stories/agent-skill-row.stories.tsx rename to ui/storybook/stories/skills/agent-skill-row.stories.tsx index fef898a117d..c90f4e411f2 100644 --- a/ui/storybook/stories/agent-skill-row.stories.tsx +++ b/ui/storybook/stories/skills/agent-skill-row.stories.tsx @@ -42,7 +42,8 @@ const READONLY: AgentSkillRowData = { }; const meta: Meta = { - title: "Agent Skills/AgentSkillRow", + id: "agent-skills-agentskillrow", + title: "Agents and reusable capabilities/Skills and Skill Studio/Agent Skills/AgentSkillRow", component: AgentSkillRow, parameters: { layout: "padded" }, decorators: [ diff --git a/ui/storybook/stories/agent-skills-edge.stories.tsx b/ui/storybook/stories/skills/agent-skills-edge.stories.tsx similarity index 98% rename from ui/storybook/stories/agent-skills-edge.stories.tsx rename to ui/storybook/stories/skills/agent-skills-edge.stories.tsx index ff04589393c..939846b4fdd 100644 --- a/ui/storybook/stories/agent-skills-edge.stories.tsx +++ b/ui/storybook/stories/skills/agent-skills-edge.stories.tsx @@ -192,7 +192,8 @@ function EdgeState({ agent, snapshot }: { agent: Agent; snapshot: AgentSkillSnap } const meta: Meta = { - title: "QA / Agent skills edge states", + id: "qa-agent-skills-edge-states", + title: "Agents and reusable capabilities/Skills and Skill Studio/QA / Agent skills edge states", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/agents-using-skill.stories.tsx b/ui/storybook/stories/skills/agents-using-skill.stories.tsx similarity index 97% rename from ui/storybook/stories/agents-using-skill.stories.tsx rename to ui/storybook/stories/skills/agents-using-skill.stories.tsx index f024c2283af..d813286384f 100644 --- a/ui/storybook/stories/agents-using-skill.stories.tsx +++ b/ui/storybook/stories/skills/agents-using-skill.stories.tsx @@ -119,7 +119,8 @@ function Seed({ skill, children }: { skill: CompanySkillDetail; children: ReactN } const meta: Meta = { - title: "Skill Studio/AgentsUsingSkill", + id: "skill-studio-agentsusingskill", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/AgentsUsingSkill", parameters: { layout: "centered" }, }; export default meta; diff --git a/ui/storybook/stories/fork-skill-flow.stories.tsx b/ui/storybook/stories/skills/fork-skill-flow.stories.tsx similarity index 98% rename from ui/storybook/stories/fork-skill-flow.stories.tsx rename to ui/storybook/stories/skills/fork-skill-flow.stories.tsx index 1c250e0bd8c..c635419c204 100644 --- a/ui/storybook/stories/fork-skill-flow.stories.tsx +++ b/ui/storybook/stories/skills/fork-skill-flow.stories.tsx @@ -152,7 +152,8 @@ function Seed({ } const meta: Meta = { - title: "Skill Studio/EditACopy", + id: "skill-studio-editacopy", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/EditACopy", parameters: { layout: "centered" }, }; export default meta; diff --git a/ui/storybook/stories/frontmatter-panel.stories.tsx b/ui/storybook/stories/skills/frontmatter-panel.stories.tsx similarity index 97% rename from ui/storybook/stories/frontmatter-panel.stories.tsx rename to ui/storybook/stories/skills/frontmatter-panel.stories.tsx index f4f510bc26d..a09ae7bb3eb 100644 --- a/ui/storybook/stories/frontmatter-panel.stories.tsx +++ b/ui/storybook/stories/skills/frontmatter-panel.stories.tsx @@ -66,7 +66,8 @@ metadata: version: 2`; const meta: Meta = { - title: "Product/Skill Studio · FrontmatterPanel", + id: "product-skill-studio-·-frontmatterpanel", + title: "Agents and reusable capabilities/Skills and Skill Studio/Product/Skill Studio · FrontmatterPanel", component: Harness, parameters: { layout: "padded" }, }; diff --git a/ui/storybook/stories/github-skills-journey.stories.tsx b/ui/storybook/stories/skills/github-skills-journey.stories.tsx similarity index 97% rename from ui/storybook/stories/github-skills-journey.stories.tsx rename to ui/storybook/stories/skills/github-skills-journey.stories.tsx index fba52de7597..aad4fb527d5 100644 --- a/ui/storybook/stories/github-skills-journey.stories.tsx +++ b/ui/storybook/stories/skills/github-skills-journey.stories.tsx @@ -11,7 +11,7 @@ import { SkillStudio } from "@/pages/SkillStudio"; import { PluginLauncherProvider } from "@/plugins/launchers"; import { Link, Route, Routes, useNavigate } from "@/lib/router"; import { queryKeys } from "@/lib/queryKeys"; -import { COMPANY_ID, COMMIT, installFixtures, repositoryCandidates, repositoryFixture, SOURCE_ID } from "../fixtures/githubSkillSources"; +import { COMPANY_ID, COMMIT, installFixtures, repositoryCandidates, repositoryFixture, SOURCE_ID } from "../../fixtures/githubSkillSources"; type Step = "start" | "repository" | "selection" | "imported" | "library" | "detail" | "agents" | "assigned" | "refresh" | "new-skills" | "scanning" | "saving"; const routes: Record = { @@ -89,7 +89,8 @@ export function GitHubSkillsJourney({ step = "start", packageScenario }: { step? } const meta = { - title: "Skills/GitHub sync journey", + id: "skills-github-sync-journey", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub sync journey", component: GitHubSkillsJourney, tags: ["!autodocs"], parameters: { diff --git a/ui/storybook/stories/github-synced-skills.stories.tsx b/ui/storybook/stories/skills/github-synced-skills.stories.tsx similarity index 97% rename from ui/storybook/stories/github-synced-skills.stories.tsx rename to ui/storybook/stories/skills/github-synced-skills.stories.tsx index f73dcd99fa1..5fbf0aa45ad 100644 --- a/ui/storybook/stories/github-synced-skills.stories.tsx +++ b/ui/storybook/stories/skills/github-synced-skills.stories.tsx @@ -1,7 +1,7 @@ import { userEvent, within } from "storybook/test"; import { useEffect, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { COMPANY_ID, SOURCE_ID, installFixtures, type RepositoryScenario } from "../fixtures/githubSkillSources"; +import { COMPANY_ID, SOURCE_ID, installFixtures, type RepositoryScenario } from "../../fixtures/githubSkillSources"; import { Link, Route, Routes, useNavigate } from "@/lib/router"; import { SkillSources } from "@/pages/SkillSources"; @@ -33,7 +33,8 @@ function SourcesStory({ view = "sources", empty = false, needsConnection = false } const meta = { - title: "Skills/GitHub synced skills", + id: "skills-github-synced-skills", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub synced skills", component: SourcesStory, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/repository-skill-packages/repository-skill-packages.stories.tsx b/ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx similarity index 93% rename from ui/storybook/stories/repository-skill-packages/repository-skill-packages.stories.tsx rename to ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx index 3e8cb341a14..baf30f5e971 100644 --- a/ui/storybook/stories/repository-skill-packages/repository-skill-packages.stories.tsx +++ b/ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx @@ -3,7 +3,8 @@ import { userEvent, within } from 'storybook/test'; import { GitHubSkillsJourney } from '../github-skills-journey.stories'; const meta = { - title: 'Skills/Repository packages', component: GitHubSkillsJourney, + id: "skills-repository-packages", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skills/Repository packages", component: GitHubSkillsJourney, tags: ['!autodocs'], parameters: { layout: 'fullscreen', docs: { description: { component: 'Production Skills UI in the app shell with repository fixtures. Repository files stay together. Select which skills appear in your library. No GitHub access or runtime execution occurs in this preview.' } } }, args: { packageScenario: 'ready' }, diff --git a/ui/storybook/stories/skill-import-progress.stories.tsx b/ui/storybook/stories/skills/skill-import-progress.stories.tsx similarity index 90% rename from ui/storybook/stories/skill-import-progress.stories.tsx rename to ui/storybook/stories/skills/skill-import-progress.stories.tsx index fc1d8dda02b..77a177d657d 100644 --- a/ui/storybook/stories/skill-import-progress.stories.tsx +++ b/ui/storybook/stories/skills/skill-import-progress.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { SkillImportProgress } from '@/pages/skills/SkillImportProgress'; -import { candidates } from '../fixtures/githubSkillSources'; +import { candidates } from '../../fixtures/githubSkillSources'; const meta = { - title: 'Skills/Import progress', + id: "skills-import-progress", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skills/Import progress", component: SkillImportProgress, args: { repository: 'acme/team-skills' }, decorators: [Story =>
], diff --git a/ui/storybook/stories/skill-policy-surfaces.stories.tsx b/ui/storybook/stories/skills/skill-policy-surfaces.stories.tsx similarity index 94% rename from ui/storybook/stories/skill-policy-surfaces.stories.tsx rename to ui/storybook/stories/skills/skill-policy-surfaces.stories.tsx index 1b3356d2c1d..11b17e2daee 100644 --- a/ui/storybook/stories/skill-policy-surfaces.stories.tsx +++ b/ui/storybook/stories/skills/skill-policy-surfaces.stories.tsx @@ -34,7 +34,8 @@ function Frame({ label, width, children }: { label: string; width: number; child } const meta = { - title: "Product/Skills/Policy surfaces", + id: "product-skills-policy-surfaces", + title: "Agents and reusable capabilities/Skills and Skill Studio/Product/Skills/Policy surfaces", component: SkillPolicyDenialNotice, args: { denial: policyDenial() }, parameters: { diff --git a/ui/storybook/stories/skills-store-detail.stories.tsx b/ui/storybook/stories/skills/skills-store-detail.stories.tsx similarity index 98% rename from ui/storybook/stories/skills-store-detail.stories.tsx rename to ui/storybook/stories/skills/skills-store-detail.stories.tsx index 7d91ba56b09..1661541bb91 100644 --- a/ui/storybook/stories/skills-store-detail.stories.tsx +++ b/ui/storybook/stories/skills/skills-store-detail.stories.tsx @@ -200,7 +200,8 @@ function SkillDetailHarness({ initialTab = "overview" as DetailTab }: { initialT } const meta: Meta = { - title: "Skills Store/Skill detail", + id: "skills-store-skill-detail", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Skill detail", component: SkillDetailHarness, parameters: { layout: "fullscreen" }, }; diff --git a/ui/storybook/stories/skills-store-discovery.stories.tsx b/ui/storybook/stories/skills/skills-store-discovery.stories.tsx similarity index 98% rename from ui/storybook/stories/skills-store-discovery.stories.tsx rename to ui/storybook/stories/skills/skills-store-discovery.stories.tsx index 2bcb7c2c988..3ffb36ab6db 100644 --- a/ui/storybook/stories/skills-store-discovery.stories.tsx +++ b/ui/storybook/stories/skills/skills-store-discovery.stories.tsx @@ -306,7 +306,8 @@ function DiscoveryGridHarness({ } const meta: Meta = { - title: "Skills Store/Discovery grid", + id: "skills-store-discovery-grid", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Discovery grid", component: DiscoveryGridHarness, parameters: { layout: "fullscreen" }, }; diff --git a/ui/storybook/stories/composer-queued-messages.stories.tsx b/ui/storybook/stories/steering/composer-queued-messages.stories.tsx similarity index 96% rename from ui/storybook/stories/composer-queued-messages.stories.tsx rename to ui/storybook/stories/steering/composer-queued-messages.stories.tsx index b2dfe5a561b..d23c3ba4c9c 100644 --- a/ui/storybook/stories/composer-queued-messages.stories.tsx +++ b/ui/storybook/stories/steering/composer-queued-messages.stories.tsx @@ -4,7 +4,7 @@ import { expect, userEvent, within } from "storybook/test"; import type { IssueQueuedCommentEntry, IssueQueuedCommentQueue } from "@paperclipai/shared"; import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages"; -import { composerAgentAppearance, composerAgents } from "../prototypes/composer-model-picker/fixtures"; +import { composerAgentAppearance, composerAgents } from "../../prototypes/composer-model-picker/fixtures"; const agentMap = new Map(composerAgents.map((agent) => [agent.id, { id: agent.id, @@ -127,7 +127,8 @@ function QueuedComposer({ protocol, steeringDisposition, initialEdit, mobile }: } const meta = { - title: "Composer/Steering and queued messages", + id: "composer-steering-and-queued-messages", + title: "Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages", component: QueuedComposer, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/interrupt-handoff.stories.tsx b/ui/storybook/stories/steering/interrupt-handoff.stories.tsx similarity index 98% rename from ui/storybook/stories/interrupt-handoff.stories.tsx rename to ui/storybook/stories/steering/interrupt-handoff.stories.tsx index 9c1f2f0c9dc..dd15023b494 100644 --- a/ui/storybook/stories/interrupt-handoff.stories.tsx +++ b/ui/storybook/stories/steering/interrupt-handoff.stories.tsx @@ -101,7 +101,8 @@ function ActivityCard({ } const meta: Meta = { - title: "Surfaces/Interrupt Handoff", + id: "surfaces-interrupt-handoff", + title: "Tasks and conversations/Steering and queued messages/Surfaces/Interrupt Handoff", }; export default meta; diff --git a/ui/storybook/stories/paused-composer.stories.tsx b/ui/storybook/stories/steering/paused-composer.stories.tsx similarity index 85% rename from ui/storybook/stories/paused-composer.stories.tsx rename to ui/storybook/stories/steering/paused-composer.stories.tsx index 83670ad3fa4..e5582e64078 100644 --- a/ui/storybook/stories/paused-composer.stories.tsx +++ b/ui/storybook/stories/steering/paused-composer.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PausedComposerPreview } from "../prototypes/PausedTaskComposer"; +import { PausedComposerPreview } from "../../prototypes/PausedTaskComposer"; const meta = { - title: "Composer/Paused task takeover", + id: "composer-paused-task-takeover", + title: "Tasks and conversations/Steering and queued messages/Composer/Paused task takeover", component: PausedComposerPreview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/task-execution-controls.stories.tsx b/ui/storybook/stories/steering/task-execution-controls.stories.tsx similarity index 99% rename from ui/storybook/stories/task-execution-controls.stories.tsx rename to ui/storybook/stories/steering/task-execution-controls.stories.tsx index 27d72d43405..735fff914a2 100644 --- a/ui/storybook/stories/task-execution-controls.stories.tsx +++ b/ui/storybook/stories/steering/task-execution-controls.stories.tsx @@ -318,7 +318,8 @@ function TaskExecutionExample({ } const meta = { - title: "Composer/Execution controls", + id: "composer-execution-controls", + title: "Tasks and conversations/Steering and queued messages/Composer/Execution controls", component: TaskExecutionExample, parameters: { layout: "padded" }, } satisfies Meta; diff --git a/ui/storybook/stories/issue-review-panel.stories.tsx b/ui/storybook/stories/task-coordination/issue-review-panel.stories.tsx similarity index 95% rename from ui/storybook/stories/issue-review-panel.stories.tsx rename to ui/storybook/stories/task-coordination/issue-review-panel.stories.tsx index 7aada79bd05..22638d9abf0 100644 --- a/ui/storybook/stories/issue-review-panel.stories.tsx +++ b/ui/storybook/stories/task-coordination/issue-review-panel.stories.tsx @@ -83,7 +83,8 @@ const stalledReviewRow: AttentionItem = { }; const meta = { - title: "Product/Decisions/Stalled review actions", + id: "product-decisions-stalled-review-actions", + title: "Tasks and conversations/Delegation, dependencies, and signoff/Product/Decisions/Stalled review actions", component: AttentionQueueRow, args: { item: stalledReviewRow, diff --git a/ui/storybook/stories/monitor-surfaces.stories.tsx b/ui/storybook/stories/task-coordination/monitor-surfaces.stories.tsx similarity index 98% rename from ui/storybook/stories/monitor-surfaces.stories.tsx rename to ui/storybook/stories/task-coordination/monitor-surfaces.stories.tsx index 92bc8b219d1..09357f19d7d 100644 --- a/ui/storybook/stories/monitor-surfaces.stories.tsx +++ b/ui/storybook/stories/task-coordination/monitor-surfaces.stories.tsx @@ -6,7 +6,7 @@ import { storybookExecutionWorkspaces, storybookIssueDocuments, storybookIssues, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const issueDocumentSummaries = storybookIssueDocuments.map(({ body: _body, ...summary }) => summary); @@ -310,7 +310,8 @@ function MonitorSurfaceStories() { } const meta = { - title: "Product/Issue Monitor surfaces", + id: "product-issue-monitor-surfaces", + title: "Tasks and conversations/Delegation, dependencies, and signoff/Product/Issue Monitor surfaces", component: MonitorSurfaceStories, parameters: { docs: { diff --git a/ui/storybook/stories/originating-tasks.stories.tsx b/ui/storybook/stories/task-coordination/originating-tasks.stories.tsx similarity index 91% rename from ui/storybook/stories/originating-tasks.stories.tsx rename to ui/storybook/stories/task-coordination/originating-tasks.stories.tsx index 4d4ee451945..a7932689b58 100644 --- a/ui/storybook/stories/originating-tasks.stories.tsx +++ b/ui/storybook/stories/task-coordination/originating-tasks.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { PluginLauncherProvider } from "@/plugins/launchers"; -import { OriginatingTasksReview, SeedData } from "../prototypes/originating-tasks/OriginatingTasks"; +import { OriginatingTasksReview, SeedData } from "../../prototypes/originating-tasks/OriginatingTasks"; const meta = { - title: "UX Labs/Tasks Created From a Task", + id: "ux-labs-tasks-created-from-a-task", + title: "Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Tasks Created From a Task", component: OriginatingTasksReview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/sub-issues-workflow.stories.tsx b/ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx similarity index 97% rename from ui/storybook/stories/sub-issues-workflow.stories.tsx rename to ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx index 4f4132d440c..c35e4874b8b 100644 --- a/ui/storybook/stories/sub-issues-workflow.stories.tsx +++ b/ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx @@ -11,7 +11,7 @@ import { storybookCompanies, storybookIssueLabels, storybookProjects, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const companyId = "company-storybook"; const parentId = "issue-pap-1953"; @@ -254,7 +254,8 @@ function SubIssuesWorkflowPanel() { } const meta = { - title: "UX Labs/Sub-issues Workflow Checklist", + id: "ux-labs-sub-issues-workflow-checklist", + title: "Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Sub-issues Workflow Checklist", component: SubIssuesWorkflowPanel, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/task-watchdog-surfaces.stories.tsx b/ui/storybook/stories/task-coordination/task-watchdog-surfaces.stories.tsx similarity index 95% rename from ui/storybook/stories/task-watchdog-surfaces.stories.tsx rename to ui/storybook/stories/task-coordination/task-watchdog-surfaces.stories.tsx index 0ca142fa57b..3530f65f1db 100644 --- a/ui/storybook/stories/task-watchdog-surfaces.stories.tsx +++ b/ui/storybook/stories/task-coordination/task-watchdog-surfaces.stories.tsx @@ -5,7 +5,7 @@ import { storybookExecutionWorkspaces, storybookIssueDocuments, storybookIssues, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const issueDocumentSummaries = storybookIssueDocuments.map(({ body: _body, ...summary }) => summary); @@ -102,7 +102,8 @@ function WatchdogSurfaceStories() { } const meta = { - title: "Product/Task Watchdog surfaces", + id: "product-task-watchdog-surfaces", + title: "Tasks and conversations/Delegation, dependencies, and signoff/Product/Task Watchdog surfaces", component: WatchdogSurfaceStories, parameters: { docs: { diff --git a/ui/storybook/stories/assigned-backlog-safeguards.stories.tsx b/ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx similarity index 97% rename from ui/storybook/stories/assigned-backlog-safeguards.stories.tsx rename to ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx index 47f40026620..78680e64901 100644 --- a/ui/storybook/stories/assigned-backlog-safeguards.stories.tsx +++ b/ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx @@ -5,7 +5,7 @@ import type { IssueRelationIssueSummary } from "@paperclipai/shared"; import { IssueAssignedBacklogNotice } from "@/components/IssueAssignedBacklogNotice"; import { IssueBlockedNotice } from "@/components/IssueBlockedNotice"; import { IssueRow } from "@/components/IssueRow"; -import { storybookAgents, createIssue } from "../fixtures/paperclipData"; +import { storybookAgents, createIssue } from "../../fixtures/paperclipData"; const codexAgent = storybookAgents.find((agent) => agent.id === "agent-codex") ?? storybookAgents[0]!; const qaAgent = storybookAgents.find((agent) => agent.id === "agent-qa") ?? storybookAgents[0]!; @@ -205,7 +205,8 @@ function AllStates() { } const meta = { - title: "Paperclip/Assigned Backlog Safeguards", + id: "paperclip-assigned-backlog-safeguards", + title: "Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards", component: AllStates, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/chat-comments.stories.tsx b/ui/storybook/stories/tasks/chat-comments.stories.tsx similarity index 98% rename from ui/storybook/stories/chat-comments.stories.tsx rename to ui/storybook/stories/tasks/chat-comments.stories.tsx index 8762c4d1f1b..ba2bbd8d0cc 100644 --- a/ui/storybook/stories/chat-comments.stories.tsx +++ b/ui/storybook/stories/tasks/chat-comments.stories.tsx @@ -16,7 +16,7 @@ import type { IssueChatTranscriptEntry, } from "@/lib/issue-chat-messages"; import type { IssueTimelineEvent } from "@/lib/issue-timeline-events"; -import { storybookAgentMap, storybookAgents } from "../fixtures/paperclipData"; +import { storybookAgentMap, storybookAgents } from "../../fixtures/paperclipData"; const companyId = "company-storybook"; const projectId = "project-board-ui"; @@ -361,7 +361,7 @@ const liveRunTranscript: TranscriptEntry[] = [ name: "apply_patch", toolUseId: "tool-live-patch", input: { - file: "ui/storybook/stories/chat-comments.stories.tsx", + file: "ui/storybook/stories/tasks/chat-comments.stories.tsx", action: "add fixtures", }, }, @@ -557,7 +557,7 @@ const issueTranscriptsByRunId = new Map [agent.id, { id: agent.id, @@ -108,7 +108,8 @@ function ComposerAddStory({ initialMode, goalAvailable, mobile, mobileContext, f } const meta = { - title: "Composer/Add menu", + id: "composer-add-menu", + title: "Tasks and conversations/Task creation and lifecycle/Composer/Add menu", component: ComposerAddStory, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/composer-mobile-assignee.stories.tsx b/ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx similarity index 84% rename from ui/storybook/stories/composer-mobile-assignee.stories.tsx rename to ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx index 1f5f8d70b17..d72f59ff0b7 100644 --- a/ui/storybook/stories/composer-mobile-assignee.stories.tsx +++ b/ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, within } from "storybook/test"; -import { ComposerRunSettingsLiveStory } from "../prototypes/composer-model-picker/ComposerRunSettingsLiveStory"; +import { ComposerRunSettingsLiveStory } from "../../prototypes/composer-model-picker/ComposerRunSettingsLiveStory"; const meta = { - title: "Composer/Mobile assignee picker", + id: "composer-mobile-assignee-picker", + title: "Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker", component: ComposerRunSettingsLiveStory, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/composer-model-picker.stories.tsx b/ui/storybook/stories/tasks/composer-model-picker.stories.tsx similarity index 98% rename from ui/storybook/stories/composer-model-picker.stories.tsx rename to ui/storybook/stories/tasks/composer-model-picker.stories.tsx index b99938b62fe..6f3ff357324 100644 --- a/ui/storybook/stories/composer-model-picker.stories.tsx +++ b/ui/storybook/stories/tasks/composer-model-picker.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { ComposerModelPickerPreview } from "../prototypes/composer-model-picker/ComposerModelPickerPreview"; -import { ComposerRunSettingsLiveStory } from "../prototypes/composer-model-picker/ComposerRunSettingsLiveStory"; +import { ComposerModelPickerPreview } from "../../prototypes/composer-model-picker/ComposerModelPickerPreview"; +import { ComposerRunSettingsLiveStory } from "../../prototypes/composer-model-picker/ComposerRunSettingsLiveStory"; const meta = { - title: "Composer/Model and effort picker", + id: "composer-model-and-effort-picker", + title: "Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker", component: ComposerModelPickerPreview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/control-plane-surfaces.stories.tsx b/ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx similarity index 98% rename from ui/storybook/stories/control-plane-surfaces.stories.tsx rename to ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx index 98e4a1fc5cb..13b559243f3 100644 --- a/ui/storybook/stories/control-plane-surfaces.stories.tsx +++ b/ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx @@ -18,7 +18,7 @@ import { storybookEntityNameMap, storybookEntityTitleMap, storybookIssues, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; function Section({ eyebrow, @@ -247,7 +247,8 @@ function ControlPlaneSurfaces() { } const meta = { - title: "Product/Control Plane Surfaces", + id: "product-control-plane-surfaces", + title: "Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces", component: ControlPlaneSurfaces, parameters: { docs: { diff --git a/ui/storybook/stories/dialogs-modals.stories.tsx b/ui/storybook/stories/tasks/dialogs-modals.stories.tsx similarity index 99% rename from ui/storybook/stories/dialogs-modals.stories.tsx rename to ui/storybook/stories/tasks/dialogs-modals.stories.tsx index 60d7ba8063b..3eb5327e95f 100644 --- a/ui/storybook/stories/dialogs-modals.stories.tsx +++ b/ui/storybook/stories/tasks/dialogs-modals.stories.tsx @@ -30,7 +30,7 @@ import { storybookIssueLabels, storybookIssues, storybookProjects, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; const SELECTED_COMPANY_STORAGE_KEY = "paperclip.selectedCompanyId"; @@ -674,7 +674,8 @@ function PathInstructionsModalStory() { } const meta = { - title: "Product/Dialogs & Modals", + id: "product-dialogs-modals", + title: "Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals", parameters: { docs: { description: { diff --git a/ui/storybook/stories/external-objects.stories.tsx b/ui/storybook/stories/tasks/external-objects.stories.tsx similarity index 99% rename from ui/storybook/stories/external-objects.stories.tsx rename to ui/storybook/stories/tasks/external-objects.stories.tsx index e0e91816dae..7e45ad9b13d 100644 --- a/ui/storybook/stories/external-objects.stories.tsx +++ b/ui/storybook/stories/tasks/external-objects.stories.tsx @@ -35,7 +35,7 @@ import { storybookIssueLabels, storybookIssues, storybookProjects, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; function makeObject(args: { id: string; @@ -672,7 +672,8 @@ function IntegrationSurfacesStory() { } const meta = { - title: "Foundations/External Objects", + id: "foundations-external-objects", + title: "Tasks and conversations/Task creation and lifecycle/External Objects", component: StateMatrixStory, parameters: { docs: { diff --git a/ui/storybook/stories/issue-management.stories.tsx b/ui/storybook/stories/tasks/issue-management.stories.tsx similarity index 99% rename from ui/storybook/stories/issue-management.stories.tsx rename to ui/storybook/stories/tasks/issue-management.stories.tsx index e1774d82d92..ae060fdb15d 100644 --- a/ui/storybook/stories/issue-management.stories.tsx +++ b/ui/storybook/stories/tasks/issue-management.stories.tsx @@ -49,7 +49,7 @@ import { storybookIssueRuns, storybookIssues, storybookProjects, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const companyId = "company-storybook"; const issueListViewKey = "storybook:issue-management:list"; @@ -872,7 +872,8 @@ function IssueManagementStories() { } const meta = { - title: "Product/Issue Management", + id: "product-issue-management", + title: "Tasks and conversations/Task creation and lifecycle/Product/Issue Management", component: IssueManagementStories, parameters: { docs: { diff --git a/ui/storybook/stories/mobile-entity-pickers.stories.tsx b/ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx similarity index 94% rename from ui/storybook/stories/mobile-entity-pickers.stories.tsx rename to ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx index ea06c277d49..f01c3efadc0 100644 --- a/ui/storybook/stories/mobile-entity-pickers.stories.tsx +++ b/ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx @@ -41,7 +41,8 @@ function OpenPicker({ kind, options }: { kind: "Assignee" | "Project"; options: } const meta = { - title: "Components/Entity pickers/Mobile", + id: "components-entity-pickers-mobile", + title: "Tasks and conversations/Task creation and lifecycle/Components/Entity pickers/Mobile", parameters: { layout: "fullscreen", viewport: { defaultViewport: "mobile1" }, diff --git a/ui/storybook/stories/new-task-composer.stories.tsx b/ui/storybook/stories/tasks/new-task-composer.stories.tsx similarity index 99% rename from ui/storybook/stories/new-task-composer.stories.tsx rename to ui/storybook/stories/tasks/new-task-composer.stories.tsx index 73fd9a8275d..5cebf9df7b6 100644 --- a/ui/storybook/stories/new-task-composer.stories.tsx +++ b/ui/storybook/stories/tasks/new-task-composer.stories.tsx @@ -25,7 +25,7 @@ import { storybookExecutionWorkspaces, storybookProjects, storybookIssues, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; const NEW_TASK_AGENTS = [...storybookAgents, { ...storybookAgents[2]!, id: "agent-ceo", name: "CEO", role: "ceo" }].map((agent) => @@ -203,7 +203,8 @@ function NewTaskStory({ } const meta = { - title: "Composer/New task", + id: "composer-new-task", + title: "Tasks and conversations/Task creation and lifecycle/Composer/New task", component: NewTaskStory, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/runner-activity.stories.tsx b/ui/storybook/stories/tasks/runner-activity.stories.tsx similarity index 93% rename from ui/storybook/stories/runner-activity.stories.tsx rename to ui/storybook/stories/tasks/runner-activity.stories.tsx index 17aea163b4f..829817578a9 100644 --- a/ui/storybook/stories/runner-activity.stories.tsx +++ b/ui/storybook/stories/tasks/runner-activity.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { MINIMAL_VIEWPORTS } from "storybook/viewport"; -import { RunnerActivityPreview } from "../prototypes/runner-activity/RunnerActivityPreview"; +import { RunnerActivityPreview } from "../../prototypes/runner-activity/RunnerActivityPreview"; const meta = { - title: "Tasks/Runner activity preview", + id: "tasks-runner-activity-preview", + title: "Tasks and conversations/Task creation and lifecycle/Tasks/Runner activity preview", component: RunnerActivityPreview, globals: { viewport: { value: "desktop", isRotated: false } }, parameters: { diff --git a/ui/storybook/stories/successful-run-handoff.stories.tsx b/ui/storybook/stories/tasks/successful-run-handoff.stories.tsx similarity index 97% rename from ui/storybook/stories/successful-run-handoff.stories.tsx rename to ui/storybook/stories/tasks/successful-run-handoff.stories.tsx index 18085a7dee4..5044a46281d 100644 --- a/ui/storybook/stories/successful-run-handoff.stories.tsx +++ b/ui/storybook/stories/tasks/successful-run-handoff.stories.tsx @@ -13,7 +13,7 @@ import { SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION, successfulRunHandoffActivityTone, } from "@/lib/successful-run-handoff"; -import { createIssue, storybookAgents } from "../fixtures/paperclipData"; +import { createIssue, storybookAgents } from "../../fixtures/paperclipData"; function ActivityExample({ action }: { action: string }) { const tone = successfulRunHandoffActivityTone(action); @@ -238,7 +238,8 @@ function SuccessfulRunHandoffEscalationComment() { } const meta = { - title: "Paperclip/Successful Run Handoff", + id: "paperclip-successful-run-handoff", + title: "Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff", component: SuccessfulRunHandoffStates, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/task-chat-chain-of-thought.stories.tsx b/ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx similarity index 97% rename from ui/storybook/stories/task-chat-chain-of-thought.stories.tsx rename to ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx index 4719d135fca..a2346dab7dd 100644 --- a/ui/storybook/stories/task-chat-chain-of-thought.stories.tsx +++ b/ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx @@ -159,7 +159,8 @@ function ReconnectingAlignmentReview() { } const meta = { - title: "Tasks/Task chat review fixes", + id: "tasks-task-chat-review-fixes", + title: "Tasks and conversations/Task creation and lifecycle/Tasks/Task chat review fixes", component: ChainOfThoughtReview, parameters: { layout: "padded" }, } satisfies Meta; diff --git a/ui/storybook/stories/task-chat-mobile-follow.stories.tsx b/ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx similarity index 95% rename from ui/storybook/stories/task-chat-mobile-follow.stories.tsx rename to ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx index 1fb4d9b3cad..9454036c6e6 100644 --- a/ui/storybook/stories/task-chat-mobile-follow.stories.tsx +++ b/ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx @@ -44,7 +44,8 @@ function MobileFollow() { } const meta = { - title: "Task chat/Mobile streaming follow", + id: "task-chat-mobile-streaming-follow", + title: "Tasks and conversations/Task creation and lifecycle/Task chat/Mobile streaming follow", component: MobileFollow, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/task-chat-system-notice.stories.tsx b/ui/storybook/stories/tasks/task-chat-system-notice.stories.tsx similarity index 94% rename from ui/storybook/stories/task-chat-system-notice.stories.tsx rename to ui/storybook/stories/tasks/task-chat-system-notice.stories.tsx index 553e8e24de4..13c138382d6 100644 --- a/ui/storybook/stories/task-chat-system-notice.stories.tsx +++ b/ui/storybook/stories/tasks/task-chat-system-notice.stories.tsx @@ -53,7 +53,8 @@ function StoryFrame({ item }: { item: TaskChatMessageItem }) { } const meta = { - title: "Product/Task chat/System notice", + id: "product-task-chat-system-notice", + title: "Tasks and conversations/Task creation and lifecycle/Product/Task chat/System notice", component: TaskChatSystemNotice, args: { item: workspaceReadyItem, diff --git a/ui/storybook/stories/task-context-notices.stories.tsx b/ui/storybook/stories/tasks/task-context-notices.stories.tsx similarity index 96% rename from ui/storybook/stories/task-context-notices.stories.tsx rename to ui/storybook/stories/tasks/task-context-notices.stories.tsx index f92b8ffaaac..945085fb208 100644 --- a/ui/storybook/stories/task-context-notices.stories.tsx +++ b/ui/storybook/stories/tasks/task-context-notices.stories.tsx @@ -45,7 +45,8 @@ function TaskContextNotices() { } const meta = { - title: "Tasks/Context notices", + id: "tasks-context-notices", + title: "Tasks and conversations/Task creation and lifecycle/Tasks/Context notices", component: TaskContextNotices, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/thread-human-identity/00-evidence.stories.tsx b/ui/storybook/stories/tasks/thread-human-identity/00-evidence.stories.tsx similarity index 85% rename from ui/storybook/stories/thread-human-identity/00-evidence.stories.tsx rename to ui/storybook/stories/tasks/thread-human-identity/00-evidence.stories.tsx index 13089428aeb..db6f71466bf 100644 --- a/ui/storybook/stories/thread-human-identity/00-evidence.stories.tsx +++ b/ui/storybook/stories/tasks/thread-human-identity/00-evidence.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { humanMessages, profiles } from "./fixtures"; +import { humanMessages, profiles } from "../../../support/thread-human-identity/fixtures"; function ReviewGuide() { return

Human identity in a mixed task conversation

@@ -10,5 +10,6 @@ function ReviewGuide() {

These are representative fixtures, not a copy of private task data. The mixed journey uses the production shell, adapter, thread, and composer. Swap the viewer to verify that ownership follows authorUserId. Full Conversation includes all fixture messages. Sending is local to the preview.

; } -export default { title: "Chat & Comments/Human identity/00 Review guide", component: ReviewGuide } satisfies Meta; +export default { id: "chat-comments-human-identity-00-review-guide", + title: "Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/00 Review guide", component: ReviewGuide } satisfies Meta; export const DataFlow: StoryObj = {}; diff --git a/ui/storybook/stories/thread-human-identity/01-components.stories.tsx b/ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx similarity index 88% rename from ui/storybook/stories/thread-human-identity/01-components.stories.tsx rename to ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx index db5af49ab97..f95ac6e2b64 100644 --- a/ui/storybook/stories/thread-human-identity/01-components.stories.tsx +++ b/ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx @@ -3,7 +3,7 @@ import { expect, within } from "storybook/test"; import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble"; import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter"; import type { TaskChatMessageItem } from "@/components/task-chat/task-chat-model"; -import { comments, profiles, alexId, samId } from "./fixtures"; +import { comments, profiles, alexId, samId } from "../../../support/thread-human-identity/fixtures"; function Bubble({ viewer = alexId, photo = null, unresolved = false }: { viewer?: string; photo?: string | null; unresolved?: boolean }) { const userProfileMap = new Map(profiles); @@ -12,7 +12,8 @@ function Bubble({ viewer = alexId, photo = null, unresolved = false }: { viewer? return
; } const meta = { - title: "Chat & Comments/Human identity/01 Components", component: Bubble, + id: "chat-comments-human-identity-01-components", + title: "Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/01 Components", component: Bubble, args: { viewer: alexId, photo: null }, parameters: { layout: "padded", docs: { description: { component: "Production adapter → model → TaskChatBubble → Identity. Other humans sit on the left on the same page surface as agents with their own name/photo; no-photo profiles use the shared initials fallback. Edit photo to exercise an uploaded Paperclip asset URL. External URLs use initials. The production avatar URL is taken from the company directory, never from the current viewer or the relay bot." } } }, } satisfies Meta; diff --git a/ui/storybook/stories/thread-human-identity/02-task-journey.stories.tsx b/ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx similarity index 93% rename from ui/storybook/stories/thread-human-identity/02-task-journey.stories.tsx rename to ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx index ae6dd2699b0..0fea4b3e70b 100644 --- a/ui/storybook/stories/thread-human-identity/02-task-journey.stories.tsx +++ b/ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx @@ -7,8 +7,8 @@ import { Layout } from "@/components/Layout"; import { TaskChatThread } from "@/components/TaskChatThread"; import { PluginLauncherProvider } from "@/plugins/launchers"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookAuthSession } from "../../fixtures/paperclipData"; -import { mixedComments as initialComments, profiles, alexId, samId, issueId, agentMap } from "./fixtures"; +import { storybookAuthSession } from "../../../fixtures/paperclipData"; +import { mixedComments as initialComments, profiles, alexId, samId, issueId, agentMap } from "../../../support/thread-human-identity/fixtures"; function Conversation({ viewer = alexId, fullHistory = false }: { viewer?: string; fullHistory?: boolean }) { const [comments, setComments] = useState(() => fullHistory ? initialComments : [...initialComments.slice(0, 2), ...initialComments.slice(-2)]); @@ -43,7 +43,8 @@ function Journey(props: { viewer?: string; fullHistory?: boolean }) {
; } const meta = { - title: "Chat & Comments/Human identity/02 Mixed-author journey", component: Journey, + id: "chat-comments-human-identity-02-mixed-author-journey", + title: "Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/02 Mixed-author journey", component: Journey, args: { viewer: alexId, fullHistory: false }, parameters: { layout: "fullscreen", initialEntries: ["/PAP/issues/human-identity-example"], docs: { story: { inline: false }, description: { component: "Representative turns from two humans and an agent in the production app shell and TaskChatThread, including its composer. Photos remain null so the shared initials fallback is visible. Swap the viewer: identity follows authorUserId. The default excerpt fits all three speakers in one view; Full Conversation includes all fixture messages. Sending is local to this preview." } } }, } satisfies Meta; diff --git a/ui/storybook/stories/team-catalog.stories.tsx b/ui/storybook/stories/teams/team-catalog.stories.tsx similarity index 99% rename from ui/storybook/stories/team-catalog.stories.tsx rename to ui/storybook/stories/teams/team-catalog.stories.tsx index 922f1cf304b..f4359fb4b38 100644 --- a/ui/storybook/stories/team-catalog.stories.tsx +++ b/ui/storybook/stories/teams/team-catalog.stories.tsx @@ -139,7 +139,8 @@ function Frame({ children, width = "max-w-3xl" }: { children: React.ReactNode; w } const meta: Meta = { - title: "Surfaces/Team Catalog", + id: "surfaces-team-catalog", + title: "Agents and reusable capabilities/Team packages and installation/Surfaces/Team Catalog", }; export default meta; type Story = StoryObj; diff --git a/ui/storybook/stories/browser-use-activity.stories.tsx b/ui/storybook/stories/workspaces/browser-use-activity.stories.tsx similarity index 94% rename from ui/storybook/stories/browser-use-activity.stories.tsx rename to ui/storybook/stories/workspaces/browser-use-activity.stories.tsx index 1554a7a8b64..bd359555029 100644 --- a/ui/storybook/stories/browser-use-activity.stories.tsx +++ b/ui/storybook/stories/workspaces/browser-use-activity.stories.tsx @@ -8,9 +8,10 @@ import { BrowserStoryProviders, InteractiveBrowserStory, mockBrowserUse, -} from "../fixtures/browser-use"; +} from "../../fixtures/browser-use"; const meta = { - title: "Tasks/Browser Use/Task activity", + id: "tasks-browser-use-task-activity", + title: "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity", component: TaskBrowserActivity, parameters: { layout: "centered", diff --git a/ui/storybook/stories/browser-use-footer.stories.tsx b/ui/storybook/stories/workspaces/browser-use-footer.stories.tsx similarity index 96% rename from ui/storybook/stories/browser-use-footer.stories.tsx rename to ui/storybook/stories/workspaces/browser-use-footer.stories.tsx index f62b4f75a5d..a51134420ff 100644 --- a/ui/storybook/stories/browser-use-footer.stories.tsx +++ b/ui/storybook/stories/workspaces/browser-use-footer.stories.tsx @@ -7,7 +7,7 @@ import type { TaskBrowser, } from "@paperclipai/shared"; import { TaskBrowserFooter } from "@/components/task-side-panel/TaskBrowserFooter"; -import { browserFixture } from "../fixtures/browser-use"; +import { browserFixture } from "../../fixtures/browser-use"; const now = Date.parse("2026-09-29T16:00:00Z"); const idle = (seconds: number): TaskBrowser => ({ ...browserFixture, @@ -15,7 +15,8 @@ const idle = (seconds: number): TaskBrowser => ({ idleDeadline: new Date(now + seconds * 1000).toISOString(), }); const meta = { - title: "Tasks/Browser Use/Footer", + id: "tasks-browser-use-footer", + title: "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer", component: TaskBrowserFooter, parameters: { layout: "centered", diff --git a/ui/storybook/stories/browser-use.stories.tsx b/ui/storybook/stories/workspaces/browser-use.stories.tsx similarity index 97% rename from ui/storybook/stories/browser-use.stories.tsx rename to ui/storybook/stories/workspaces/browser-use.stories.tsx index 608054f1dbc..f132beed510 100644 --- a/ui/storybook/stories/browser-use.stories.tsx +++ b/ui/storybook/stories/workspaces/browser-use.stories.tsx @@ -4,17 +4,18 @@ import { expect, userEvent, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { TaskSidePanel } from "@/components/task-side-panel/TaskSidePanel"; import { TaskBrowserPanel } from "@/components/task-side-panel/TaskBrowserPanel"; -import { storybookIssues } from "../fixtures/paperclipData"; +import { storybookIssues } from "../../fixtures/paperclipData"; import { BrowserStoryProviders, InteractiveBrowserStory, browserFixture, browserState, mockBrowserUse, -} from "../fixtures/browser-use"; +} from "../../fixtures/browser-use"; const meta = { - title: "Tasks/Browser Use/Panel", + id: "tasks-browser-use-panel", + title: "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel", component: TaskBrowserPanel, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/searchable-select.stories.tsx b/ui/storybook/stories/workspaces/searchable-select.stories.tsx similarity index 98% rename from ui/storybook/stories/searchable-select.stories.tsx rename to ui/storybook/stories/workspaces/searchable-select.stories.tsx index 12d657245e8..1600f40fcaa 100644 --- a/ui/storybook/stories/searchable-select.stories.tsx +++ b/ui/storybook/stories/workspaces/searchable-select.stories.tsx @@ -185,7 +185,8 @@ function FormContext({ triggerClassName }: { triggerClassName?: string }) { } const meta = { - title: "Components/SearchableSelect/Workspace picker", + id: "components-searchableselect-workspace-picker", + title: "Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker", parameters: { layout: "centered" }, } satisfies Meta; diff --git a/ui/storybook/stories/workspace-file-browser.stories.tsx b/ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx similarity index 98% rename from ui/storybook/stories/workspace-file-browser.stories.tsx rename to ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx index b33d8eda98f..72bc848e765 100644 --- a/ui/storybook/stories/workspace-file-browser.stories.tsx +++ b/ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx @@ -197,7 +197,8 @@ function Placement() { } const meta: Meta = { - title: "Issue/Workspace File Browser", + id: "issue-workspace-file-browser", + title: "Projects and execution/Workspaces, services, and files/Issue/Workspace File Browser", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/workspace-runtime-provisioning.stories.tsx b/ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx similarity index 95% rename from ui/storybook/stories/workspace-runtime-provisioning.stories.tsx rename to ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx index edfe1f6ccf6..7dfdcc6c46d 100644 --- a/ui/storybook/stories/workspace-runtime-provisioning.stories.tsx +++ b/ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx @@ -37,7 +37,8 @@ function ContextCard({ status }: { status: RuntimeProvisionStatus }) { } const meta: Meta = { - title: "Workspaces/Runtime provisioning status", + id: "workspaces-runtime-provisioning-status", + title: "Projects and execution/Workspaces, services, and files/Workspaces/Runtime provisioning status", component: RuntimeProvisionStatusValue, parameters: { layout: "padded" }, }; diff --git a/ui/storybook/stories/workspace-service-control-bar.stories.tsx b/ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx similarity index 97% rename from ui/storybook/stories/workspace-service-control-bar.stories.tsx rename to ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx index 62aef3243a3..f542218f387 100644 --- a/ui/storybook/stories/workspace-service-control-bar.stories.tsx +++ b/ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx @@ -20,7 +20,8 @@ function entry(overrides: Partial = {}): Workspace } const meta: Meta = { - title: "Workspaces/Service control bar", + id: "workspaces-service-control-bar", + title: "Projects and execution/Workspaces, services, and files/Workspaces/Service control bar", component: WorkspaceServiceControlBar, parameters: { layout: "padded", diff --git a/ui/storybook/stories/artifact-cards/README.md b/ui/storybook/support/artifact-cards/README.md similarity index 96% rename from ui/storybook/stories/artifact-cards/README.md rename to ui/storybook/support/artifact-cards/README.md index 76d9d8f0d5b..6734b88c4be 100644 --- a/ui/storybook/stories/artifact-cards/README.md +++ b/ui/storybook/support/artifact-cards/README.md @@ -1,6 +1,6 @@ # Artifact cards -Storybook: **Explorations → Artifact cards**. Each story renders one production +Storybook: **Tasks and conversations → Documents, attachments, and work products → Explorations → Artifact cards**. Each story renders one production card from `ui/src/components/artifacts/RichArtifactCards.tsx`. All example titles, authors, content, filenames, repository names, and URLs live in story args. Use **Controls** to edit values, including Markdown bodies and JSON table data. diff --git a/ui/storybook/stories/artifact-cards/storyConfig.tsx b/ui/storybook/support/artifact-cards/storyConfig.tsx similarity index 100% rename from ui/storybook/stories/artifact-cards/storyConfig.tsx rename to ui/storybook/support/artifact-cards/storyConfig.tsx diff --git a/ui/storybook/stories/artifact-upload/UploadPreview.tsx b/ui/storybook/support/artifact-upload/UploadPreview.tsx similarity index 100% rename from ui/storybook/stories/artifact-upload/UploadPreview.tsx rename to ui/storybook/support/artifact-upload/UploadPreview.tsx diff --git a/ui/storybook/stories/artifact-upload/fixtures.ts b/ui/storybook/support/artifact-upload/fixtures.ts similarity index 100% rename from ui/storybook/stories/artifact-upload/fixtures.ts rename to ui/storybook/support/artifact-upload/fixtures.ts diff --git a/ui/storybook/stories/credential-access/CredentialTaskPage.tsx b/ui/storybook/support/credential-access/CredentialTaskPage.tsx similarity index 100% rename from ui/storybook/stories/credential-access/CredentialTaskPage.tsx rename to ui/storybook/support/credential-access/CredentialTaskPage.tsx diff --git a/ui/storybook/stories/decision-models/fixtures.ts b/ui/storybook/support/decision-models/fixtures.ts similarity index 100% rename from ui/storybook/stories/decision-models/fixtures.ts rename to ui/storybook/support/decision-models/fixtures.ts diff --git a/ui/storybook/stories/external-agent-invite/ExternalAgentInvitePreview.tsx b/ui/storybook/support/external-agent-invite/ExternalAgentInvitePreview.tsx similarity index 100% rename from ui/storybook/stories/external-agent-invite/ExternalAgentInvitePreview.tsx rename to ui/storybook/support/external-agent-invite/ExternalAgentInvitePreview.tsx diff --git a/ui/storybook/stories/external-agent-invite/fixtures.ts b/ui/storybook/support/external-agent-invite/fixtures.ts similarity index 100% rename from ui/storybook/stories/external-agent-invite/fixtures.ts rename to ui/storybook/support/external-agent-invite/fixtures.ts diff --git a/ui/storybook/stories/github-bot-management/fixture-api.test.tsx b/ui/storybook/support/github-bot-management/fixture-api.test.tsx similarity index 100% rename from ui/storybook/stories/github-bot-management/fixture-api.test.tsx rename to ui/storybook/support/github-bot-management/fixture-api.test.tsx diff --git a/ui/storybook/stories/github-bot-management/fixture-api.tsx b/ui/storybook/support/github-bot-management/fixture-api.tsx similarity index 100% rename from ui/storybook/stories/github-bot-management/fixture-api.tsx rename to ui/storybook/support/github-bot-management/fixture-api.tsx diff --git a/ui/storybook/stories/github-bot-management/fixtures.ts b/ui/storybook/support/github-bot-management/fixtures.ts similarity index 100% rename from ui/storybook/stories/github-bot-management/fixtures.ts rename to ui/storybook/support/github-bot-management/fixtures.ts diff --git a/ui/storybook/stories/html-artifacts/fixtures.ts b/ui/storybook/support/html-artifacts/fixtures.ts similarity index 100% rename from ui/storybook/stories/html-artifacts/fixtures.ts rename to ui/storybook/support/html-artifacts/fixtures.ts diff --git a/ui/storybook/stories/html-artifacts/report.html b/ui/storybook/support/html-artifacts/report.html similarity index 100% rename from ui/storybook/stories/html-artifacts/report.html rename to ui/storybook/support/html-artifacts/report.html diff --git a/ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx b/ui/storybook/support/primary-agent/PrimaryAgentStory.tsx similarity index 100% rename from ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx rename to ui/storybook/support/primary-agent/PrimaryAgentStory.tsx diff --git a/ui/storybook/stories/private-tasks/PrivacyStory.tsx b/ui/storybook/support/private-tasks/PrivacyStory.tsx similarity index 100% rename from ui/storybook/stories/private-tasks/PrivacyStory.tsx rename to ui/storybook/support/private-tasks/PrivacyStory.tsx diff --git a/ui/storybook/stories/public-mcp-presenter.tsx b/ui/storybook/support/public-mcp-presenter.tsx similarity index 100% rename from ui/storybook/stories/public-mcp-presenter.tsx rename to ui/storybook/support/public-mcp-presenter.tsx diff --git a/ui/storybook/stories/repository-skill-packages/README.md b/ui/storybook/support/repository-skill-packages/README.md similarity index 68% rename from ui/storybook/stories/repository-skill-packages/README.md rename to ui/storybook/support/repository-skill-packages/README.md index 738b47bd2c1..ffe1e47b799 100644 --- a/ui/storybook/stories/repository-skill-packages/README.md +++ b/ui/storybook/support/repository-skill-packages/README.md @@ -1,6 +1,6 @@ # Repository imports -Run the UI Storybook, then open **Skills → Repository packages**. These stories use the production pages and components with scoped API fixtures; they do not download GitHub repositories or invoke agents. +Run the UI Storybook, then open **Agents and reusable capabilities → Skills and Skill Studio → Skills → Repository packages**. These stories use the production pages and components with scoped API fixtures; they do not download GitHub repositories or invoke agents. - **01–03**: normal repository import and shared-file inspection. Every skill is visible and selected; package files start collapsed. - **04–05**: omitted files, collapsed and expanded warnings; import stays available. diff --git a/ui/storybook/stories/thread-human-identity/fixtures.ts b/ui/storybook/support/thread-human-identity/fixtures.ts similarity index 100% rename from ui/storybook/stories/thread-human-identity/fixtures.ts rename to ui/storybook/support/thread-human-identity/fixtures.ts diff --git a/ui/storybook/stories/workspace-branch-recovery/fixtures.ts b/ui/storybook/support/workspace-branch-recovery/fixtures.ts similarity index 100% rename from ui/storybook/stories/workspace-branch-recovery/fixtures.ts rename to ui/storybook/support/workspace-branch-recovery/fixtures.ts From 9b1b905e01445d1d46228ff8c9e45e3a8ab6c96a Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 12 Oct 2026 15:44:47 -0700 Subject: [PATCH 09/15] fix(heartbeat): answer an unsettled adapter Stop with a 409 instead of a 500 (#16032) An owned adapter Stop that is still settling when the 60s request wait expires now returns 409 adapter_stop_pending instead of escaping as an HTTP 500. Stop proof, lease and restore ownership are unchanged. Sentry is notified only when the same execution is still unsettled ten minutes after the Stop request (measured from stopRequestedAt), so real Stop hangs stay visible and slow workspace sync-back no longer pages. Co-Authored-By: Paperclip --- .../adapter-execution-control.test.ts | 70 ++++++++++++++++ .../src/services/adapter-execution-control.ts | 67 +++++++++++++--- .../services/heartbeat/run-control.test.ts | 79 ++++++++++++++++++- server/src/services/heartbeat/run-control.ts | 20 ++++- 4 files changed, 222 insertions(+), 14 deletions(-) diff --git a/server/src/services/adapter-execution-control.test.ts b/server/src/services/adapter-execution-control.test.ts index 7167e51f249..cc030434897 100644 --- a/server/src/services/adapter-execution-control.test.ts +++ b/server/src/services/adapter-execution-control.test.ts @@ -5,6 +5,7 @@ import { captureAdapterStopOwnership, createAdapterExecutionControl, registerAdapterExecutionControl, + reportAdapterStopIfUnsettled, waitForAdapterStop, } from "./adapter-execution-control.js"; @@ -204,3 +205,72 @@ it.each(["replaced", "wrong_run", "wrong_promise", "throwing_reader"])("omits pe other.finish(); adapterExecutionControls.delete(runId); }); + +it("reports a Stop once, with its pending phase, only when it stays unsettled", async () => { + vi.useFakeTimers(); + const runId = "11111111-1111-4111-8111-111111111111"; + const control = createAdapterExecutionControl(); + await registerAdapterExecutionControl(runId, control); + const report = vi.fn(); + try { + control.phases.enter("workspace_restore"); + reportAdapterStopIfUnsettled(control.settled, report, { runId, adapterType: "codex_local" }, control, { reportAfterMs: 5000 }); + // A repeated Stop for the same execution must not add another report. + reportAdapterStopIfUnsettled(control.settled, report, { runId }, control, { reportAfterMs: 5000 }); + await vi.advanceTimersByTimeAsync(4999); + expect(report).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(report).toHaveBeenCalledOnce(); + const error = report.mock.calls[0]![0] as AdapterStopTimeoutError; + expect(error).toBeInstanceOf(AdapterStopTimeoutError); + expect(error.diagnostics).toMatchObject({ + runId, adapterType: "codex_local", timeoutMs: 5000, phase: "workspace_restore", phaseElapsedMs: expect.any(Number), + }); + expect(vi.getTimerCount()).toBe(0); + expect(adapterExecutionControls.get(runId)).toBe(control); + } finally { + control.finish(); + adapterExecutionControls.delete(runId); + } +}); + +it("does not report a Stop that settles before the report bound", async () => { + vi.useFakeTimers(); + const control = createAdapterExecutionControl(); + const report = vi.fn(); + reportAdapterStopIfUnsettled(control.settled, report, undefined, control, { reportAfterMs: 5000 }); + await vi.advanceTimersByTimeAsync(1000); + control.finish(); + await control.settled; + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(10_000); + expect(report).not.toHaveBeenCalled(); +}); + +it("measures the report bound from the Stop request, including its response wait", async () => { + vi.useFakeTimers(); + const control = createAdapterExecutionControl(); + const report = vi.fn(); + const stopRequestedAt = Date.now(); + // The Stop request's own response wait elapses before the watch starts. + const responseWait = stopTimeout(waitForAdapterStop(control.settled, 2000)); + await vi.advanceTimersByTimeAsync(2000); + await responseWait; + reportAdapterStopIfUnsettled(control.settled, report, undefined, control, { stopRequestedAt, reportAfterMs: 5000 }); + await vi.advanceTimersByTimeAsync(2999); + expect(report).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(report).toHaveBeenCalledOnce(); + expect((report.mock.calls[0]![0] as AdapterStopTimeoutError).diagnostics).toMatchObject({ timeoutMs: 5000 }); + control.finish(); +}); + +it("keeps a failing reporter from escaping its timer", async () => { + vi.useFakeTimers(); + const control = createAdapterExecutionControl(); + const report = vi.fn(() => { throw new Error("reporter down"); }); + reportAdapterStopIfUnsettled(control.settled, report, undefined, undefined, { reportAfterMs: 1000 }); + await vi.advanceTimersByTimeAsync(1000); + expect(report).toHaveBeenCalledOnce(); + control.finish(); +}); diff --git a/server/src/services/adapter-execution-control.ts b/server/src/services/adapter-execution-control.ts index 06f60cab9fb..9b1b9f17d08 100644 --- a/server/src/services/adapter-execution-control.ts +++ b/server/src/services/adapter-execution-control.ts @@ -68,9 +68,9 @@ export async function registerAdapterExecutionControl( adapterExecutionControls.set(runId, control); } -export async function waitForAdapterStop( +function adapterStopTimeoutError( settled: Promise, - timeoutMs = 60_000, + timeoutMs: number, diagnostics?: AdapterStopContext, owner?: ReturnType, ) { @@ -83,21 +83,27 @@ export async function waitForAdapterStop( return null; } }; + return new AdapterStopTimeoutError(timeoutMs, { + ...diagnostics, + phase: undefined, + phaseElapsedMs: undefined, + ...currentPhase(), + }); +} + +export async function waitForAdapterStop( + settled: Promise, + timeoutMs = 60_000, + diagnostics?: AdapterStopContext, + owner?: ReturnType, +) { let timer: ReturnType | undefined; try { await Promise.race([ settled, new Promise((_, reject) => { timer = setTimeout( - () => - reject( - new AdapterStopTimeoutError(timeoutMs, { - ...diagnostics, - phase: undefined, - phaseElapsedMs: undefined, - ...currentPhase(), - }), - ), + () => reject(adapterStopTimeoutError(settled, timeoutMs, diagnostics, owner)), timeoutMs, ); }), @@ -106,3 +112,42 @@ export async function waitForAdapterStop( clearTimeout(timer); } } + +// A Stop request's wait is a response budget, not the adapter's settlement +// bound: workspace sync-back and cooperative provider cancellation routinely +// settle a few minutes after it. Only a Stop still unsettled this long after +// it was requested is an operational failure worth reporting. +export const UNSETTLED_ADAPTER_STOP_REPORT_MS = 10 * 60_000; +const watchedAdapterStops = new WeakSet>(); + +/** Reports at most once per execution, and only if it never settles in time. */ +export function reportAdapterStopIfUnsettled( + settled: Promise, + report: (error: AdapterStopTimeoutError) => void, + diagnostics?: AdapterStopContext, + owner?: ReturnType, + { + stopRequestedAt = Date.now(), + reportAfterMs = UNSETTLED_ADAPTER_STOP_REPORT_MS, + }: { stopRequestedAt?: number; reportAfterMs?: number } = {}, +) { + if (watchedAdapterStops.has(settled)) return; + watchedAdapterStops.add(settled); + let done = false; + // The bound runs from the Stop request, which already spent its response wait. + const remainingMs = Math.max(0, stopRequestedAt + reportAfterMs - Date.now()); + const timer = setTimeout(() => { + if (done) return; + try { + report(adapterStopTimeoutError(settled, reportAfterMs, diagnostics, owner)); + } catch { + // Reporting is diagnostic only. + } + }, remainingMs); + // Never hold process shutdown open for a diagnostic. + timer.unref?.(); + void settled.then(() => { + done = true; + clearTimeout(timer); + }); +} diff --git a/server/src/services/heartbeat/run-control.test.ts b/server/src/services/heartbeat/run-control.test.ts index 0234f326f48..d140f7f234d 100644 --- a/server/src/services/heartbeat/run-control.test.ts +++ b/server/src/services/heartbeat/run-control.test.ts @@ -5,7 +5,15 @@ import { eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; import { runningProcesses } from "../../adapters/index.js"; -import { adapterExecutionControls } from "../adapter-execution-control.js"; +import { + adapterExecutionControls, + createAdapterExecutionControl, + reportAdapterStopIfUnsettled, + waitForAdapterStop, +} from "../adapter-execution-control.js"; +import { AdapterStopTimeoutError } from "../adapter-stop-timeout.js"; +import { HttpError } from "../../errors.js"; +import { captureException } from "../../sentry.js"; import { terminateLocalService } from "../local-service-supervisor.js"; import { createHeartbeatRunState } from "./run-state.js"; import { createHeartbeatRunPreparation } from "./run-preparation.js"; @@ -13,6 +21,14 @@ import { createHeartbeatLifecycle, type HeartbeatLifecycleDependencies } from ". import { createHeartbeatRunControl, type HeartbeatRunControlDependencies } from "./run-control.js"; vi.mock("../live-events.js", () => ({ publishLiveEvent: vi.fn() })); +vi.mock("../adapter-execution-control.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + waitForAdapterStop: vi.fn(actual.waitForAdapterStop), + reportAdapterStopIfUnsettled: vi.fn(actual.reportAdapterStopIfUnsettled), + }; +}); vi.mock("../local-service-supervisor.js", async (importOriginal) => ({ ...await importOriginal(), terminateLocalService: vi.fn(async () => undefined), @@ -256,6 +272,67 @@ describe.skipIf(!support.supported)("heartbeat run-control database wiring", () expect(deps.failedProcessRunCancellations.get(run.id)?.error).toBe(failure); }); + async function ownedAdapterRun() { + const { company, agent } = await fixture(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running", runtimeMode: "legacy", runtimeModeResolvedAt: new Date() }).returning(); + const control = createAdapterExecutionControl(); + adapterExecutionControls.set(run.id, control); + return { run, control }; + } + + it("answers an owned adapter Stop that is still settling with a pending conflict", async () => { + const { run, control } = await ownedAdapterRun(); + const deps = callbacks(db); + deps.activeRunExecutions.add(run.id); + try { + let waitStartedAt = 0; + vi.mocked(waitForAdapterStop).mockImplementationOnce(async () => { + waitStartedAt = Date.now(); + throw new AdapterStopTimeoutError(60_000, { runId: run.id }); + }); + const requestedAt = Date.now(); + const error = await createHeartbeatRunControl(db, deps).cancelRunInternal(run.id).catch((err: unknown) => err); + expect(error).toBeInstanceOf(HttpError); + expect(error).toMatchObject({ + status: 409, + message: "Execution is still stopping; termination has not been verified.", + details: { code: "adapter_stop_pending", runId: run.id }, + }); + expect(control.controller.signal.aborted).toBe(true); + // Only a Stop that never settles is reported, from the same execution. + expect(reportAdapterStopIfUnsettled).toHaveBeenCalledWith(control.settled, captureException, + expect.objectContaining({ runId: run.id, adapterType: "codex_local", runtimeMode: "legacy", abortRequested: true }), control, + { stopRequestedAt: expect.any(Number) }); + // Its report bound runs from the Stop request, before the response wait. + const { stopRequestedAt } = vi.mocked(reportAdapterStopIfUnsettled).mock.calls.at(-1)![4]!; + expect(stopRequestedAt).toBeGreaterThanOrEqual(requestedAt); + expect(stopRequestedAt).toBeLessThanOrEqual(waitStartedAt); + // A pending Stop is not a cancellation receipt. + expect(deps.setRunStatusFromLive).not.toHaveBeenCalled(); + expect(deps.finalizeAgentStatus).not.toHaveBeenCalled(); + expect((await deps.getRun(run.id))?.status).toBe("running"); + expect(deps.failedProcessRunCancellations.has(run.id)).toBe(false); + } finally { + control.finish(); + adapterExecutionControls.delete(run.id); + } + }); + + it("keeps other owned adapter Stop failures unchanged", async () => { + const { run, control } = await ownedAdapterRun(); + const deps = callbacks(db); + vi.mocked(reportAdapterStopIfUnsettled).mockClear(); + try { + const failure = new Error("owner wait failed"); + vi.mocked(waitForAdapterStop).mockRejectedValueOnce(failure); + await expect(createHeartbeatRunControl(db, deps).cancelRunInternal(run.id)).rejects.toBe(failure); + expect(reportAdapterStopIfUnsettled).not.toHaveBeenCalled(); + } finally { + control.finish(); + adapterExecutionControls.delete(run.id); + } + }); + it("deduplicates agent batches and cancels only unclaimed pending wakes", async () => { const { company, agent } = await fixture(); const [otherAgent] = await db.insert(agents).values({ companyId: company.id, name: "Other agent" }).returning(); diff --git a/server/src/services/heartbeat/run-control.ts b/server/src/services/heartbeat/run-control.ts index f744f35d147..f4b0529ddc9 100644 --- a/server/src/services/heartbeat/run-control.ts +++ b/server/src/services/heartbeat/run-control.ts @@ -44,8 +44,11 @@ import { import { adapterExecutionControls, captureAdapterStopOwnership, + reportAdapterStopIfUnsettled, waitForAdapterStop, } from "../adapter-execution-control.js"; +import { AdapterStopTimeoutError } from "../adapter-stop-timeout.js"; +import { captureException } from "../../sentry.js"; import { randomUUID } from "node:crypto"; import { and, @@ -999,6 +1002,7 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont processCancellationSettlement, ); } + const stopRequestedAt = Date.now(); const cancellation = await (async () => { try { if (control) { @@ -1059,12 +1063,24 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont } if (control) { - await waitForAdapterStop(control.settled, undefined, { + const stopDiagnostics = { runId: run.id, adapterType: agent?.adapterType, runtimeMode: run.runtimeMode, abortRequested: control.controller.signal.aborted, - }, control); + }; + try { + await waitForAdapterStop(control.settled, undefined, stopDiagnostics, control); + } catch (error) { + if (!(error instanceof AdapterStopTimeoutError)) throw error; + // The owned adapter is still settling (for example copying the + // workspace back) and keeps its Stop proof requirements. Answer + // this request honestly; report only a Stop that never settles. + reportAdapterStopIfUnsettled(control.settled, captureException, stopDiagnostics, control, { + stopRequestedAt, + }); + throw conflict(error.message, { code: "adapter_stop_pending", runId: run.id }); + } const stopped = await getRun(run.id); if (stopped && isHeartbeatRunTerminalStatus(stopped.status)) { if ( From 64bd6e9e07210240196f6b9969d25a23aa98fe0e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:46:25 -0500 Subject: [PATCH 10/15] refactor(chat): extract delivery, reaction, and setup boundaries (#16024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip manages AI agents and their work. > - Chat channels connect that work to provider conversations. > - One shared service contains callback, delivery, reaction, and setup logic. > - These responsibilities have distinct contracts and existing public-path tests. > - This PR moves ten cohesive responsibilities into focused modules. > - The service retains shared runtime ownership and scheduling. > - Provider behavior is easier to locate and review. ## Linked Issues or Issue Description Refs #15995, #16026, #16027, #16030, #16031, #16035, #16037, #16038, #16041, #16043. This PR consolidates the reviewed extraction stack. The plan is `doc/plans/2026-10-12-chat-channel-delivery-boundaries-refactor.md`. ## What Changed - Move Slack callback verification into the Slack folder. - Move Discord Gateway events and root-thread admission/recovery into the Discord folder. - Extract durable receipt reactions and inbound reaction admission/replay. - Extract inbound attachment ingestion and outbound integrity-checked hydration. Preserve the public hydration export. - Extract durable provider effects. Keep Telegram callback notices in the Telegram folder. - Extract conversation/activity reads and setup qualification. Keep Slack and Teams projections in their channel folders. - Preserve the merged managed Slack setup behavior, shared workers, credential leases, and transaction boundaries. - Reduce `chat-channels.ts` from 26,493 lines / 1,048,769 bytes to 21,460 lines / 862,284 bytes. This removes 5,033 lines from the service. ## Verification - Each extraction passed its selected existing tests before and after through public service paths. - On updated master, all 55 selected Slack cases passed before extraction. All 194 selected chat/managed-Slack cases passed on the final stack tip. - `pnpm -r typecheck`, `pnpm build`, module-boundary checks, and whitespace checks passed on that tip. - The normalized syntax audit verifies 69 moved declarations and all retained statements, including their relative order, against updated master. - The eight earlier stack merges retain the exact validated repository tree. The combined root equals the validated tip merged with current master. All chat-channel files remain byte-identical to the validated tip. Current combined-root CI and review are pending. - Full local verification remains unverified. The unchanged `workspace-git-snapshot-streaming.test.ts:56` stress test timed out at 300,000 ms in a clean run and again in isolation. The full run was stopped after the reproduced failure. No timeout or assertion was weakened. - These checks use assembled services, real local databases, and provider fixtures. They do not prove live third-party execution. Reviewers can run the public-path suites: ```sh pnpm exec vitest run server/src/__tests__/chat-channels.integration.test.ts server/src/services/chat-outbound-attachment-hydration.test.ts server/src/__tests__/slack-development-manager.test.ts ``` ## Risks - Factory wiring must preserve live values, shared state, and initialization order. Identity-link construction retains deferred access to the provider-effects callback. - Credential fences, authorization transactions, and delivery settlement must keep their original ordering. Syntax parity and public-path integration cases cover these boundaries. - Full local verification has the recorded Git-streaming timeout. The combined PR must pass master’s required CI before merging into master. ## Model Used OpenAI Codex, GPT-6 family, with reasoning and repository tools. The exact runtime model ID and context-window size are not exposed in this session. No separate agent was used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ...at-channel-delivery-boundaries-refactor.md | 100 + server/src/services/chat-channels.ts | 5107 +---------------- server/src/services/chat-channels/activity.ts | 616 ++ .../chat-channels/discord/gateway-events.ts | 253 + .../chat-channels/discord/messages.ts | 9 + .../chat-channels/discord/root-threads.ts | 570 ++ .../chat-channels/inbound-attachments.ts | 674 +++ .../file-transfer-projection.ts | 18 + .../chat-channels/outbound-attachments.ts | 294 + .../chat-channels/provider-effects.ts | 957 +++ .../src/services/chat-channels/reactions.ts | 891 +++ .../chat-channels/receipt-reactions.ts | 586 ++ .../services/chat-channels/runtime-types.ts | 16 +- .../services/chat-channels/setup-probes.ts | 353 ++ .../slack/callback-verification.ts | 300 + .../services/chat-channels/slack/messages.ts | 46 + .../chat-channels/slack/task-start.ts | 18 + .../telegram/callback-notices.ts | 128 + .../chat-channels/telegram/messages.ts | 12 + 19 files changed, 5877 insertions(+), 5071 deletions(-) create mode 100644 doc/plans/2026-10-12-chat-channel-delivery-boundaries-refactor.md create mode 100644 server/src/services/chat-channels/activity.ts create mode 100644 server/src/services/chat-channels/discord/gateway-events.ts create mode 100644 server/src/services/chat-channels/discord/root-threads.ts create mode 100644 server/src/services/chat-channels/inbound-attachments.ts create mode 100644 server/src/services/chat-channels/microsoft-teams/file-transfer-projection.ts create mode 100644 server/src/services/chat-channels/outbound-attachments.ts create mode 100644 server/src/services/chat-channels/provider-effects.ts create mode 100644 server/src/services/chat-channels/reactions.ts create mode 100644 server/src/services/chat-channels/receipt-reactions.ts create mode 100644 server/src/services/chat-channels/setup-probes.ts create mode 100644 server/src/services/chat-channels/slack/callback-verification.ts create mode 100644 server/src/services/chat-channels/telegram/callback-notices.ts diff --git a/doc/plans/2026-10-12-chat-channel-delivery-boundaries-refactor.md b/doc/plans/2026-10-12-chat-channel-delivery-boundaries-refactor.md new file mode 100644 index 00000000000..01a3fb22ef9 --- /dev/null +++ b/doc/plans/2026-10-12-chat-channel-delivery-boundaries-refactor.md @@ -0,0 +1,100 @@ +# Chat channel callback and delivery boundaries, batch four + +Base: 4b766b872993045a2e787404f0475503683e5ded. Batch three (#15995, #15996, #15999, #16001, #16002) +is merged. The service starts at 26,461 lines / 1,046,711 bytes. +This batch may create up to ten stacked PRs. Merging the new batch is separate work. + +| Order / boundary | Destination | Approx. moved / net lines | Contract and state | Risks and existing verification | +| --- | --- | --- | --- | --- | +| 1. Slack callback verification | `slack/callback-verification.ts` | 250 / 230 | Pure signature/workspace/URL inspection plus signed URL verification; explicit vault lookup, URL getter, scheduling and registration callbacks | Preserve signing-secret-only setup, original request, TLS/proxy evidence and workspace checks. Signed verification and callback health cases before/after. | +| 2. Discord Gateway events | `discord/gateway-events.ts` | 200 / 185 | Runtime fence and lease-owner predicates, endpoint lookup and lifecycle callback; original per-runtime event queue and sequence | Preserve stale-owner fencing, event order, transient guild recovery and terminal revocation. Gateway lifecycle cases before/after. | +| 3. Discord root threads | `discord/root-threads.ts` | 500 / 460 | Credential lease, current principal authority, runtime, message admission and conversation drain callbacks | Preserve uncertain thread creation, restart reconciliation, no duplicate starter and pause/credential serialization. Root-thread public-service cases. | +| 4. Receipt reactions | `receipt-reactions.ts` and Slack error classifier beside Slack messages | 545 / 500 | Durable reaction claim/transport/settlement with runtime and credential fences; no scheduling owner moves | Preserve retries, legacy GitHub receipts, terminal-removal supersession and same-attempt settlement. Receipt recovery cases before/after. | +| 5. Inbound reactions | `reactions.ts` | 800 / 740 | Conversation lookup/locks, reaction admission and deferred replay together; current principal/runtime callbacks | Preserve exact event dedupe, completed generation links, bounded replay age and no task wakeup. Reaction race/restart/authorization cases. | +| 6. Inbound attachments | `inbound-attachments.ts` | 600 / 550 | Existing storage and issue service, retained source authorization and live options; return same attachment results | Preserve size limits, source authority around I/O, omissions and text delivery. Provider attachment/restart/stale-authority cases. | +| 7. Outbound attachments | `outbound-attachments.ts`; provider Markdown wrappers beside provider messages | 245 / 220 | Metadata validation and bounded integrity-checked hydration; existing storage object. Keep public hydration export compatible | Preserve stream cleanup, deadlines, byte/digest checks and retry classification. Hydration and publication attachment cases. | +| 8. Provider effects | `provider-effects.ts` | 950 / 850 | Durable reply claim, authorization, transport and settlement; endpoint/runtime/credential/principal callbacks | Preserve uncertain-delivery quarantine, credential fence and transaction ordering. Native callback, task-control and reply recovery cases. Put Telegram-only authority under `telegram/` if its contract is narrow. | +| 9. Conversation and activity reads | `activity.ts` | 540 / 520 | Endpoint lookup and read-only Teams conflict projection; return existing API models | Preserve company/endpoint joins, mixed-source cursor ties, replay eligibility and read-only GETs. Activity, file receipt projection and conversation-state cases. | +| 10. Setup probes | `setup-probes.ts` | 280 / 250 | Credential lease, current identity/principal authority, provider fetch, endpoint projection and live activation hook | Preserve real-message requirement, generation and linked-user activation. Setup-test, reconnect and authorization cases. | + +Each slice must earn its place after reading its complete path and callers. +Reassess before implementation; stop short of ten if the remaining contracts are +not cohesive. Keep provider-specific code in channel folders and common durable +workflows in the shared chat-channels directory. + +Receiver lease ownership stays shared: the existing Discord-named owner is also +used by Photon. Shared lifecycle/application admission, queue-drain ownership, +worker scheduling, and the largest action/message handlers remain in the service. +Do not hide their dependencies in a mutable context object. Preserve live option +hooks, runtime-map identity, partial-failure cleanup, and initialization order. + +Run relevant existing public-service tests before and after each mechanical move. +Compare moved declarations and remaining statements with normalized syntax, then +run server types, module boundaries and whitespace checks. Finish local verification and a fresh +5/5 review before opening the next draft PR. GitHub runner-selection jobs remained +queued without steps for over 25 minutes in this batch. To continue the authorized +stack, publish drafts in review order while tracking CI as pending; CI remains a +merge gate. Run cumulative repository gates at handoff. +Retain failures and skipped/unrun coverage even when an isolated retry passes. +Use fixture-backed assembled service/database tests; do not claim live provider +execution. Remap LOC/bytes and the remaining orchestration after the batch. + +## Slice 1: Slack callback verification + +- Service: 26,461 lines / 1,046,711 bytes → 26,201 lines / 1,035,260 bytes. +- Destination: 299 lines / 13,038 bytes. Eight declarations move without body + changes. Three existing setup types move unchanged into `runtime-types.ts`. +- The verification factory initializes after Slack registration. It receives the + same registration object, credential resolver, live URL getter and scheduler. + Signature/workspace/URL helpers remain callable by shared webhook orchestration. +- Nine public-service cases passed before and after on the updated master base. + Server typecheck, module boundaries, whitespace and normalized syntax parity + passed. Coverage includes signed URL verification, account notification, + workspace rejection, callback surfaces, TLS termination and Cloud host evidence. +- Next: Discord Gateway event sequencing and health transitions. Keep shared + receiver lease ownership and runtime-map lifetime in the service. + +## Batch outcome + +All ten candidates produced cohesive boundaries. Shared receiver ownership, queue +management and worker scheduling remain in the service. The only intentional +control-wiring change is deferred access to `stageProviderEffect` from identity +link construction; typecheck and integration tests caught the initial early read. + +| Slice | Service lines after | Primary module lines | Before/after cases | +| --- | ---: | ---: | ---: | +| 1. Slack signature, workspace and callback url verification | 26,233 | 300 | 9 | +| 2. Discord gateway event sequencing and endpoint health transitions | 26,029 | 253 | 7 | +| 3. Discord root-thread admission, provider creation and durable recovery | 25,527 | 570 | 4 | +| 4. Durable receipt reaction claims, transport, retries and settlement | 24,975 | 586 | 7 | +| 5. Inbound reaction conversation lookup, admission and deferred replay | 24,160 | 891 | 21 | +| 6. Inbound attachment authorization, bounded ingestion, registration and omissions | 23,565 | 674 | 39 | +| 7. Outbound attachment metadata validation and bounded integrity-checked hydration | 23,298 | 294 | 22 | +| 8. Durable provider reply staging, authorization, transport and recovery | 22,331 | 957 | 24 | +| 9. Conversation summaries and paginated activity projections | 21,741 | 616 | 16 | +| 10. Setup message detection and credential-fenced round-trip qualification | 21,460 | 353 | 7 | + +After rebasing on master `8e7b5db822`, the service shrank from +**26,493 lines / 1,048,769 bytes** to **21,460 lines / +862,284 bytes** (5,033 fewer lines, about 19%). Normalized syntax comparison +checks all 69 moved declarations/types and every remaining original statement. +Server types, targeted tests, module boundaries and whitespace checks are the +per-slice gates. Cumulative repository gates and remote reviews are recorded in +the PR descriptions as they complete. + +Orchestration measurement: the service function has 150 nested functions +(19,399 lines) and 67 state/wiring statements (459 lines), versus 191 functions +(23,829 lines) and 54 statements (425 lines) at the start. Imports take 400 lines. +The remaining large paths are `processMessage` (2,166), `handleAction` (1,074), +`configureWithCredentialLease` (868), `processSelectedPublication` (729), +`handleModalSubmit` (708), `handleWebhook` (685), and lifecycle processing (657). + +A future pass should inspect publication admission/transport/settlement as one +workflow, then inbound delivery authorization and drain ownership. Those cuts +need explicit ownership contracts; avoid separating individual checks from their +transactions merely to reduce the remaining line count. + +The rebase preserves the merged managed Slack setup flow, including its shared +`isProvisionedSlackSetup` predicate in the extracted callback verification +module. A fresh syntax audit compares all moved declarations and retained +statement ordering with this updated master. diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index e1077280799..6a302b30fdc 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1,6 +1,22 @@ import { slackTestMessageService } from "./connectors/slack/test-message.js"; import type { SlackSetupOptions } from "./connectors/slack/setup/registration.js"; import { isProvisionedSlackSetup } from "@paperclipai/shared"; +import { createSetupProbes } from "./chat-channels/setup-probes.js"; +import { createChatActivity, publicationSummary } from "./chat-channels/activity.js"; +import { fileTransferProjectionColumns } from "./chat-channels/microsoft-teams/file-transfer-projection.js"; +import { createProviderEffects, providerEffectPayload, type ProviderEffectTarget, type ProviderEffectPayload } from "./chat-channels/provider-effects.js"; +import { createTelegramCallbackNoticeAuthority } from "./chat-channels/telegram/callback-notices.js"; +import { createOutboundAttachments, isOutboundAttachmentHydrationError, isOutboundAttachmentValidationError } from "./chat-channels/outbound-attachments.js"; +export { hydrateOutboundAttachment } from "./chat-channels/outbound-attachments.js"; +import { discordMarkdownAttachment } from "./chat-channels/discord/messages.js"; +import { telegramMarkdownAttachment } from "./chat-channels/telegram/messages.js"; +import { createInboundAttachments } from "./chat-channels/inbound-attachments.js"; +import { createInboundReactions } from "./chat-channels/reactions.js"; +import { slackThreadChannelId } from "./chat-channels/slack/messages.js"; +import { createReceiptReactions } from "./chat-channels/receipt-reactions.js"; +import { createDiscordRootThreads } from "./chat-channels/discord/root-threads.js"; +import { createDiscordGatewayEvents } from "./chat-channels/discord/gateway-events.js"; +import { createSlackCallbackVerification, slackRequestSignatureIsValid, slackRequestWorkspaceId, canonicalCallbackUrl, slackCallbackMatchesPublicUrl, inspectSlackCallback } from "./chat-channels/slack/callback-verification.js"; import { createTeamsFileAuthority } from "./chat-channels/microsoft-teams/file-transfer-authority.js"; import { createPhotonEvents } from "./chat-channels/imessage-photon/events.js"; import { createTelegramPublicationDrafts } from "./chat-channels/telegram/publication-drafts.js"; @@ -8,10 +24,10 @@ import { createSlackFileReceipts } from "./chat-channels/slack/file-receipts.js" import { createSlackSessionStatus } from "./chat-channels/slack/session-status.js"; import { createDiscordCommands } from "./chat-channels/discord/commands.js"; import { createSlackTaskStart, slackSlashTaskRecoveryPayload, slackTaskStarterThreadId } from "./chat-channels/slack/task-start.js"; -import { createGitHubWebhookIngress, GITHUB_RECOVERY_STATE_KEY, type GitHubIngressStageResult } from "./chat-channels/github/webhook-ingress.js"; +import { createGitHubWebhookIngress, type GitHubIngressStageResult } from "./chat-channels/github/webhook-ingress.js"; import { createSlackSessionStop } from "./chat-channels/slack/session-stop.js"; import { createTelegramMaintenance } from "./chat-channels/telegram/maintenance.js"; -import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext } from "./chat-channels/runtime-types.js"; +import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext, SlackCallbackSurface, InternalSetupState } from "./chat-channels/runtime-types.js"; import { createChatFailedRunRetry } from "./chat-channels/failed-run-retry.js"; import { createChatPublicationReplacement } from "./chat-channels/publication-replacement.js"; import { createChatIdentityLinks } from "./chat-channels/identity-links.js"; @@ -51,7 +67,7 @@ import { type GitHubLifecycleEvent, githubLifecycleEventFromRequest, } from "./chat-channels/github/messages.js"; -import { dueChatAction, nextChatActionAt, SLACK_COMMAND_POST_STALE_MS, SLACK_COMMAND_EXPLICIT_RETRY_STALE_MS, PROVIDER_EFFECT_STALE_MS } from "./chat-action-work.js"; +import { dueChatAction, nextChatActionAt, SLACK_COMMAND_POST_STALE_MS, SLACK_COMMAND_EXPLICIT_RETRY_STALE_MS } from "./chat-action-work.js"; import type { ScheduledChatAction } from "./chat-work-notifications.js"; import { notifyChatDeliveryWork, notifyChatPublicationWork, notifyChatEndpointWork, notifyChatActionWork, notifyChatVerificationWork } from "./chat-work-notifications.js"; import { syncSpekoVoiceTools } from "./voice/speko-agent-tools.js"; @@ -64,9 +80,7 @@ import type { ChatEndpointRuntime } from "./voice/voice-runtime.js"; import { parseMarkdown } from "chat"; import { githubPublicAttachmentsFromMessage } from "./chat-github-attachments.js"; import { - receiptReactionPayload, stageReceiptReactionRemovals, - type ReceiptReactionPayload, } from "./chat-receipt-reactions.js"; import { fastResponseService, enqueueFastResponse, fastResponseSourceCurrent, fastResponseTurnQueued } from "./fast-responses.js"; import { fastResponseRequests } from "@paperclipai/db"; @@ -89,7 +103,7 @@ import { registerSlackTaskAuthority, slackRunOrigin } from "./connectors/slack-a import { buildChatCommunicationGuidance } from "./chat-communication-guidance.js"; function githubPolicyRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } -import { githubChatManagementService } from "./chat-github-management.js"; + import { githubReviewCheckService } from "./chat-github-checks.js"; import { githubAutomaticReviewEvent, @@ -112,12 +126,12 @@ import { githubAppJwt } from "./chat-github-client.js"; import { resumeExternalConversation } from "./slack-conversation-state.js"; import { settleExternalConversation } from "./slack-conversation-lifecycle.js"; import { runtimeCanonicalOrigin } from "./cloud-runtime-identity.js"; -import { takePhotonCompanion } from "./photon/attachments.js"; + import { writePhotonCheckpoint } from "./photon/receiver.js"; import { PhotonState } from "./photon/state.js"; -import { HEIF_CONTENT_TYPES, photonHeifPreview, validatePhotonImage } from "./photon/media.js"; -import { projectSafeChatPublicationText } from "./chat-publication-projection.js"; + + import { nativePhotonInteraction, publishPhotonPrompt, type PhotonPromptReceipt, type PhotonInteractionBinding } from "./photon/interactions.js"; @@ -139,7 +153,6 @@ import { desc, eq, gt, - gte, inArray, isNotNull, isNull, @@ -205,7 +218,6 @@ import type { ChatEndpointSetupState, ChatProvider, ChatPublicationBatchStatus, - ChatPublicationSummary, ConfigureChatEndpointInput, CreateChatEndpointInput, ExternalChannelBindingSummary, @@ -222,10 +234,8 @@ import { } from "@paperclipai/shared"; import { formatAttachmentSize, - isAllowedContentType, MAX_ATTACHMENT_BYTES, normalizeContentType, - normalizeUploadAttachmentContentType, } from "../attachment-types.js"; import { isUniqueViolation } from "../db-errors.js"; import { @@ -239,24 +249,17 @@ import { import { projectTeamsFilePublication } from "./chat-teams-file-publication.js"; import type { TeamsFileConsentEvent } from "./chat-teams-file-consent.js"; import { - chatFileTransferResolutionActions, projectChatFileTransfer, projectChatPublicationBatch, } from "./chat-publication-batches.js"; import { telegramAttachmentForUpload } from "./chat-telegram-photo.js"; import { - parseTelegramCallbackReceipt, readTelegramCallbackProvenance, telegramCallbackThreadId, TELEGRAM_PRIVATE_ACTION_UNAVAILABLE, - type TelegramCallbackReceipt, } from "./chat-telegram-ephemeral.js"; -import { - hasTelegramMediaProvenance, - identifyTelegramMedia, - telegramMediaNeedsIdentification, -} from "./chat-telegram-media-intake.js"; + import { normalizeTelegramRichMessage } from "./chat-telegram-rich-intake.js"; import { isTelegramDraftStopped, @@ -265,10 +268,7 @@ import { import { parseChatWebhookPublicBaseUrl } from "../chat-webhook-public-url.js"; -import { - parseGitHubReceiptIdentity, - type GitHubReceiptIdentity, -} from "./chat-github-receipt-reactions.js"; + import { badRequest, HttpError, @@ -281,14 +281,12 @@ import { logger } from "../middleware/logger.js"; import { redactSensitiveText } from "../redaction.js"; import type { StorageService } from "../storage/types.js"; import { - DiscordAdapterCompatibilityError, type ChatSdkCallbackEvent, type ChatSdkMessageCallbackEvent, type ChatSdkMessageUpdatedCallbackEvent, type ChatSdkProvider, type ChatSdkRuntime, type DiscordGatewayCallbackEvent, - type DiscordRootMentionAdmissionEvent, type ResolvedChatSdkProviderConfig, type SlackFileUploadAcceptedReceipt, } from "./chat-sdk-runtime.js"; @@ -322,10 +320,7 @@ import { requestGitHubAppWebhookRedelivery, } from "./chat-github-webhook-config.js"; import { - GITHUB_ATTACHMENT_BATCH_TIMEOUT_MS, - GitHubAttachmentUnavailableError, githubAttachmentLimitOmissions, - prepareGitHubPublicAttachment, restoreGitHubAttachmentLimitOmissions, } from "./chat-github-attachments.js"; import { getExternalChannelBindingSummary } from "./chat-channel-binding.js"; @@ -383,9 +378,7 @@ import { normalizeMicrosoftTeamsCredentialIds, } from "./chat-teams-credentials.js"; import { - isTeamsInlineImageContentType, prepareTeamsInlineImage, - TEAMS_INLINE_IMAGE_MAX_BYTES, } from "./chat-teams-inline-images.js"; import { shouldStreamSafePublicationText, @@ -419,44 +412,11 @@ import type { MessageDeletedEvent, ModalResponse, ModalSubmitEvent, - ReactionEvent, SlashCommandEvent, Thread, } from "chat"; import { Actions, Button, Card, CardText, LinkButton } from "chat"; -// Only these closed fields may reach Activity or a delivery-batch response. -// The resolution predicate yields one boolean, never the private envelope. -const fileTransferProjectionColumns = { - publicationId: chatTeamsFileTransfers.publicationId, - phase: chatTeamsFileTransfers.phase, - version: chatTeamsFileTransfers.version, - filename: chatTeamsFileTransfers.filename, - expiresAt: chatTeamsFileTransfers.expiresAt, - consentMessageId: chatTeamsFileTransfers.consentMessageId, - responseActivityId: chatTeamsFileTransfers.responseActivityId, - fileInfoMessageId: chatTeamsFileTransfers.fileInfoMessageId, - operatorConfirmed: sql` - coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,schema}', '') = 'paperclip.teams.file-resolution.v1' - and coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,action}', '') = 'mark_delivered' - and coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,fromPhase}', '') = 'file_info_unknown' - `, -}; - -function publicationSummary( - row: typeof chatPublications.$inferSelect, -): ChatPublicationSummary { - return { - id: row.id, - state: row.state, - providerUrl: row.providerUrl, - attempts: row.attempts, - redactedError: row.redactedError, - nextAttemptAt: row.nextAttemptAt?.toISOString() ?? null, - publishedAt: row.publishedAt?.toISOString() ?? null, - }; -} - const PROVIDER_LABELS: Record = { speko: "Speko", "imessage-photon": "iMessage Photon", @@ -494,207 +454,6 @@ function publicationPrecedes( ); } -function slackRequestSignatureIsValid( - request: Request, - body: string, - signingSecret: string, -): boolean { - const timestamp = request.headers.get("x-slack-request-timestamp"); - const signature = request.headers.get("x-slack-signature"); - if (!timestamp || !signature) return false; - const timestampSeconds = Number(timestamp); - if ( - !Number.isFinite(timestampSeconds) || - Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds) > 300 - ) - return false; - const expected = `v0=${createHmac("sha256", signingSecret).update(`v0:${timestamp}:${body}`).digest("hex")}`; - try { - return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); - } catch { - return false; - } -} - -function slackRequestWorkspaceId( - body: string, - contentType: string, -): string | null { - let payload: unknown; - try { - if (contentType.includes("application/json")) { - payload = JSON.parse(body); - } else { - const form = new URLSearchParams(body); - const interactivePayload = form.get("payload"); - payload = interactivePayload - ? JSON.parse(interactivePayload) - : Object.fromEntries(form.entries()); - } - } catch { - return null; - } - if (!payload || typeof payload !== "object") return null; - const record = payload as Record; - const nestedId = (value: unknown): string | null => { - if (!value || typeof value !== "object") return null; - const id = (value as Record).id; - return typeof id === "string" && id.length > 0 ? id : null; - }; - const stringField = (key: string): string | null => { - const value = record[key]; - return typeof value === "string" && value.length > 0 ? value : null; - }; - const authorization = Array.isArray(record.authorizations) - ? record.authorizations.find( - (value): value is Record => - Boolean(value) && typeof value === "object", - ) - : null; - const authorizationField = (key: string): string | null => { - const value = authorization?.[key]; - return typeof value === "string" && value.length > 0 ? value : null; - }; - - // A Grid event may carry both an enterprise id and the concrete workspace - // id. The bot identity is verified against auth.test's team_id, so prefer a - // team id and use the enterprise id only when Slack omitted team context. - return ( - stringField("team_id") ?? - nestedId(record.team) ?? - authorizationField("team_id") ?? - stringField("enterprise_id") ?? - nestedId(record.enterprise) ?? - authorizationField("enterprise_id") - ); -} - -function canonicalCallbackUrl(value: string): string | null { - try { - const url = new URL(value); - if (url.protocol !== "https:" && url.protocol !== "http:") return null; - url.hostname = url.hostname.replace(/\.$/, ""); - url.username = ""; - url.password = ""; - url.search = ""; - url.hash = ""; - return url.toString(); - } catch { - return null; - } -} - -// TLS terminates at a reverse proxy in many self-hosted deployments. The -// transport scheme is not evidence of URL drift; authority and path still are. -// This is diagnostic only: it does not trust forwarded headers or change auth. -function slackCallbackMatchesPublicUrl(observed: string, current: string): boolean { - const canonical = canonicalCallbackUrl(observed); - if (canonical === current) return true; - if (!canonical) return false; - const observedUrl = new URL(canonical); - const currentUrl = new URL(current); - return observedUrl.protocol === "http:" - && currentUrl.protocol === "https:" - && observedUrl.host === currentUrl.host - && observedUrl.pathname === currentUrl.pathname; -} - -type SlackCallbackInspection = { - surface: SlackCallbackSurface; - url: string; - isUrlVerification: boolean; -}; - -// Cloud records the public request host in dedicated headers so provider edges -// cannot replace it with their upstream host. Only use callback-health evidence on a -// claimed Cloud instance, after provider authentication succeeded. It must not -// change the Request passed to the adapter, signature verification, routing, -// board identity, or the configured URL used to generate callbacks. -function slackCallbackObservationUrl(request: Request): string | null { - const directUrl = canonicalCallbackUrl(request.url); - if (!directUrl || !runtimeCanonicalOrigin()) return directUrl; - const hasCloudEvidence = request.headers.has("x-paperclip-cloud-forwarded-host"); - const host = request.headers.get(hasCloudEvidence ? "x-paperclip-cloud-forwarded-host" : "x-forwarded-host")?.trim(); - const protocol = request.headers.get(hasCloudEvidence ? "x-paperclip-cloud-forwarded-proto" : "x-forwarded-proto")?.trim(); - if (!host || /[\s/@\\?#,]/.test(host) || (protocol !== "https" && protocol !== "http")) return directUrl; - try { - const origin = new URL(`${protocol}://${host}`); - // Accept one authority only, never credentials, paths, queries, or lists. - if (!origin.host || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash) return directUrl; - const observed = new URL(directUrl); - observed.protocol = origin.protocol; - observed.host = origin.host; - observed.port = origin.port; - return canonicalCallbackUrl(observed.toString()); - } catch { - return directUrl; - } -} - -async function inspectSlackCallback( - request: Request, -): Promise { - const url = slackCallbackObservationUrl(request); - if (!url) return null; - const contentType = request.headers.get("content-type")?.toLowerCase() ?? ""; - let body: string; - try { - body = await request.text(); - } catch { - return null; - } - if (contentType.includes("application/json")) { - try { - const payload = JSON.parse(body) as { - type?: unknown; - challenge?: unknown; - }; - const isUrlVerification = - payload.type === "url_verification" && - typeof payload.challenge === "string" && - payload.challenge.length > 0; - if (isUrlVerification || payload.type === "event_callback") { - return { - surface: "events", - url, - isUrlVerification, - }; - } - } catch { - return null; - } - return null; - } - - const form = new URLSearchParams(body); - if (form.get("command")) { - return { surface: "slashCommands", url, isUrlVerification: false }; - } - const interactivePayload = form.get("payload"); - if (!interactivePayload) return null; - try { - const payload = JSON.parse(interactivePayload) as { type?: unknown }; - if ( - typeof payload.type === "string" && - [ - "block_actions", - "block_suggestion", - "dialog_submission", - "message_action", - "shortcut", - "view_closed", - "view_submission", - "workflow_step_edit", - ].includes(payload.type) - ) { - return { surface: "interactivity", url, isUrlVerification: false }; - } - } catch { - return null; - } - return null; -} - const CAPABILITIES: Record = { speko: { threads: false, directMessages: true, nativeStreaming: false, messageEdits: false, messageDeletes: false, reactions: false, files: false, cards: false, actions: false, modals: false, slashCommands: false, ephemeralMessages: false, proactiveDirectMessages: false }, "imessage-photon": { threads: false, directMessages: true, nativeStreaming: false, messageEdits: true, messageDeletes: false, reactions: false, files: true, cards: false, actions: true, modals: false, slashCommands: false, ephemeralMessages: false, proactiveDirectMessages: false }, @@ -841,13 +600,6 @@ const SUPPLIED_CREDENTIAL_KEYS: Record = { const MAX_ERROR_TEXT = 2_000; const DELIVERY_PROCESSING_STALE_MS = 60_000; -// Reactions can beat the transaction that persists a just-sent provider -// message link. Keep that narrow gap durable, but never retain an unbound -// reaction indefinitely or let it enter the ordinary inbound-message FIFO. -const REACTION_LINK_RETRY_DELAY_MS = 250; -const REACTION_LINK_MAX_DELAY_MS = 5_000; -const REACTION_LINK_MAX_ATTEMPTS = 20; -const REACTION_LINK_MAX_AGE_MS = 2 * 60_000; // A Discord root mention is durably staged before the adapter creates its // provider thread. Give the bounded provider retry loop ample time to finish; // if the process disappears, the delivery worker verifies that thread over @@ -887,17 +639,6 @@ const INGRESS_REORDER_WINDOW_MS: Partial> = { telegram: 750, }; type ResourceRow = typeof chatEndpointResources.$inferSelect; -type SlackCallbackSurface = keyof ChatEndpointCallbackSurfaces; -type SlackCallbackObservation = { url: string; observedAt: string }; -type InternalSetupState = ChatEndpointSetupState & { - photonIntakeAfter?: string; - runtimeGeneration?: number; - /** Binds pre-install URL verification to the signing secret later configured. */ - slackVerificationSigningFingerprint?: string; - slackCallbackSurfaces?: Partial< - Record - >; -}; type VerifiedProviderIdentity = { githubOwnerType?: "personal" | "organization"; githubOwnerLogin?: string; @@ -1054,130 +795,6 @@ type LiveInboundMessage = { thread: Thread; trigger: ChatSdkMessageCallbackEvent["trigger"]; }; -type ProviderEffectTarget = { - post: Thread["post"]; - postEphemeral?: Thread["postEphemeral"]; -}; -type ProviderEffectPayload = { - version: 1; - effect: "thread_message" | "ephemeral_message" | "telegram_callback_notice"; - telegramCallback?: TelegramCallbackReceipt; - telegramAuthorizationSha256?: string; - authorizationMode?: "principal" | "safe_notice"; - threadId: string; - userId?: string; - text: string; - fallbackText?: string; - settleDelivery: boolean; - completeConversationId?: string; - resourceId?: string; - runtimeGeneration: number; - credentialFingerprint: string; -}; - -function isSlackReceiptReactionAlreadyApplied(error: unknown): boolean { - const pending: Array<{ depth: number; value: unknown }> = [ - { depth: 0, value: error }, - ]; - const seen = new Set(); - while (pending.length > 0) { - const current = pending.shift(); - if ( - !current || - current.depth > 4 || - !current.value || - typeof current.value !== "object" || - seen.has(current.value) - ) { - continue; - } - seen.add(current.value); - const record = current.value as { - cause?: unknown; - code?: unknown; - data?: { error?: unknown }; - original?: unknown; - originalError?: unknown; - }; - if ( - record.code === "already_reacted" || - record.data?.error === "already_reacted" - ) { - return true; - } - for (const value of [record.cause, record.original, record.originalError]) { - pending.push({ depth: current.depth + 1, value }); - } - } - return false; -} - -function providerEffectPayload( - payload: Record, -): ProviderEffectPayload | null { - if ( - payload.version !== 1 || - (payload.effect !== "thread_message" && - payload.effect !== "ephemeral_message" && - payload.effect !== "telegram_callback_notice") || - typeof payload.threadId !== "string" || - !payload.threadId || - typeof payload.text !== "string" || - !payload.text || - typeof payload.settleDelivery !== "boolean" || - typeof payload.runtimeGeneration !== "number" || - !Number.isSafeInteger(payload.runtimeGeneration) || - typeof payload.credentialFingerprint !== "string" - ) { - return null; - } - if ( - payload.effect === "ephemeral_message" && - (typeof payload.userId !== "string" || !payload.userId) - ) { - return null; - } - for (const key of [ - "authorizationMode", - "userId", - "fallbackText", - "completeConversationId", - "resourceId", - ] as const) { - const value = payload[key]; - if (value !== undefined && (typeof value !== "string" || !value)) { - return null; - } - } - if ( - payload.authorizationMode !== undefined && - payload.authorizationMode !== "principal" && - payload.authorizationMode !== "safe_notice" - ) { - return null; - } - if (payload.effect === "telegram_callback_notice") { - const receipt = parseTelegramCallbackReceipt(payload.telegramCallback); - if ( - !receipt || - payload.authorizationMode !== "safe_notice" || - payload.text !== TELEGRAM_PRIVATE_ACTION_UNAVAILABLE || - payload.threadId !== telegramCallbackThreadId(receipt) || - payload.userId !== receipt.receiverUserId || - payload.settleDelivery !== false || - payload.fallbackText !== undefined || - payload.completeConversationId !== undefined || - typeof payload.telegramAuthorizationSha256 !== "string" || - !/^[a-f0-9]{64}$/.test(payload.telegramAuthorizationSha256) - ) - return null; - } else if ( - payload.telegramCallback !== undefined || - payload.telegramAuthorizationSha256 !== undefined - ) - return null; - return payload as ProviderEffectPayload; -} export interface ChatChannelServiceOptions { /** @@ -1489,15 +1106,6 @@ function safeTitle(text: string, fallback: string): string { return (line || fallback).slice(0, 160); } -function slackThreadChannelId(threadId: string): string | null { - if (!threadId.startsWith("slack:")) return null; - const channelAndThread = threadId.slice("slack:".length); - const separator = channelAndThread.indexOf(":"); - const channelId = - separator === -1 ? channelAndThread : channelAndThread.slice(0, separator); - return channelId || null; -} - function actionThreadMatchesConversation( provider: ChatProvider, actionThreadId: string, @@ -1514,24 +1122,6 @@ function actionThreadMatchesConversation( ); } -function slackTaskStartActivityStatus(action: { - status: string; - updatedAt: Date; -}): string { - const age = Date.now() - action.updatedAt.getTime(); - if (action.status === "validating" && age >= SLACK_COMMAND_POST_STALE_MS) { - return "queued"; - } - if ( - (action.status === "received" && age >= SLACK_COMMAND_POST_STALE_MS) || - (action.status === "resolving" && - age >= SLACK_COMMAND_EXPLICIT_RETRY_STALE_MS) - ) { - return "delivery_unknown"; - } - return action.status; -} - function sanitizeFilename(value: string | undefined): string | null { if (!value) return null; const leaf = value.replaceAll("\\", "/").split("/").pop()?.trim(); @@ -1821,163 +1411,6 @@ export function createChatSdkStatePersistence(db: Db): ChatSdkStatePersistence { }; } -const OUTBOUND_ATTACHMENT_STORAGE_TIMEOUT_MS = 10_000; - -class OutboundAttachmentHydrationError extends Error { - readonly code = "CHAT_ATTACHMENT_HYDRATION_FAILED"; - - constructor(message: string, cause?: unknown) { - super(message, cause === undefined ? undefined : { cause }); - this.name = "OutboundAttachmentHydrationError"; - } -} - -class OutboundAttachmentValidationError extends Error { - readonly code = "CHAT_ATTACHMENT_VALIDATION_FAILED"; - - constructor(message: string) { - super(message); - this.name = "OutboundAttachmentValidationError"; - } -} - -function isOutboundAttachmentHydrationError( - error: unknown, -): error is OutboundAttachmentHydrationError { - return error instanceof OutboundAttachmentHydrationError; -} - -function isOutboundAttachmentValidationError( - error: unknown, -): error is OutboundAttachmentValidationError { - return error instanceof OutboundAttachmentValidationError; -} - -/** - * Read one already-authorized attachment before any provider transport starts. - * The database digest, rather than mutable object-store metadata, is the - * integrity boundary. Both acquisition and streaming are bounded so a stuck - * storage backend cannot hold the publication lane indefinitely. - */ -export async function hydrateOutboundAttachment(input: { - storage: StorageService; - companyId: string; - objectKey: string; - byteSize: number; - sha256: string; - filename: string; - mimeType: string; - timeoutMs?: number; -}): Promise { - const timeoutMs = - typeof input.timeoutMs === "number" && - Number.isFinite(input.timeoutMs) && - input.timeoutMs > 0 - ? input.timeoutMs - : OUTBOUND_ATTACHMENT_STORAGE_TIMEOUT_MS; - if ( - !Number.isSafeInteger(input.byteSize) || - input.byteSize <= 0 || - input.byteSize > MAX_ATTACHMENT_BYTES || - !/^[0-9a-f]{64}$/iu.test(input.sha256) - ) { - throw new OutboundAttachmentHydrationError( - "Chat publication attachment metadata is invalid", - ); - } - const acquisitionTimeoutError = new OutboundAttachmentHydrationError( - "Chat publication attachment storage read timed out", - ); - let acquisitionTimedOut = false; - let rejectAcquisition!: (error: Error) => void; - const acquisitionTimeout = new Promise((_resolve, reject) => { - rejectAcquisition = reject; - }); - const acquisitionTimer = setTimeout(() => { - acquisitionTimedOut = true; - rejectAcquisition(acquisitionTimeoutError); - }, timeoutMs); - acquisitionTimer.unref?.(); - const objectPromise = Promise.resolve() - .then(() => input.storage.getObject(input.companyId, input.objectKey)) - .then((object) => { - if (acquisitionTimedOut) object.stream.destroy(); - return object; - }) - .catch((error) => { - throw isOutboundAttachmentHydrationError(error) - ? error - : new OutboundAttachmentHydrationError( - "Chat publication attachment storage read failed", - error, - ); - }); - let object: Awaited>; - try { - object = await Promise.race([objectPromise, acquisitionTimeout]); - } finally { - clearTimeout(acquisitionTimer); - } - - if ( - object.contentLength !== undefined && - object.contentLength !== input.byteSize - ) { - object.stream.destroy(); - throw new OutboundAttachmentHydrationError( - "Chat publication attachment size changed after registration", - ); - } - - const streamTimeoutError = new OutboundAttachmentHydrationError( - "Chat publication attachment storage read timed out", - ); - const streamTimer = setTimeout(() => { - object.stream.destroy(streamTimeoutError); - }, timeoutMs); - streamTimer.unref?.(); - const chunks: Buffer[] = []; - const digest = createHash("sha256"); - let bytes = 0; - try { - for await (const chunk of object.stream) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - bytes += buffer.length; - if (bytes > input.byteSize || bytes > MAX_ATTACHMENT_BYTES) { - object.stream.destroy(); - throw new OutboundAttachmentHydrationError( - "Chat publication attachment exceeded its streaming byte limit", - ); - } - digest.update(buffer); - chunks.push(buffer); - } - } catch (error) { - throw isOutboundAttachmentHydrationError(error) - ? error - : new OutboundAttachmentHydrationError( - "Chat publication attachment storage read failed", - error, - ); - } finally { - clearTimeout(streamTimer); - } - - const actualDigest = digest.digest("hex"); - if (bytes !== input.byteSize || actualDigest !== input.sha256.toLowerCase()) { - if (!object.stream.destroyed) object.stream.destroy(); - throw new OutboundAttachmentHydrationError( - "Chat publication attachment integrity changed after registration", - ); - } - - return { - data: Buffer.concat(chunks, bytes), - filename: input.filename, - mimeType: input.mimeType, - }; -} - export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const runtime = options.runtime ?? createChatRuntime(); const githubManualMessages = new WeakMap(); @@ -2009,7 +1442,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { // An explicit webhook ingress remains separate from board/identity links. const getPublicBaseUrl = () => runtimeCanonicalOrigin() ?? configuredPublicBaseUrl; const { listPrincipals, createLinkIntent, previewIdentityLink, requestIdentityAccess, confirmIdentityLink, revokeLink } = createChatIdentityLinks(db, { - endpointRecord, getPublicBaseUrl, stageProviderEffect, scheduleProviderEffect, + endpointRecord, getPublicBaseUrl, stageProviderEffect: (...args) => stageProviderEffect(...args), scheduleProviderEffect, confirmationRuntimeFor: async (endpoint) => runtimeContexts.get((await runtimeFor(endpoint)) as object), }); // Task links must validate the original configured URL before normalization @@ -2459,67 +1892,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ); } - async function addReceiptReaction(input: { - deliveryId: string; - endpoint: EndpointRow; - message: Message; - runtimeContext?: LifecycleRuntimeFence; - thread: Thread; - }): Promise { - // GitHub acknowledges through one editable response comment. Continue to - // drain legacy eyes removals, but never create new eyes for these requests. - if (input.endpoint.provider === "github") return; - const record = await endpointRecord(input.endpoint.id); - if (!record) return; - // A live SDK object remains bound to the credentials that authenticated - // its callback. Preserve that original fence rather than sampling the - // endpoint again after a rotation and accidentally blessing an obsolete - // adapter with the new generation. - const context = input.runtimeContext ?? runtimeContextForRecord(record); - const [inserted] = await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "receipt_reaction"); - return tx - .insert(chatActions) - .values({ - companyId: input.endpoint.companyId, - endpointId: input.endpoint.id, - deliveryId: input.deliveryId, - kind: "receipt_reaction", - providerActionId: `receipt_reaction:${input.deliveryId}`, - payload: { - version: 1, - operation: "add", - threadId: input.thread.id, - messageId: input.message.id, - reaction: "eyes", - runtimeGeneration: context.generation, - credentialFingerprint: context.credentialFingerprint, - } satisfies ReceiptReactionPayload, - status: "received", - }) - .onConflictDoNothing() - .returning({ id: chatActions.id }); - }); - const actionId = - inserted?.id ?? - (await db - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, input.endpoint.id), - eq( - chatActions.providerActionId, - `receipt_reaction:${input.deliveryId}`, - ), - ), - ) - .then((rows) => rows[0]?.id ?? null)); - if (!actionId) return; - await options.receiptReactionTransportBarrier?.(); - await processReceiptReaction(actionId); - } - function conversationDrainKey(endpointId: string, threadId: string) { return `${endpointId}:${createHash("sha256").update(durableExternalThreadIdentity(threadId)).digest("hex")}`; } @@ -2588,506 +1960,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .then((rows) => rows[0] ?? null); } - async function processReceiptReaction(actionId: string): Promise { - const initialAction = await db - .select({ endpointId: chatActions.endpointId }) - .from(chatActions) - .where( - and( - eq(chatActions.id, actionId), - eq(chatActions.kind, "receipt_reaction"), - ), - ) - .then((rows) => rows[0] ?? null); - if (!initialAction) return; - const initialEndpoint = await endpointRecord(initialAction.endpointId); - if (!initialEndpoint) return; - // Receipt reactions are GitHub repository mutations too. Serialize their - // final runtime-fence check and provider call with reconnect, secret - // rotation, pause, and removal so credentials cannot become obsolete - // between authorization and transport. - await withCredentialMutationLease(initialEndpoint.endpoint, (lease) => - processReceiptReactionWithCredentialLease(actionId, lease), - ); - } - - async function processReceiptReactionWithCredentialLease( - actionId: string, - credentialLease: CredentialMutationLeaseGuard, - ): Promise { - let action = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.id, actionId), - eq(chatActions.kind, "receipt_reaction"), - ), - ) - .then((rows) => rows[0] ?? null); - if (!action || ["processed", "cancelled"].includes(action.status)) return; - const payload = receiptReactionPayload(action.payload); - if (!payload) { - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "receipt_reaction"); - return tx - .update(chatActions) - .set({ - status: "failed", - result: { - code: "receipt_reaction_payload_invalid", - retryable: false, - }, - updatedAt: new Date(), - }) - .where(eq(chatActions.id, action.id)); - }); - return; - } - if (payload.operation === "add" && action.deliveryId) { - const terminalRemoval = await db - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, action.endpointId), - eq( - chatActions.providerActionId, - `receipt_reaction_remove:${action.deliveryId}`, - ), - ), - ) - .then((rows) => rows[0] ?? null); - if (terminalRemoval) { - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatActionWork(tx, "receipt_reaction"); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - ...(typeof action!.result?.attempts === "number" - ? { attempts: action!.result.attempts } - : {}), - code: "receipt_reaction_superseded_by_terminal_publication", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action!.id), - eq(chatActions.status, action!.status), - ), - ); - await credentialLease.assertOwned(tx); - }); - return; - } - } - if (action.status === "processing") { - if (action.updatedAt > new Date(Date.now() - PROVIDER_EFFECT_STALE_MS)) - return; - // Adding or removing the same reaction is idempotent. A worker - // interruption can be retried safely without an ambiguous - // duplicate-message boundary. - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "receipt_reaction"); - return tx - .update(chatActions) - .set({ - status: "failed", - result: { - ...(action.result ?? {}), - retryable: true, - retryAt: new Date().toISOString(), - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - eq(chatActions.updatedAt, action.updatedAt), - ), - ); - }); - action = await db - .select() - .from(chatActions) - .where(eq(chatActions.id, action.id)) - .then((rows) => rows[0] ?? null); - if (!action) return; - } - if (action.status === "failed") { - if (action.result?.retryable !== true) return; - const retryAt = - typeof action.result.retryAt === "string" - ? new Date(action.result.retryAt) - : null; - if (retryAt && retryAt > new Date()) return; - } else if (action.status !== "received") { - return; - } - const attempt = - (typeof action.result?.attempts === "number" ? action.result.attempts : 0) + - 1; - const retainedGitHubReceipt = parseGitHubReceiptIdentity( - action.result?.githubReceipt, - ); - const [claimed] = await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatActionWork(tx, "receipt_reaction"); - const rows = await tx - .update(chatActions) - .set({ - status: "processing", - result: { - attempts: attempt, - ...(retainedGitHubReceipt - ? { githubReceipt: retainedGitHubReceipt } - : {}), - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action!.id), - eq(chatActions.status, action!.status), - ), - ) - .returning(); - await credentialLease.assertOwned(tx); - return rows; - }); - if (!claimed) return; - - let reactionProvider: ChatProvider | null = null; - let githubReceipt: GitHubReceiptIdentity | null = retainedGitHubReceipt; - try { - const record = await endpointRecord(action.endpointId); - if ( - !record || - !["verifying", "active"].includes(record.endpoint.status) || - runtimeGeneration(record.endpoint.setup) !== payload.runtimeGeneration || - credentialFingerprint(record.credentialSecretRefs) !== - payload.credentialFingerprint - ) { - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatActionWork(tx, "receipt_reaction"); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - attempts: attempt, - code: "receipt_reaction_runtime_superseded", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action!.id), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, - ), - ); - await credentialLease.assertOwned(tx); - }); - return; - } - reactionProvider = record.endpoint.provider; - // Resolve the adapter only after acquiring the credential lease and - // proving the action's durable generation/fingerprint is still current. - // Never reuse the live callback's adapter: it may hold an installation - // token minted by a superseded private key. - const endpointRuntime = await runtimeFor(record.endpoint); - const adapter = endpointRuntime.thread(payload.threadId).adapter; - await credentialLease.assertOwned(); - if (record.endpoint.provider === "slack") { - await endpointRuntime.applySlackReceiptReaction(payload, fetchImpl); - } else if (record.endpoint.provider === "github") { - const assertCurrent = async () => { - await credentialLease.assertOwned(); - const [current] = await db - .select({ endpoint: chatEndpoints, connection: toolConnections }) - .from(chatEndpoints) - .innerJoin( - toolConnections, - and( - eq(toolConnections.id, chatEndpoints.connectionId), - eq(toolConnections.companyId, chatEndpoints.companyId), - ), - ) - .where( - and( - eq(chatEndpoints.id, action!.endpointId), - eq(chatEndpoints.companyId, action!.companyId), - ), - ) - .limit(1); - if ( - !current || - !["active", "verifying"].includes(current.endpoint.status) || - !current.connection.enabled || - current.connection.status !== "active" || - runtimeGeneration(current.endpoint.setup) !== - payload.runtimeGeneration || - credentialFingerprint(current.connection.credentialSecretRefs) !== - payload.credentialFingerprint || - runtime.get(action!.endpointId) !== endpointRuntime - ) - throw new Error("GitHub receipt authority is no longer current"); - }; - const applied = await endpointRuntime.applyGitHubReceiptReaction( - { ...payload, ...(githubReceipt ? { githubReceipt } : {}) }, - assertCurrent, - fetchImpl, - ); - githubReceipt = parseGitHubReceiptIdentity(applied); - if ( - !githubReceipt || - (payload.operation === "add" && !githubReceipt.reactionId) - ) - throw new Error("GitHub receipt identity was not confirmed"); - } else if (payload.operation === "remove") { - await adapter.removeReaction( - payload.threadId, - payload.messageId, - payload.reaction, - ); - } else { - await adapter.addReaction( - payload.threadId, - payload.messageId, - payload.reaction, - ); - } - // The provider may already have accepted the idempotent reaction. Fence - // the local success receipt so a reclaimed lease cannot report success - // for an obsolete runtime. - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatActionWork(tx, "receipt_reaction"); - const processed = await tx - .update(chatActions) - .set({ - status: "processed", - result: { - attempts: attempt, - ...(githubReceipt ? { githubReceipt } : {}), - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, - ), - ) - .returning({ id: chatActions.id }); - if (!processed.length) { - throw new Error( - "Receipt-reaction ownership changed before success settlement", - ); - } - if (action.deliveryId) { - await tx - .update(chatDeliveries) - .set({ redactedError: null, updatedAt: new Date() }) - .where( - and( - eq(chatDeliveries.id, action.deliveryId), - eq(chatDeliveries.state, "processed"), - like(chatDeliveries.redactedError, "Receipt reaction %"), - ), - ); - } - await credentialLease.assertOwned(tx); - }); - } catch (error) { - if ( - payload.operation === "add" && - reactionProvider === "slack" && - isSlackReceiptReactionAlreadyApplied(error) - ) { - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatActionWork(tx, "receipt_reaction"); - const processed = await tx - .update(chatActions) - .set({ - status: "processed", - result: { - attempts: attempt, - code: "receipt_reaction_already_applied", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action!.id), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, - ), - ) - .returning({ id: chatActions.id }); - if (!processed.length) { - throw new Error( - "Receipt-reaction ownership changed before idempotent settlement", - ); - } - if (action.deliveryId) { - await tx - .update(chatDeliveries) - .set({ redactedError: null, updatedAt: new Date() }) - .where( - and( - eq(chatDeliveries.id, action.deliveryId), - eq(chatDeliveries.state, "processed"), - like(chatDeliveries.redactedError, "Receipt reaction %"), - ), - ); - } - await credentialLease.assertOwned(tx); - }); - return; - } - const disposition = classifyChatPublicationError(error, attempt); - const retryable = - disposition.kind === "retry" || - (disposition.kind === "delivery_unknown" && attempt < 3); - const retryAfterMs = - disposition.kind === "retry" - ? disposition.retryAfterMs - : 100 * 2 ** Math.max(0, attempt - 1); - const operationLabel = - payload.operation === "remove" ? " reaction removal" : " reaction"; - const diagnostic = - `Receipt${operationLabel} ${retryable ? "deferred" : "failed"} after ${attempt} attempt${attempt === 1 ? "" : "s"} (${disposition.kind}): ${redactError(error)}`.slice( - 0, - MAX_ERROR_TEXT, - ); - const retryAt = retryable - ? new Date(Date.now() + retryAfterMs).toISOString() - : null; - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatActionWork(tx, "receipt_reaction"); - const [failed] = await tx - .update(chatActions) - .set({ - status: "failed", - result: { - attempts: attempt, - ...(githubReceipt ? { githubReceipt } : {}), - code: - payload.operation === "remove" - ? `receipt_reaction_removal_${disposition.kind}` - : `receipt_reaction_${disposition.kind}`, - redactedError: diagnostic, - retryable, - retryAt, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, - ), - ) - .returning({ id: chatActions.id }); - if (failed && action.deliveryId) { - await tx - .update(chatDeliveries) - .set({ redactedError: diagnostic, updatedAt: new Date() }) - .where( - and( - eq(chatDeliveries.id, action.deliveryId), - eq(chatDeliveries.state, "processed"), - ), - ); - } - await credentialLease.assertOwned(tx); - }); - logger.warn( - { - endpointId: action.endpointId, - deliveryId: action.deliveryId, - attempts: attempt, - disposition: disposition.kind, - operation: payload.operation, - retryAt, - }, - "external chat receipt reaction deferred", - ); - } - } - - async function processPendingReceiptReactions( - limit = 25, - onlyActionId?: string, - ) { - const actions = await db.select({ id: chatActions.id }).from(chatActions) - .where(and(dueChatAction("receipt_reaction"), onlyActionId ? eq(chatActions.id, onlyActionId) : undefined)) - .orderBy(asc(chatActions.createdAt)).limit(limit); - for (const action of actions) await processReceiptReaction(action.id); - return actions.length; - } - - async function stageProviderEffect( - database: DbOrTransaction, - input: { - endpoint: EndpointRow; - deliveryId?: string | null; - conversationId?: string | null; - principalId?: string | null; - providerActionId: string; - payload: Omit< - ProviderEffectPayload, - "runtimeGeneration" | "credentialFingerprint" - >; - runtimeContext: LifecycleRuntimeFence; - }, - ) { - await notifyChatActionWork(database, "provider_effect"); - const [inserted] = await database - .insert(chatActions) - .values({ - companyId: input.endpoint.companyId, - endpointId: input.endpoint.id, - deliveryId: input.deliveryId ?? null, - conversationId: input.conversationId ?? null, - principalId: input.principalId ?? null, - kind: "provider_effect", - providerActionId: input.providerActionId, - payload: { - ...input.payload, - runtimeGeneration: input.runtimeContext.generation, - credentialFingerprint: input.runtimeContext.credentialFingerprint, - }, - status: "received", - }) - .onConflictDoNothing() - .returning(); - if (inserted) return inserted; - return database - .select() - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, input.endpoint.id), - eq(chatActions.providerActionId, input.providerActionId), - eq(chatActions.kind, "provider_effect"), - ), - ) - .then((rows) => rows[0] ?? null); - } - async function stageAuthorizedTaskControlPublication( tx: DbOrTransaction, input: { @@ -3145,809 +2017,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return publication; } - function providerEffectDisposition( - error: unknown, - attempt: number, - providerEffectCompleted: boolean, - ) { - if (!providerEffectCompleted) - return classifyChatPublicationError(error, attempt); - return { - kind: "delivery_unknown" as const, - reason: - "Provider effect completed, but Paperclip could not confirm its durable result", - }; - } - - async function finalizeProviderEffectFailure(input: { - action: typeof chatActions.$inferSelect; - endpoint: EndpointRow; - payload: ProviderEffectPayload; - disposition: ReturnType; - attempt: number; - }): Promise { - const failure = redactSensitiveText(input.disposition.reason).slice( - 0, - MAX_ERROR_TEXT, - ); - const retryable = input.disposition.kind === "retry"; - const retryAt = - input.disposition.kind === "retry" - ? new Date(Date.now() + input.disposition.retryAfterMs) - : null; - let invalidateCurrentRuntime = false; - const finalized = await db.transaction(async (tx) => { - // Admission locks the endpoint before its delivery. Keep that order - // when failure settlement also needs to change endpoint/resource health. - const currentEndpoint = - input.disposition.kind === "endpoint_attention" || - input.disposition.kind === "resource_unavailable" - ? await runtimeCallbackEndpoint( - tx, - input.endpoint.id, - { - generation: input.payload.runtimeGeneration, - credentialFingerprint: input.payload.credentialFingerprint, - }, - ["verifying", "active"], - ) - : null; - await notifyChatActionWork(tx, "provider_effect"); - const [ownedAction] = await tx - .update(chatActions) - .set({ - status: - input.disposition.kind === "delivery_unknown" - ? "delivery_unknown" - : "failed", - result: { - code: `provider_effect_${input.disposition.kind}`, - attempts: input.attempt, - redactedError: failure, - retryable, - retryAt: retryAt?.toISOString() ?? null, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, input.action.id), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${input.attempt}`, - ), - ) - .returning({ id: chatActions.id }); - if (!ownedAction) return false; - if (input.payload.settleDelivery && input.action.deliveryId) { - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: retryable ? "retry" : "failed", - nextAttemptAt: retryAt, - redactedError: - input.disposition.kind === "delivery_unknown" - ? `Provider response is unconfirmed: ${failure}`.slice( - 0, - MAX_ERROR_TEXT, - ) - : failure, - updatedAt: new Date(), - }) - .where( - and( - eq(chatDeliveries.id, input.action.deliveryId), - eq(chatDeliveries.state, "processing"), - ), - ); - } - if (input.disposition.kind === "endpoint_attention" && currentEndpoint) { - await tx - .update(chatEndpoints) - .set({ - status: "attention", - healthMessage: "Provider credentials or permissions need attention", - lastError: failure, - updatedAt: new Date(), - }) - .where(eq(chatEndpoints.id, currentEndpoint.id)); - await tx - .update(toolConnections) - .set({ - status: "disabled", - enabled: false, - healthStatus: "degraded", - healthMessage: "Provider credentials or permissions need attention", - lastError: failure, - healthCheckedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(toolConnections.id, currentEndpoint.connectionId)); - invalidateCurrentRuntime = true; - } else if ( - input.disposition.kind === "resource_unavailable" && - input.payload.resourceId && - currentEndpoint - ) { - await tx - .update(chatEndpointResources) - .set({ availability: "unavailable", updatedAt: new Date() }) - .where( - and( - eq(chatEndpointResources.id, input.payload.resourceId), - eq(chatEndpointResources.endpointId, currentEndpoint.id), - ), - ); - } - return true; - }); - if (finalized && invalidateCurrentRuntime) { - await invalidateRuntime(input.endpoint.id).catch(() => undefined); - } - return finalized; - } - - async function quarantineStaleProviderEffect( - action: typeof chatActions.$inferSelect, - ) { - const payload = providerEffectPayload(action.payload); - const failure = - "Provider delivery could not be confirmed after the worker stopped. The effect will not be replayed automatically."; - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "provider_effect"); - const [quarantined] = await tx - .update(chatActions) - .set({ - status: "delivery_unknown", - result: { - code: "provider_effect_delivery_unknown", - redactedError: failure, - retryable: false, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - eq(chatActions.updatedAt, action.updatedAt), - sql`(${chatActions.result}->>'attempts')::int = ${ - typeof action.result?.attempts === "number" - ? action.result.attempts - : -1 - }`, - lte( - chatActions.updatedAt, - new Date(Date.now() - PROVIDER_EFFECT_STALE_MS), - ), - ), - ) - .returning({ id: chatActions.id }); - if (quarantined && payload?.settleDelivery && action.deliveryId) { - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: "failed", - nextAttemptAt: null, - redactedError: failure, - updatedAt: new Date(), - }) - .where( - and( - eq(chatDeliveries.id, action.deliveryId), - eq(chatDeliveries.state, "processing"), - ), - ); - } - }); - } - - function providerEffectThreadResourceId( - provider: ChatProvider, - threadId: string, - ): string | null { - if (provider === "slack") { - return ( - slackThreadChannelId(threadId) ?? - (!threadId.includes(":") ? threadId : null) - ); - } - if (provider === "telegram") { - return /^telegram:([^:]+)/.exec(threadId)?.[1] ?? null; - } - if (provider === "github") { - return /^github:([^:]+)/.exec(threadId)?.[1]?.toLowerCase() ?? null; - } - if (provider === "discord") { - return /^discord:[^:]+:([^:]+)/.exec(threadId)?.[1] ?? null; - } - const conversationId = teamsConversationId(threadId); - return conversationId ? baseTeamsConversationId(conversationId) : null; - } - - // This is permission to return one fixed, neutral callback denial, not - // permission to execute the denied action or publish to the conversation. - // Retain the initial identity-policy snapshot so queued notices cannot cross - // a subsequent actor/link/reach change. No provider I/O occurs under locks. - async function lockTelegramCallbackNoticeAuthority( - tx: DbTransaction, - endpoint: EndpointRow, - receipt: TelegramCallbackReceipt, - principalId: string | null, - ): Promise { - if ( - endpoint.provider !== "telegram" || - endpoint.status !== "active" || - endpoint.companyId !== receipt.companyId || - endpoint.id !== receipt.endpointId || - endpoint.botExternalId !== receipt.botUserId || - !endpoint.providerAccountId || - !principalId || - Date.now() < receipt.receivedAtMs || - Date.now() >= receipt.deadlineAtMs - ) - return null; - const resource = - receipt.chatType === "private" - ? null - : await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.providerResourceId, receipt.chatId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if ( - receipt.chatType === "private" - ? !endpoint.allowDirectMessages - : !resource || !nonDirectDestinationAllowed(endpoint, resource) - ) - return null; - const principal = await tx - .select() - .from(chatExternalPrincipals) - .where( - and( - eq(chatExternalPrincipals.companyId, endpoint.companyId), - eq(chatExternalPrincipals.provider, endpoint.provider), - eq( - chatExternalPrincipals.providerAccountId, - endpoint.providerAccountId, - ), - eq(chatExternalPrincipals.id, principalId), - eq(chatExternalPrincipals.externalId, receipt.receiverUserId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if (!principal || principal.kind !== "user" || principal.isBot) return null; - const authorization = await lockCurrentPrincipalAuthorization( - tx, - endpoint, - principal.id, - ); - const link = await tx - .select({ - status: chatIdentityLinks.status, - userId: chatIdentityLinks.paperclipUserId, - }) - .from(chatIdentityLinks) - .where( - and( - eq(chatIdentityLinks.companyId, endpoint.companyId), - eq(chatIdentityLinks.endpointId, endpoint.id), - eq(chatIdentityLinks.principalId, principal.id), - ), - ) - .then((rows) => rows[0] ?? null); - return createHash("sha256") - .update( - JSON.stringify([ - endpoint.companyId, - endpoint.id, - receipt.botUserId, - resource?.id ?? null, - principal.id, - principal.externalId, - link, - authorization, - endpoint.allowUnlinkedPeople, - endpoint.sponsorUserId, - ]), - ) - .digest("hex"); - } - - async function lockCurrentProviderEffectAuthorization( - tx: DbTransaction, - action: typeof chatActions.$inferSelect, - endpoint: EndpointRow, - payload: ProviderEffectPayload, - ): Promise { - if (payload.effect === "telegram_callback_notice") { - const receipt = parseTelegramCallbackReceipt(payload.telegramCallback); - if (!receipt || !action.deliveryId) return false; - const delivery = await tx - .select() - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.id, action.deliveryId), - eq(chatDeliveries.companyId, action.companyId), - eq(chatDeliveries.endpointId, action.endpointId), - eq(chatDeliveries.state, "filtered"), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const retained = - delivery && - parseTelegramCallbackReceipt(delivery.normalizedEvent.telegramCallback); - if ( - !delivery || - delivery.principalId !== action.principalId || - retained?.sourceSha256 !== receipt.sourceSha256 || - delivery.normalizedEvent.telegramAuthorizationSha256 !== - payload.telegramAuthorizationSha256 - ) - return false; - return ( - (await lockTelegramCallbackNoticeAuthority( - tx, - endpoint, - receipt, - action.principalId, - )) === payload.telegramAuthorizationSha256 - ); - } - const conversation = action.conversationId - ? await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, action.conversationId), - inArray(chatConversations.state, ["active", "waiting"]), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - if (action.conversationId && !conversation) return false; - const inferredResourceId = providerEffectThreadResourceId( - endpoint.provider, - payload.threadId, - ); - const resource = await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - payload.resourceId || conversation?.resourceId - ? eq( - chatEndpointResources.id, - payload.resourceId ?? conversation!.resourceId!, - ) - : inferredResourceId - ? eq(chatEndpointResources.providerResourceId, inferredResourceId) - : sql`false`, - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const slackChannelId = - endpoint.provider === "slack" - ? slackThreadChannelId(payload.threadId) - : null; - const isDirectMessage = - conversation?.isDirectMessage === true || - resource?.type === "direct_message" || - Boolean(slackChannelId && /^D[A-Z0-9]+$/i.test(slackChannelId)); - const destinationAllowed = isDirectMessage - ? endpoint.allowDirectMessages - : nonDirectDestinationAllowed(endpoint, resource); - if (payload.authorizationMode === "safe_notice") return true; - if (!destinationAllowed) return false; - if (!action.principalId) return false; - return ( - await lockCurrentPrincipalAuthorization(tx, endpoint, action.principalId) - ).allowed; - } - - async function cancelUnauthorizedProviderEffect( - tx: DbTransaction, - action: typeof chatActions.$inferSelect, - attempt: number, - ): Promise { - await notifyChatActionWork(tx, "provider_effect"); - const [cancelled] = await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - attempts: attempt, - code: "provider_effect_no_longer_authorized", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, - ), - ) - .returning({ id: chatActions.id }); - if (!cancelled) return false; - if (action.deliveryId) { - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: "filtered", - processedAt: new Date(), - nextAttemptAt: null, - redactedError: - "Provider reply was suppressed because current chat authorization changed", - updatedAt: new Date(), - }) - .where( - and( - eq(chatDeliveries.id, action.deliveryId), - eq(chatDeliveries.state, "processing"), - ), - ); - } - return true; - } - - async function processProviderEffect( - actionId: string, - liveTarget?: ProviderEffectTarget, - ): Promise<"processed" | "pending" | "failed" | "delivery_unknown"> { - const initial = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.id, actionId), - eq(chatActions.kind, "provider_effect"), - ), - ) - .then((rows) => rows[0] ?? null); - if (!initial) return "failed"; - const initialEndpoint = await endpointRecord(initial.endpointId); - if (!initialEndpoint) return "failed"; - - const processClaimedEffect = async () => { - let action = await db - .select() - .from(chatActions) - .where(eq(chatActions.id, actionId)) - .then((rows) => rows[0] ?? null); - if (!action) return "failed"; - const payload = providerEffectPayload(action.payload); - if (!payload) { - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "provider_effect"); - return tx - .update(chatActions) - .set({ - status: "failed", - result: { code: "provider_effect_payload_invalid" }, - updatedAt: new Date(), - }) - .where(eq(chatActions.id, action.id)); - }); - return "failed"; - } - if (action.status === "processed") return "processed"; - if (action.status === "delivery_unknown") return "delivery_unknown"; - if (action.status === "processing") { - if (action.updatedAt > new Date(Date.now() - PROVIDER_EFFECT_STALE_MS)) - return "pending"; - await quarantineStaleProviderEffect(action); - return "delivery_unknown"; - } - if (action.status === "failed") { - if (action.result?.retryable !== true) return "failed"; - const retryAt = - typeof action.result.retryAt === "string" - ? new Date(action.result.retryAt) - : null; - if (retryAt && retryAt > new Date()) return "pending"; - } else if (action.status !== "received") { - return "failed"; - } - - const attempt = - (typeof action.result?.attempts === "number" - ? action.result.attempts - : 0) + 1; - const record = await endpointRecord(action.endpointId); - let providerEffectCompleted = false; - try { - if (!record) { - throw Object.assign( - new Error("Chat endpoint is unavailable for this provider effect"), - { code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" }, - ); - } - return await withCredentialMutationLease( - record.endpoint, - async (credentialLease) => { - const claim = await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const currentAction = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.id, action!.id), - eq(chatActions.kind, "provider_effect"), - eq(chatActions.status, action!.status), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if (!currentAction) return null; - const currentEndpoint = await runtimeCallbackEndpoint( - tx, - currentAction.endpointId, - { - generation: payload.runtimeGeneration, - credentialFingerprint: payload.credentialFingerprint, - }, - ["verifying", "active"], - ); - const authorized = currentEndpoint - ? await lockCurrentProviderEffectAuthorization( - tx, - currentAction, - currentEndpoint, - payload, - ) - : false; - await notifyChatActionWork(tx, "provider_effect"); - const [transportClaim] = await tx - .update(chatActions) - .set({ - status: "processing", - result: { attempts: attempt }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, currentAction.id), - eq(chatActions.status, currentAction.status), - ), - ) - .returning(); - if (!transportClaim) return null; - if (!currentEndpoint || !authorized) { - const cancelled = await cancelUnauthorizedProviderEffect( - tx, - transportClaim, - attempt, - ); - if (!cancelled) { - throw new Error( - "Provider effect ownership changed before authorization cancellation", - ); - } - return { outcome: "cancelled" as const }; - } - await credentialLease.assertOwned(tx); - return { - action: transportClaim, - endpoint: currentEndpoint, - outcome: "claimed" as const, - }; - }); - if (!claim) return "pending"; - if (claim.outcome === "cancelled") return "failed"; - action = claim.action; - - // `processing` is the durable, single-owner transport claim and - // authorization linearization point. Its transaction has committed - // before provider I/O begins. The renewable credential lease keeps - // runtime identity stable without holding database row locks. - let sent: { id: string; threadId: string } | null = null; - if (payload.effect === "telegram_callback_notice") { - sent = await ( - await runtimeFor(claim.endpoint) - ).sendTelegramCallbackNotice( - payload.telegramCallback!, - payload.text, - ); - } else { - const target = - liveTarget ?? - (await runtimeFor(claim.endpoint)).thread(payload.threadId); - if (payload.effect === "ephemeral_message") { - if (!target.postEphemeral) { - if (!payload.fallbackText) { - throw Object.assign( - new Error("Provider does not support ephemeral messages"), - { code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" }, - ); - } - sent = await target.post(payload.fallbackText); - } else { - try { - sent = await target.postEphemeral( - payload.userId!, - payload.text, - { fallbackToDM: false }, - ); - } catch (error) { - if ( - !payload.fallbackText || - classifyChatPublicationError(error, attempt).kind !== - "failed" - ) { - throw error; - } - } - if (!sent && payload.fallbackText) { - sent = await target.post(payload.fallbackText); - } - } - } else if (claim.endpoint.provider === "imessage-photon") { - const adapter = (await runtimeFor(claim.endpoint)).getProviderAdapter(); - if (!(adapter instanceof PhotonChatAdapter)) throw new Error("Photon runtime unavailable"); - const receipt = await adapter.publish(payload.threadId, `effect:${action.id}`, projectSafeChatPublicationText(payload.text), { - assertCurrent: async () => { await credentialLease.assertOwned(); }, - }); - sent = { id: receipt.id, threadId: payload.threadId }; - } else { - sent = await target.post(payload.text); - } - } - if (!sent) { - throw Object.assign( - new Error("Provider did not accept the message"), - { code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" }, - ); - } - providerEffectCompleted = true; - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - // A duplicate webhook holds the endpoint while waiting for the - // delivery's unique-index entry. Lock this endpoint first too, - // before settling the action/conversation/delivery, so neither - // transaction can wait on a row already held by the other. - const currentEndpoint = await runtimeCallbackEndpoint( - tx, - claim.endpoint.id, - { - generation: payload.runtimeGeneration, - credentialFingerprint: payload.credentialFingerprint, - }, - ["verifying", "active"], - ); - await notifyChatActionWork(tx, "provider_effect"); - const [completed] = await tx - .update(chatActions) - .set({ - status: "processed", - result: { - attempts: attempt, - providerMessageId: sent.id, - threadId: sent.threadId, - ...(payload.effect === "telegram_callback_notice" - ? { - code: - payload.telegramCallback?.chatType === "private" - ? "telegram_private_api_accepted" - : "telegram_ephemeral_api_accepted", - } - : {}), - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action!.id), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, - ), - ) - .returning({ id: chatActions.id }); - if (!completed) { - throw new Error( - "Provider effect ownership changed before commit", - ); - } - if (payload.completeConversationId) { - await tx - .update(chatConversations) - .set({ state: "completed", updatedAt: new Date() }) - .where( - and( - eq(chatConversations.id, payload.completeConversationId), - eq(chatConversations.endpointId, claim.endpoint.id), - inArray(chatConversations.state, ["active", "waiting"]), - ), - ); - } - if (payload.settleDelivery && action!.deliveryId) { - await notifyChatDeliveryWork(tx); - const [settled] = await tx - .update(chatDeliveries) - .set({ - state: "processed", - processedAt: new Date(), - nextAttemptAt: null, - redactedError: null, - updatedAt: new Date(), - }) - .where( - and( - eq(chatDeliveries.id, action!.deliveryId), - eq(chatDeliveries.state, "processing"), - ), - ) - .returning({ id: chatDeliveries.id }); - if (!settled) { - throw new Error( - "Provider effect delivery ownership changed before commit", - ); - } - } - if (currentEndpoint) { - await tx - .update(chatEndpoints) - .set({ lastEventAt: new Date(), updatedAt: new Date() }) - .where(eq(chatEndpoints.id, currentEndpoint.id)); - } - await credentialLease.assertOwned(tx); - }); - return "processed"; - }, - ); - } catch (error) { - const disposition = providerEffectDisposition( - error, - attempt, - providerEffectCompleted, - ); - try { - await finalizeProviderEffectFailure({ - action, - endpoint: record?.endpoint ?? initialEndpoint.endpoint, - payload, - disposition, - attempt, - }); - } catch (finalizationError) { - if (disposition.kind === "delivery_unknown") { - throw Object.assign( - new Error( - "Provider effect is ambiguous and its durable quarantine failed", - { cause: finalizationError }, - ), - { code: "CHAT_PROVIDER_EFFECT_AMBIGUOUS" }, - ); - } - throw finalizationError; - } - throw error; - } - }; - return processClaimedEffect(); - } - function scheduleProviderEffect( actionId: string, liveTarget?: ProviderEffectTarget, @@ -3957,36 +2026,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }); } - async function processPendingProviderEffects(limit = 25) { - const now = new Date(); - const staleBefore = new Date(now.getTime() - PROVIDER_EFFECT_STALE_MS); - const actions = await db - .select() - .from(chatActions) - .where(dueChatAction("provider_effect", now.getTime())) - .orderBy(asc(chatActions.createdAt)) - .limit(limit); - for (const action of actions) { - if (action.status === "processing" && action.updatedAt <= staleBefore) { - await quarantineStaleProviderEffect(action); - continue; - } - try { - await processProviderEffect(action.id); - } catch (error) { - logger.warn( - { - endpointId: action.endpointId, - actionId: action.id, - error: redactError(error), - }, - "chat provider effect reconciliation failed", - ); - } - } - return actions.length; - } - async function runtimeCallbackEndpoint( tx: DbTransaction, endpointId: string, @@ -4102,212 +2141,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ); } - function discordGatewayFailureDetail( - event: DiscordGatewayCallbackEvent["event"], - ): string { - if (event.type === "guild_removed") { - return "Discord bot was removed from the configured server"; - } - if (event.type === "guild_unavailable") { - return "Discord server is temporarily unavailable; reconnecting"; - } - if (event.type === "disconnected") { - return `Discord Gateway disconnected${event.code === undefined ? "" : ` (code ${event.code})`}; reconnecting`; - } - if (event.type === "failure") { - const code = event.error.code ?? event.error.status; - return `Discord Gateway failed${code === undefined ? "" : ` (${String(code).slice(0, 64)})`}; ${event.fatal ? "credentials or privileged intents need attention" : "reconnecting"}`; - } - return "Discord Gateway connection needs attention"; - } - - function handleDiscordGatewayEvent( - callback: DiscordGatewayCallbackEvent, - context: RuntimeContext, - ): Promise { - const pending = (context.discordGatewayEventQueue ?? Promise.resolve()) - .catch(() => undefined) - .then(() => processDiscordGatewayEvent(callback, context)); - context.discordGatewayEventQueue = pending; - return pending; - } - - async function processDiscordGatewayEvent( - callback: DiscordGatewayCallbackEvent, - context: RuntimeContext, - ): Promise { - if ( - !(await ensureDiscordGatewayRuntimeIsCurrent( - callback.endpointId, - context, - )) - ) - return; - if (callback.sequence <= (context.discordGatewaySequence ?? 0)) return; - context.discordGatewaySequence = callback.sequence; - await options.discordGatewayEventBarrier?.(callback); - const record = await runtimeCallbackRecord(callback.endpointId, context, [ - "verifying", - "active", - "attention", - ]); - if (!record || record.endpoint.provider !== "discord") return; - const event = callback.event; - if (event.type === "channel_removed") { - await applyProviderLifecycleEffect( - record.endpoint, - { - kind: "resource", - provider: "discord", - providerEventId: `gateway:channel_removed:${event.channelId}`, - providerResourceId: event.channelId, - resourceType: "channel", - label: event.label?.trim() || event.channelId, - availability: "removed", - metadata: { source: "discord_gateway" }, - }, - context, - ); - return; - } - - await db.transaction(async (tx) => { - const current = await runtimeCallbackEndpoint( - tx, - callback.endpointId, - context, - ["verifying", "active", "attention"], - ); - if ( - !current || - current.provider !== "discord" || - !discordGatewayRuntimeIsCurrent(callback.endpointId, context) - ) { - return; - } - const now = new Date(); - if (event.type === "connecting") { - await tx - .update(chatEndpoints) - .set({ - healthMessage: "Discord Gateway connecting", - lastError: null, - lastEventAt: now, - updatedAt: now, - }) - .where(eq(chatEndpoints.id, current.id)); - await tx - .update(toolConnections) - .set({ - healthStatus: "unchecked", - healthMessage: "Discord Gateway connecting", - lastError: null, - healthCheckedAt: now, - updatedAt: now, - }) - .where(eq(toolConnections.id, current.connectionId)); - return; - } - if (event.type === "ready" || event.type === "guild_available") { - const gatewayOwnedAttention = - current.status === "attention" && - current.healthMessage?.startsWith("Discord ") === true; - if (current.status === "attention" && !gatewayOwnedAttention) return; - const status = - current.setup.step === "complete" ? "active" : "verifying"; - if (status !== current.status) await notifyChatEndpointWork(tx); - await tx - .update(chatEndpoints) - .set({ - status, - healthMessage: - status === "active" - ? "Connected" - : "Waiting for a test conversation", - lastError: null, - lastEventAt: now, - updatedAt: now, - }) - .where(eq(chatEndpoints.id, current.id)); - await tx - .update(toolConnections) - .set({ - status: "active", - enabled: true, - healthStatus: "healthy", - healthMessage: "Connected", - lastError: null, - healthCheckedAt: now, - updatedAt: now, - }) - .where(eq(toolConnections.id, current.connectionId)); - return; - } - - const detail = discordGatewayFailureDetail(event); - const transientGuildOutage = event.type === "guild_unavailable"; - const terminal = - event.type === "guild_removed" || - ((event.type === "failure" || event.type === "disconnected") && - event.fatal); - await tx - .update(chatEndpoints) - .set({ - status: transientGuildOutage - ? current.status - : event.type === "guild_removed" - ? "revoked" - : "attention", - healthMessage: detail, - lastError: detail, - lastEventAt: now, - ...(terminal - ? { - setup: { - ...current.setup, - runtimeGeneration: runtimeGeneration(current.setup) + 1, - } as InternalSetupState, - } - : {}), - updatedAt: now, - }) - .where(eq(chatEndpoints.id, current.id)); - await tx - .update(toolConnections) - .set({ - status: terminal ? "disabled" : "active", - enabled: !terminal, - healthStatus: event.type === "guild_removed" ? "failed" : "degraded", - healthMessage: detail, - lastError: detail, - healthCheckedAt: now, - updatedAt: now, - }) - .where(eq(toolConnections.id, current.connectionId)); - if (event.type === "guild_removed") { - await tx - .update(chatEndpointResources) - .set({ availability: "unavailable", updatedAt: now }) - .where( - and( - eq(chatEndpointResources.companyId, current.companyId), - eq(chatEndpointResources.endpointId, current.id), - ), - ); - await tx - .update(chatConversations) - .set({ state: "unavailable", updatedAt: now }) - .where( - and( - eq(chatConversations.companyId, current.companyId), - eq(chatConversations.endpointId, current.id), - inArray(chatConversations.state, ["active", "waiting"]), - ), - ); - } - }); - } - function serializeEndpoint( row: Awaited>, ): ChatEndpoint { @@ -5270,6 +3103,16 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } const withCredentialMutationLease = chatCredentialMutationLease(db, options); + const { setupTestStatus, test } = createSetupProbes(db, { endpointRecord, withCredentialMutationLease, fetchImpl, findAuthorizedIdentityLink, runtimeGeneration, lockCurrentPrincipalAuthorization, get, setupTestActivationBarrier: () => options.setupTestActivationBarrier?.() ?? Promise.resolve() }); + const { listConversations, listActivity, listActivityPage } = createChatActivity(db, { endpointRecord, teamsTransferProtocol }); + const { lockTelegramCallbackNoticeAuthority } = createTelegramCallbackNoticeAuthority({ nonDirectDestinationAllowed, lockCurrentPrincipalAuthorization }); + const { stageProviderEffect, processProviderEffect, processPendingProviderEffects } = createProviderEffects(db, { endpointRecord, runtimeCallbackEndpoint, withCredentialMutationLease, runtimeFor, invalidateRuntime, lockCurrentPrincipalAuthorization, redactError, MAX_ERROR_TEXT, nonDirectDestinationAllowed, lockTelegramCallbackNoticeAuthority }); + const { publicationFiles } = createOutboundAttachments(db, { get storage() { return options.storage; } }); + const { ingestAttachments, attachmentOmissionDetail } = createInboundAttachments(db, { authorizeInboundWakeup, lifecycleRuntimeFence, sanitizeFilename, issuesSvc, isExternalActionAuthorizationChange, redactError, get storage() { return options.storage; } }); + const { handleReaction, processPendingReactionDeliveries } = createInboundReactions(db, { lockCurrentPrincipalAuthorization, runtimeCallbackRecord, runtimeCallbackEndpoint, ensurePrincipal, durableExternalThreadIdentity, recordCurrentMicrosoftTeamsRoute, lifecycleRuntimeFence, deliveryReady, redactError, nonDirectDestinationAllowed, reactionLinkPreflightBarrier: () => options.reactionLinkPreflightBarrier?.() ?? Promise.resolve(), reactionReplayEndpointLockBarrier: () => options.reactionReplayEndpointLockBarrier?.() ?? Promise.resolve(), reactionReplayConversationLockBarrier: () => options.reactionReplayConversationLockBarrier?.() ?? Promise.resolve() }); + const { addReceiptReaction, processReceiptReaction, processPendingReceiptReactions } = createReceiptReactions(db, { endpointRecord, runtimeContextForRecord, withCredentialMutationLease, runtimeFor, fetchImpl, runtime, runtimeGeneration, credentialFingerprint, redactError, MAX_ERROR_TEXT, receiptReactionTransportBarrier: () => options.receiptReactionTransportBarrier?.() ?? Promise.resolve() }); + const { admitDiscordRootMention, discordRootThreadPending, reconcileDiscordRootThread } = createDiscordRootThreads(db, { ensureDiscordGatewayRuntimeIsCurrent, withCredentialMutationLease, runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization, processMessage, redactError, scheduleConversationDrain, lifecycleRuntimeFence, runtimeFor, DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS, discordRootThreadTransportBarrier: () => options.discordRootThreadTransportBarrier?.() ?? Promise.resolve() }); + const { handleDiscordGatewayEvent } = createDiscordGatewayEvents(db, { ensureDiscordGatewayRuntimeIsCurrent, discordGatewayRuntimeIsCurrent, runtimeCallbackRecord, runtimeCallbackEndpoint, applyProviderLifecycleEffect, runtimeGeneration, discordGatewayEventBarrier: (event) => options.discordGatewayEventBarrier?.(event) ?? Promise.resolve() }); const { handlePhotonFailure, handlePhotonEvent } = createPhotonEvents(db, { runtimeCallbackRecord, runtimeCallbackEndpoint, invalidateRuntime, ensurePrincipal, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, scheduleMessageProcessing, processPendingPublications, questionResponses, redactError, renewDiscordGatewayOwnershipForMessageAdmission, handleSdkMessage }); const { handleTelegramGenerationStopped, reserveTelegramPublicationDraft } = createTelegramPublicationDrafts(db, { runtimeCallbackRecord, runtimeCallbackEndpoint, runtimeFor, runtime, runtimeContexts, credentialFingerprint, authorizeRetainedChatSourcePublication }); const { recordSlackFileUploadReceipt, stageSlackFilePublicationSuccess, processPendingSlackFileUploadReceipts, nextSlackReceiptAt } = createSlackFileReceipts(db, { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runtimeFor, isExplicitOperatorPublication, recordExactOutboundPublicationLink }); @@ -7143,288 +4986,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .limit(1).then((rows) => rows[0] ?? null); } - async function setupTestStatus(endpointId: string, userId: string) { - const record = await endpointRecord(endpointId); - if (!record) throw notFound("Chat endpoint not found"); - const startedAt = record.endpoint.setup.testStartedAt; - if (!startedAt) return { messageReceivedAt: null }; - const links = await db.select({ principalId: chatIdentityLinks.principalId }).from(chatIdentityLinks).where(and( - eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.endpointId, endpointId), - eq(chatIdentityLinks.paperclipUserId, userId), eq(chatIdentityLinks.status, "linked"), - )); - if (!links.length) return { messageReceivedAt: null }; - const principalIds = links.map((link) => link.principalId); - const [delivery, command] = await Promise.all([ - db.select({ at: chatDeliveries.createdAt }).from(chatDeliveries).where(and( - eq(chatDeliveries.companyId, record.endpoint.companyId), eq(chatDeliveries.endpointId, endpointId), - inArray(chatDeliveries.principalId, principalIds), gte(chatDeliveries.createdAt, new Date(startedAt)), - inArray(chatDeliveries.eventKind, ["message", "mention"]), - )).orderBy(asc(chatDeliveries.createdAt)).limit(1).then((rows) => rows[0]), - db.select({ at: chatActions.createdAt }).from(chatActions).where(and( - eq(chatActions.companyId, record.endpoint.companyId), eq(chatActions.endpointId, endpointId), - inArray(chatActions.principalId, principalIds), eq(chatActions.kind, "slash_task_start"), gte(chatActions.createdAt, new Date(startedAt)), - )).orderBy(asc(chatActions.createdAt)).limit(1).then((rows) => rows[0]), - ]); - const at = [delivery?.at, command?.at].filter((value): value is Date => Boolean(value)).sort((a, b) => a.getTime() - b.getTime())[0]; - return { messageReceivedAt: at?.toISOString() ?? null }; - } - - async function test(endpointId: string, finishOptions?: { optionalSlackTestForUser: string }) { - const initial = await endpointRecord(endpointId); - if (!initial) throw notFound("Chat endpoint not found"); - return withCredentialMutationLease( - initial.endpoint, - async (credentialLease) => { - // Reload only after owning the same lane as reconnect, pause, removal, - // and credential rotation. Qualification and activation must describe - // one exact provider runtime, never a setup snapshot that a concurrent - // reconnect has already superseded. - const record = await endpointRecord(endpointId); - if (!record) throw notFound("Chat endpoint not found"); - const endpoint = record.endpoint; - if (endpoint.status !== "verifying" || endpoint.setup.step !== "test") { - throw conflict("This connection is not waiting for a setup test", { - code: "chat_endpoint_not_testing", - }); - } - if (endpoint.provider === "github" && endpoint.setup.github) { - const verified = await githubChatManagementService(db, fetchImpl).verification(endpoint.id); - if (!verified.ready && !(finishOptions?.optionalSlackTestForUser && verified.connectionReady)) throw conflict("Finish verifying the App, repositories, and assigned agent's tools before activating this bot", { checks: verified.checks }); - } - const testStartedAtValue = endpoint.setup.testStartedAt; - const testStartedAt = testStartedAtValue - ? new Date(testStartedAtValue) - : null; - const optionalSlackTest = Boolean(finishOptions?.optionalSlackTestForUser); - if (optionalSlackTest) { - if (!["slack", "github"].includes(endpoint.provider) || !endpoint.setup.webhookVerifiedAt || !endpoint.providerAccountId) { - throw conflict("Verify the connection before finishing setup"); - } - const linked = await findAuthorizedIdentityLink(endpoint, finishOptions!.optionalSlackTestForUser); - if (!linked) throw forbidden("Connect your account before finishing setup"); - } - if (!optionalSlackTest) { - if ( - !endpoint.lastEventAt || - !testStartedAtValue || - !testStartedAt || - Number.isNaN(testStartedAt.getTime()) || - endpoint.lastEventAt < testStartedAt - ) { - throw conflict( - "Send the test message in the provider before completing setup", - { - code: "chat_test_message_missing", - }, - ); - } - const requiredTrigger = - ["telegram", "imessage-photon"].includes(endpoint.provider) - ? "direct_message" - : "subscribed_message"; - const qualifyingDelivery = await db - .select({ - id: chatDeliveries.id, - conversationId: chatDeliveries.conversationId, - processedAt: chatDeliveries.processedAt, - }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.companyId, endpoint.companyId), - eq(chatDeliveries.endpointId, endpoint.id), - eq(chatDeliveries.state, "processed"), - gte(chatDeliveries.processedAt, testStartedAt), - sql`${chatDeliveries.normalizedEvent}->>'trigger' = ${requiredTrigger}`, - ), - ) - .orderBy(desc(chatDeliveries.processedAt)) - .limit(1) - .then((rows) => rows[0] ?? null); - if ( - !qualifyingDelivery?.conversationId || - !qualifyingDelivery.processedAt - ) { - throw conflict( - ["telegram", "imessage-photon"].includes(endpoint.provider) - ? "Send the test direct message before completing setup" - : "Reply once without mentioning the agent before completing setup", - { code: "chat_test_follow_up_missing" }, - ); - } - const finalPublication = await db - .select({ - commentId: chatPublications.commentId, - payload: chatPublications.payload, - }) - .from(chatPublications) - .innerJoin( - issueComments, - and( - eq(issueComments.id, chatPublications.commentId), - eq(issueComments.companyId, chatPublications.companyId), - eq(issueComments.issueId, chatPublications.issueId), - eq(issueComments.authorType, "agent"), - eq(issueComments.authorAgentId, endpoint.assignedAgentId), - ), - ) - .innerJoin( - chatMessageLinks, - and( - eq(chatMessageLinks.companyId, chatPublications.companyId), - eq(chatMessageLinks.endpointId, chatPublications.endpointId), - eq( - chatMessageLinks.conversationId, - chatPublications.conversationId, - ), - eq(chatMessageLinks.deliveryId, qualifyingDelivery.id), - eq(chatMessageLinks.direction, "inbound"), - isNotNull(chatMessageLinks.commentId), - ), - ) - .innerJoin( - heartbeatRuns, - and( - eq(heartbeatRuns.id, issueComments.createdByRunId), - eq(heartbeatRuns.companyId, chatPublications.companyId), - eq(heartbeatRuns.agentId, endpoint.assignedAgentId), - eq(heartbeatRuns.status, "succeeded"), - eq( - sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, - sql`${chatPublications.issueId}::text`, - ), - or( - sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`, - sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'commentId'`, - sql`coalesce(${heartbeatRuns.contextSnapshot} -> 'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`, - ), - ), - ) - .where( - and( - eq(chatPublications.companyId, endpoint.companyId), - eq(chatPublications.endpointId, endpoint.id), - eq( - chatPublications.conversationId, - qualifyingDelivery.conversationId, - ), - eq(chatPublications.state, "published"), - gte(chatPublications.publishedAt, qualifyingDelivery.processedAt), - ), - ) - .orderBy(desc(chatPublications.publishedAt)) - .then((rows) => - rows.find( - (row) => - row.payload.interactionId === undefined && - row.payload.progressState === undefined && - row.commentId !== null, - ), - ); - let githubToolReply = false; - if (!finalPublication && endpoint.provider === "github") { - const setupRuns = await db.select({ id: heartbeatRuns.id, issueId: chatConversations.issueId }) - .from(heartbeatRuns) - .innerJoin(chatMessageLinks, and( - eq(chatMessageLinks.companyId, endpoint.companyId), - eq(chatMessageLinks.endpointId, endpoint.id), - eq(chatMessageLinks.deliveryId, qualifyingDelivery.id), - eq(chatMessageLinks.direction, "inbound"), - or( - sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot}->>'wakeCommentId'`, - sql`coalesce(${heartbeatRuns.contextSnapshot}->'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`, - ), - )) - .innerJoin(chatConversations, and( - eq(chatConversations.id, qualifyingDelivery.conversationId), - eq(chatConversations.id, chatMessageLinks.conversationId), - eq(chatConversations.companyId, endpoint.companyId), - sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${chatConversations.issueId}::text`, - )) - .where(and(eq(heartbeatRuns.companyId, endpoint.companyId), - eq(heartbeatRuns.agentId, endpoint.assignedAgentId), eq(heartbeatRuns.status, "succeeded"))) - .orderBy(desc(heartbeatRuns.createdAt)).limit(20); - for (const run of setupRuns) { - if (await githubRunReplyState(db, { companyId: endpoint.companyId, endpointId: endpoint.id, - issueId: run.issueId, runId: run.id }) === "confirmed") { - githubToolReply = true; - break; - } - } - } - if (!finalPublication && !githubToolReply) { - throw conflict( - "Wait for the Paperclip agent to reply to the setup turn before completing setup", - { - code: "chat_test_round_trip_incomplete", - }, - ); - } - } - await options.setupTestActivationBarrier?.(); - const expectedGeneration = runtimeGeneration(endpoint.setup); - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - if (optionalSlackTest) { - const linked = await tx.select({ principalId: chatIdentityLinks.principalId }).from(chatIdentityLinks).where(and( - eq(chatIdentityLinks.endpointId, endpoint.id), eq(chatIdentityLinks.paperclipUserId, finishOptions!.optionalSlackTestForUser), eq(chatIdentityLinks.status, "linked"), - )).limit(1).then((rows) => rows[0]); - if (!linked || !(await lockCurrentPrincipalAuthorization(tx, endpoint, linked.principalId)).allowed) throw forbidden("Your connected account is no longer authorized"); - } - await notifyChatEndpointWork(tx); - const [activated] = await tx - .update(chatEndpoints) - .set({ - status: "active", - setup: { - ...endpoint.setup, - step: "complete", - testStartedAt: null, - testSkipped: optionalSlackTest, - }, - healthMessage: optionalSlackTest ? "Connected; conversation test optional" : "Connected", - activatedAt: endpoint.activatedAt ?? new Date(), - updatedAt: new Date(), - }) - .where( - and( - eq(chatEndpoints.id, endpointId), - eq(chatEndpoints.status, "verifying"), - sql`${chatEndpoints.setup}->>'step' = 'test'`, - sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${expectedGeneration}`, - sql`${chatEndpoints.setup}->>'testStartedAt' = ${testStartedAtValue}`, - ), - ) - .returning({ id: chatEndpoints.id }); - if (!activated) { - throw conflict("This setup test was superseded by a reconnect", { - code: "chat_endpoint_test_superseded", - }); - } - await tx - .update(toolConnections) - .set({ - status: "active", - enabled: true, - healthStatus: "healthy", - healthMessage: optionalSlackTest ? "Connected; conversation test optional" : "Connected", - lastError: null, - healthCheckedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(toolConnections.id, endpoint.connectionId)); - if (optionalSlackTest) await logActivity(tx as unknown as Db, { - companyId: endpoint.companyId, actorType: "user", actorId: finishOptions!.optionalSlackTestForUser, - action: "chat.setup_completed", entityType: "chat_endpoint", entityId: endpointId, - details: { conversationTestOptional: true }, - }); - await credentialLease.assertOwned(tx); - }); - return get(endpointId); - }, - ); - } - async function ensurePrincipal( endpoint: EndpointRow, author: Author, @@ -7795,595 +5356,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return resource; } - async function ingestAttachments(input: { - endpoint: EndpointRow; - endpointRuntime: ChatEndpointRuntime; - deliveryId: string; - issueId: string; - issueCommentId: string; - attachments: Attachment[]; - attachmentLimitOmissions?: number; - unavailableReferenceCount?: number; - actorUserId: string | null; - }): Promise<{ - storedIds: string[]; - omissionReasons: Record; - }> { - const omissionReasons: Record = {}; - const omit = (reason: string, count = 1) => { - omissionReasons[reason] = (omissionReasons[reason] ?? 0) + count; - }; - const boundedAttachments = input.attachments.slice(0, 20); - const photonDerivatives = new Map(); - const originalPhotonAttachments = new Map(); - if ( - input.endpoint.provider === "microsoft-teams" && - input.unavailableReferenceCount - ) { - // A channel/group reference is not a download capability. Still tell - // the exact current-turn consumer that those bytes are unavailable, - // including when there are no ingestible attachments at all. - omit("download_unavailable", input.unavailableReferenceCount); - } - if ( - ["github", "imessage-photon"].includes(input.endpoint.provider) && - input.attachmentLimitOmissions - ) { - omit("attachment_limit", input.attachmentLimitOmissions); - } - if (input.attachments.length > boundedAttachments.length) { - omit( - "attachment_limit", - input.attachments.length - boundedAttachments.length, - ); - } - if (!options.storage) { - if (boundedAttachments.length) - omit("storage_unavailable", boundedAttachments.length); - return { storedIds: [], omissionReasons }; - } - const existingByFingerprint = new Map(); - const existingAttachments = await db - .select({ - id: issueAttachments.id, - byteSize: assets.byteSize, - contentType: assets.contentType, - originalFilename: assets.originalFilename, - sha256: assets.sha256, - }) - .from(issueAttachments) - .innerJoin( - assets, - and( - eq(assets.companyId, issueAttachments.companyId), - eq(assets.id, issueAttachments.assetId), - ), - ) - .where( - and( - eq(issueAttachments.companyId, input.endpoint.companyId), - eq(issueAttachments.issueId, input.issueId), - eq(issueAttachments.issueCommentId, input.issueCommentId), - ), - ); - for (const existing of existingAttachments) { - const fingerprint = JSON.stringify([ - existing.sha256, - existing.byteSize, - existing.contentType, - existing.originalFilename, - ]); - const ids = existingByFingerprint.get(fingerprint) ?? []; - ids.push(existing.id); - existingByFingerprint.set(fingerprint, ids); - } - const storedIds: string[] = []; - const withCurrentAttachmentAuthorization = async ( - work: (tx: DbTransaction) => Promise, - enforceUnchangedSource = false, - telegramMedia?: Attachment, - ): Promise => { - for (let attempt = 0; ; attempt++) { - try { - return await db.transaction(async (tx) => { - const action = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, input.endpoint.companyId), - eq(chatActions.endpointId, input.endpoint.id), - eq(chatActions.deliveryId, input.deliveryId), - eq(chatActions.kind, "inbound_wakeup"), - ), - ) - .limit(1) - .then((rows) => rows[0]); - if ( - !action || - !["preparing", "issued", "processing", "processed"].includes( - action.status, - ) || - action.payload.issueId !== input.issueId || - action.payload.commentId !== input.issueCommentId - ) - throw forbidden( - "The attachment's current chat admission is unavailable", - { code: "chat_action_authorization_changed" }, - ); - const current = await authorizeInboundWakeup(tx, action); - // Delivery settlement/replay can update these receipts without - // taking the issue lock. Lock in delivery -> action order only - // after policy authorization; NOWAIT breaks cross-worker lock - // inversions and the outer loop retries the whole transaction. - const lockedDelivery = await tx - .select() - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.companyId, input.endpoint.companyId), - eq(chatDeliveries.id, input.deliveryId), - ), - ) - .for("update", { noWait: true }) - .limit(1) - .then((rows) => rows[0]); - const lockedAction = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, input.endpoint.companyId), - eq(chatActions.id, action.id), - ), - ) - .for("update", { noWait: true }) - .limit(1) - .then((rows) => rows[0]); - const scopeKeys = [ - "version", - "issueId", - "commentId", - "agentId", - "sessionGeneration", - "requestedByActorType", - "requestedByActorId", - ] as const; - if ( - current.delivery.state !== "processing" || - !lockedDelivery || - !lockedAction || - lockedDelivery.state !== "processing" || - lockedDelivery.endpointId !== action.endpointId || - lockedDelivery.conversationId !== action.conversationId || - lockedDelivery.principalId !== action.principalId || - lockedDelivery.attempts !== current.delivery.attempts || - JSON.stringify(lifecycleRuntimeFence(lockedDelivery)) !== - JSON.stringify(lifecycleRuntimeFence(current.delivery)) || - lockedAction.endpointId !== action.endpointId || - lockedAction.deliveryId !== action.deliveryId || - lockedAction.conversationId !== action.conversationId || - lockedAction.principalId !== action.principalId || - lockedAction.kind !== "inbound_wakeup" || - !["preparing", "issued", "processing", "processed"].includes( - lockedAction.status, - ) || - scopeKeys.some( - (key) => lockedAction.payload[key] !== action.payload[key], - ) - ) - throw forbidden( - "The attachment's current chat admission is unavailable", - { code: "chat_action_authorization_changed" }, - ); - if (enforceUnchangedSource) { - if (telegramMedia) { - const normalized = lockedDelivery.normalizedEvent as { - principal?: { externalId?: string }; - message?: { - providerMessageId?: string; - attachments?: Array<{ recovery?: unknown }>; - }; - }; - const fence = lifecycleRuntimeFence(lockedDelivery); - const descriptor = - input.endpointRuntime.attachmentRecoveryDescriptor( - telegramMedia, - { - threadId: current.conversation.externalThreadId, - messageId: normalized.message?.providerMessageId ?? "", - principalExternalId: normalized.principal?.externalId, - runtimeGeneration: fence?.generation, - credentialFingerprint: fence?.credentialFingerprint, - }, - ); - if ( - descriptor?.locator.kind !== "telegram_media" || - !normalized.message?.attachments?.some((item) => { - const retained = item.recovery as { - version?: unknown; - provider?: unknown; - locator?: Record; - attachment?: Record; - } | null; - return ( - retained?.version === 1 && - retained.provider === "telegram" && - Object.entries(descriptor.locator).every( - ([key, value]) => retained.locator?.[key] === value, - ) && - Object.entries(descriptor.attachment).every( - ([key, value]) => retained.attachment?.[key] === value, - ) - ); - }) - ) - throw forbidden( - "The media's admitted source is unavailable", - { code: "chat_action_authorization_changed" }, - ); - } - const [source] = await tx - .select({ id: issueComments.id }) - .from(issueComments) - .where( - and( - eq(issueComments.companyId, input.endpoint.companyId), - eq(issueComments.issueId, input.issueId), - eq(issueComments.id, input.issueCommentId), - isNull(issueComments.deletedAt), - sql`${issueComments.updatedAt} = ${issueComments.createdAt}`, - ), - ) - .for("share", { noWait: true }); - // Lifecycle admission precedes its conversation drain. Pending - // source changes already invalidate this image download, even - // when the append-only original comment is still untouched. - const [lifecycle] = await tx - .select({ id: chatDeliveries.id }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.companyId, input.endpoint.companyId), - eq(chatDeliveries.endpointId, input.endpoint.id), - or( - isNull(chatDeliveries.conversationId), - eq( - chatDeliveries.conversationId, - current.conversation.id, - ), - ), - ne(chatDeliveries.state, "filtered"), - inArray(chatDeliveries.eventKind, [ - "message_updated", - "message_deleted", - "message_restored", - ]), - sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(lifecycleRuntimeFence(lockedDelivery))}::jsonb`, - sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${lockedDelivery.providerEventId}`, - ), - ) - .limit(1); - if (!source || lifecycle) - throw forbidden( - "The image's admitted source changed before attachment registration", - { - code: "chat_action_authorization_changed", - }, - ); - } - return await work(tx); - }); - } catch (error) { - if (!isExternalChatWaitAuthorizationContention(error) || attempt >= 4) - throw error; - // Retry the complete short transaction; no network/storage work or - // backoff occurs while governance rows are locked. - await new Promise((resolve) => - setTimeout(resolve, 20 * (attempt + 1)), - ); - } - } - }; - // One shared deadline bounds the sequential batch; an expired batch never - // starts another GitHub request. Other providers retain their own policy. - const githubBatchSignal = - input.endpoint.provider === "github" - ? AbortSignal.timeout(GITHUB_ATTACHMENT_BATCH_TIMEOUT_MS) - : undefined; - // This new image-only lane shares one budget across token acquisition and - // downloads. DB/storage commit semantics remain explicit and unchanged. - const teamsInlineBatchSignal = - input.endpoint.provider === "microsoft-teams" - ? AbortSignal.timeout(10_000) - : undefined; - for (let attachment of boundedAttachments) { - const teamsInlineImage = - input.endpoint.provider === "microsoft-teams" && - input.endpointRuntime.attachmentRecoveryDescriptor(attachment)?.locator - .kind === "teams_inline_image"; - const telegramMedia = - input.endpoint.provider === "telegram" && - hasTelegramMediaProvenance(attachment); - const sourceBoundMedia = - teamsInlineImage || - telegramMedia || - input.endpoint.provider === "imessage-photon"; - const requireCurrentAttachmentAuthorization = - input.endpoint.provider === "github" || sourceBoundMedia; - try { - if (teamsInlineImage && teamsInlineBatchSignal?.aborted) { - omit("download_unavailable"); - continue; - } - // GitHub's anonymized upload URLs carry no trustworthy MIME metadata. - // Resolve public bytes only here, after durable comment admission, and - // then apply the same storage/type policy as every native attachment. - if (input.endpoint.provider === "github") { - await withCurrentAttachmentAuthorization(async () => {}); - attachment = await prepareGitHubPublicAttachment( - attachment, - githubBatchSignal, - async (request, signal) => { - await withCurrentAttachmentAuthorization(async () => {}); - return ( - (await input.endpointRuntime.resolveGitHubAttachmentComment?.( - request, - signal, - )) ?? null - ); - }, - ); - } - if ( - requireCurrentAttachmentAuthorization && - input.endpoint.provider !== "github" - ) - await withCurrentAttachmentAuthorization( - async () => {}, - sourceBoundMedia, - telegramMedia ? attachment : undefined, - ); - if ( - attachment.size !== undefined && - attachment.size > MAX_ATTACHMENT_BYTES - ) { - omit("declared_too_large"); - continue; - } - if (!attachment.fetchData) { - omit("download_unavailable"); - continue; - } - const originalFilename = sanitizeFilename(attachment.name); - let contentType = normalizeUploadAttachmentContentType({ - contentType: normalizeContentType( - attachment.mimeType ?? "application/octet-stream", - ), - originalFilename, - isAllowedContentType, - }); - // Reject a provider-declared type before allocating the downloaded - // payload. The actual byte length is checked again after the adapter's - // bounded fetch contract resolves. - // A filename may infer an Office MIME for ordinary documents, but it - // must not bypass byte identification for native media without MIME. - const identifyTelegram = - telegramMedia && - telegramMediaNeedsIdentification(attachment) && - normalizeContentType( - attachment.mimeType ?? "application/octet-stream", - ) === "application/octet-stream"; - if (!isAllowedContentType(contentType) && !identifyTelegram) { - omit("unsupported_type"); - continue; - } - const fetched = teamsInlineImage - ? await input.endpointRuntime.fetchTeamsInlineImage( - attachment, - teamsInlineBatchSignal!, - ) - : await attachment.fetchData(); - const body = Buffer.isBuffer(fetched) ? fetched : Buffer.from(fetched); - if (body.length === 0) { - omit("empty_download"); - continue; - } - if (body.length > MAX_ATTACHMENT_BYTES) { - omit("downloaded_too_large"); - continue; - } - if (identifyTelegram) { - contentType = - identifyTelegramMedia(attachment, body) ?? - "application/octet-stream"; - if (!isAllowedContentType(contentType)) { - omit("unsupported_type"); - continue; - } - } - const photonSourceHash = createHash("sha256") - .update(body) - .digest("hex"); - if (input.endpoint.provider === "imessage-photon") { - await validatePhotonImage(body, contentType); - const companion = takePhotonCompanion(body); - if (companion?.unavailable) omit("companion_unavailable"); - else if (companion) { - const video: Attachment = { type: "video", name: companion.fileName, mimeType: companion.mimeType, size: companion.data.length, fetchData: async () => companion.data }; - photonDerivatives.set(video, { source: attachment, sourceHash: photonSourceHash, kind: "live_photo_video" }); - boundedAttachments.push(video); - } - if ( - HEIF_CONTENT_TYPES.has(contentType) && - isAllowedContentType("image/jpeg") - ) { - try { - const preview = await photonHeifPreview(body); - const derivative: Attachment = { - type: "image", - name: `${originalFilename} (JPEG preview).jpg`, - mimeType: "image/jpeg", - size: preview.length, - fetchData: async () => preview, - }; - photonDerivatives.set(derivative, { source: attachment, sourceHash: photonSourceHash, kind: "heif_jpeg_preview" }); - boundedAttachments.push(derivative); - } catch { - omit("preview_unavailable"); - } - } - } - const fingerprint = JSON.stringify([ - photonSourceHash, - body.length, - contentType, - originalFilename, - ]); - const existingIds = existingByFingerprint.get(fingerprint); - const existingId = existingIds?.shift(); - if (requireCurrentAttachmentAuthorization) { - // Revoke during either API resolution or the anonymous byte download - // must prevent storage and attachment registration for that input. - await withCurrentAttachmentAuthorization( - async () => {}, - sourceBoundMedia, - telegramMedia ? attachment : undefined, - ); - } - const registerPhotonProvenance = async (attachmentId: string) => { - if (input.endpoint.provider !== "imessage-photon") return; - const relation = photonDerivatives.get(attachment); - if (!relation) { - originalPhotonAttachments.set(attachment, attachmentId); - return; - } - const originalAttachmentId = - originalPhotonAttachments.get(relation.source); - if (!originalAttachmentId) - throw new Error("Photon related attachment source is missing"); - await db - .insert(chatActions) - .values({ - companyId: input.endpoint.companyId, - endpointId: input.endpoint.id, - deliveryId: input.deliveryId, - kind: relation.kind === "heif_jpeg_preview" ? "attachment_derivative" : "attachment_companion", - providerActionId: `photon-${relation.kind}:${input.deliveryId}:${originalAttachmentId}`, - payload: { - originalAttachmentId, - derivativeAttachmentId: attachmentId, - originalSha256: relation.sourceHash, - derivativeSha256: photonSourceHash, - kind: relation.kind, - }, - status: "processed", - }) - .onConflictDoNothing(); - }; - if (existingId) { - storedIds.push(existingId); - await registerPhotonProvenance(existingId); - continue; - } - const stored = await options.storage.putFile({ - companyId: input.endpoint.companyId, - namespace: `issues/${input.issueId}`, - originalFilename, - contentType, - body, - }); - const registration = { - issueId: input.issueId, - issueCommentId: input.issueCommentId, - provider: stored.provider, - objectKey: stored.objectKey, - contentType: stored.contentType, - byteSize: stored.byteSize, - sha256: stored.sha256, - originalFilename: stored.originalFilename, - createdByUserId: input.actorUserId, - }; - let row; - try { - row = requireCurrentAttachmentAuthorization - ? await withCurrentAttachmentAuthorization( - (tx) => - issueService(tx as unknown as Db).createAttachment( - registration, - ), - sourceBoundMedia, - telegramMedia ? attachment : undefined, - ) - : await issuesSvc.createAttachment(registration); - } catch (error) { - // An explicit authorization denial happens before registration. Do - // not delete on ambiguous database errors which may have committed. - if ( - requireCurrentAttachmentAuthorization && - isExternalActionAuthorizationChange(error) - ) - await options.storage - .deleteObject(input.endpoint.companyId, stored.objectKey) - .catch(() => undefined); - throw error; - } - storedIds.push(row.id); - await registerPhotonProvenance(row.id); - } catch (error) { - if (isExternalActionAuthorizationChange(error)) throw error; - if ( - error instanceof PhotonError && - ["attachment_not_ready", "network", "quota", "credentials"].includes( - error.code, - ) - ) - throw error; - // Use the closed current-input omission vocabulary consumed by native - // prompts; provider-specific diagnostics remain redacted log codes. - omit( - error instanceof GitHubAttachmentUnavailableError - ? "download_unavailable" - : "processing_failed", - ); - // A malformed or unavailable provider attachment must not strand the - // durable text delivery. The rejected file is intentionally omitted; - // the delivery remains auditable through its normalized attachment - // metadata without persisting provider credentials or download URLs. - logger.warn( - { - endpointId: input.endpoint.id, - issueId: input.issueId, - ...(error instanceof GitHubAttachmentUnavailableError - ? { - deliveryId: input.deliveryId, - attachmentDiagnosticCode: error.code, - } - : {}), - attachmentName: sanitizeFilename(attachment.name), - error: redactError(error), - }, - "external chat attachment was rejected", - ); - } - } - return { storedIds, omissionReasons }; - } - - function attachmentOmissionDetail(result: { - omissionReasons: Record; - }): string | null { - const entries = Object.entries(result.omissionReasons).filter( - ([, count]) => count > 0, - ); - const omitted = entries.reduce((total, [, count]) => total + count, 0); - if (!omitted) return null; - const reasons = entries - .map(([reason, count]) => `${reason.replaceAll("_", " ")}: ${count}`) - .join(", "); - return `${omitted} external attachment${omitted === 1 ? " was" : "s were"} omitted (${reasons})`; - } - async function settleTelegramAttachmentOnlyFailure(input: { activeDelivery: DeliveryRow; attachmentResult: { @@ -8491,317 +5463,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return true; } - async function admitDiscordRootMention( - configuredEndpoint: EndpointRow, - event: DiscordRootMentionAdmissionEvent, - context: RuntimeContext, - ): Promise { - if ( - !(await ensureDiscordGatewayRuntimeIsCurrent( - configuredEndpoint.id, - context, - )) - ) { - return false; - } - return withCredentialMutationLease( - configuredEndpoint, - async (credentialLease) => { - const threadId = `discord:${event.guildId}:${event.channelId}:${event.messageId}`; - const providerEventId = `${threadId}:${event.messageId}`; - const allowed = await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const endpoint = await runtimeCallbackEndpoint( - tx, - configuredEndpoint.id, - context, - ["verifying", "active"], - ); - const existing = await tx - .select({ state: chatDeliveries.state }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.endpointId, configuredEndpoint.id), - eq(chatDeliveries.providerEventId, providerEventId), - ), - ) - .then((rows) => rows[0] ?? null); - if ( - existing && - ["processed", "filtered", "failed"].includes(existing.state) - ) - return false; - - let reason: string | null = null; - if (!endpoint || endpoint.providerAccountId !== event.guildId) { - reason = "Connection is not active"; - } - const resource = endpoint - ? await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.type, "channel"), - eq( - chatEndpointResources.providerResourceId, - event.channelId, - ), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const enabledCount = - endpoint?.status === "verifying" - ? await tx - .select({ count: sql`count(*)::int` }) - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.type, "channel"), - eq(chatEndpointResources.enabled, true), - eq(chatEndpointResources.availability, "available"), - ), - ) - .then((rows) => rows[0]?.count ?? 0) - : 0; - const setupDestination = - endpoint?.status === "verifying" && - enabledCount === 0 && - resource?.availability === "available"; - if ( - !reason && - !( - endpoint?.allowGroupChats && - resource?.availability === "available" && - (resource.enabled || setupDestination) - ) - ) { - reason = "Destination is not enabled in Paperclip"; - } - - if (!reason && endpoint) { - const principal = await tx - .select({ id: chatExternalPrincipals.id }) - .from(chatExternalPrincipals) - .where( - and( - eq(chatExternalPrincipals.companyId, endpoint.companyId), - eq(chatExternalPrincipals.provider, "discord"), - eq(chatExternalPrincipals.providerAccountId, event.guildId), - eq(chatExternalPrincipals.externalId, event.userId), - ), - ) - .then((rows) => rows[0] ?? null); - let allowed: boolean; - let linkedDenied = false; - if (principal) { - const authorization = await lockCurrentPrincipalAuthorization( - tx, - endpoint, - principal.id, - ); - allowed = authorization.allowed; - linkedDenied = authorization.linkedDenied; - } else if (!endpoint.allowUnlinkedPeople) { - allowed = false; - } else if (!endpoint.sponsorUserId) { - allowed = true; - } else { - const sponsor = await tx - .select({ - status: companyMemberships.status, - membershipRole: companyMemberships.membershipRole, - }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, endpoint.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, endpoint.sponsorUserId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - allowed = - sponsor?.status === "active" && - sponsor.membershipRole !== "viewer"; - } - if (!allowed) { - reason = linkedDenied - ? "Linked Paperclip account is not currently permitted" - : endpoint.allowUnlinkedPeople - ? "Endpoint sponsor can no longer authorize external guests" - : "External identity must be linked to a Paperclip account"; - } - } - if (!reason) { - await credentialLease.assertOwned(tx); - return true; - } - - const filteredAt = new Date(); - const normalizedEvent = { - providerEventId, - kind: "mention", - trigger: "mention", - resource: { - type: "channel", - providerResourceId: event.channelId, - }, - conversation: { externalThreadId: threadId }, - message: { providerMessageId: event.messageId }, - filtering: { contentRetained: false, providerThreadCreated: false }, - }; - await notifyChatDeliveryWork(tx); - await tx - .insert(chatDeliveries) - .values({ - companyId: configuredEndpoint.companyId, - endpointId: configuredEndpoint.id, - providerEventId, - deduplicationKey: createHash("sha256") - .update(providerEventId) - .digest("hex"), - eventKind: "mention", - normalizedEvent, - state: "filtered", - redactedError: reason, - processedAt: filteredAt, - }) - .onConflictDoUpdate({ - target: [ - chatDeliveries.endpointId, - chatDeliveries.providerEventId, - ], - set: { - normalizedEvent, - principalId: null, - state: "filtered", - nextAttemptAt: null, - redactedError: reason, - processedAt: filteredAt, - updatedAt: filteredAt, - }, - }); - await tx - .update(chatEndpoints) - .set({ lastEventAt: filteredAt, updatedAt: filteredAt }) - .where(eq(chatEndpoints.id, configuredEndpoint.id)); - await credentialLease.assertOwned(tx); - return false; - }); - if (!allowed || !event.message || !context.endpointRuntime) { - return false; - } - const thread = context.endpointRuntime.thread(event.threadId); - await processMessage( - configuredEndpoint, - thread, - event.message, - "mention", - true, - null, - context, - undefined, - null, - true, - undefined, - true, - ); - const staged = await db - .select({ state: chatDeliveries.state }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.endpointId, configuredEndpoint.id), - eq(chatDeliveries.providerEventId, providerEventId), - ), - ) - .then((rows) => rows[0] ?? null); - if ( - !staged || - !["received", "retry", "processing"].includes(staged.state) - ) { - return false; - } - - await options.discordRootThreadTransportBarrier?.(); - await credentialLease.assertOwned(); - if ( - !(await ensureDiscordGatewayRuntimeIsCurrent( - configuredEndpoint.id, - context, - )) - ) { - return false; - } - try { - await context.endpointRuntime.ensureDiscordRootThread({ - channelId: event.channelId, - content: event.message.text, - messageId: event.messageId, - }); - } catch (error) { - // The durable provisional receipt owns recovery. Do not let a - // transient REST failure escape the Gateway event handler or ask the - // adapter to create the same thread outside Paperclip's lifecycle - // fence. - logger.warn( - { - endpointId: configuredEndpoint.id, - error: redactError(error), - providerEventId, - }, - "Discord root thread creation will be retried from the durable receipt", - ); - return false; - } - const promoted = await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const current = await runtimeCallbackEndpoint( - tx, - configuredEndpoint.id, - context, - ["verifying", "active"], - ); - if (!current) return false; - await notifyChatDeliveryWork(tx); - const [row] = await tx - .update(chatDeliveries) - .set({ - state: "received", - normalizedEvent: sql`${chatDeliveries.normalizedEvent} - 'providerThreadPending'`, - nextAttemptAt: null, - redactedError: null, - updatedAt: new Date(), - }) - .where( - and( - eq(chatDeliveries.endpointId, configuredEndpoint.id), - eq(chatDeliveries.providerEventId, providerEventId), - eq(chatDeliveries.state, "retry"), - sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`, - ), - ) - .returning({ id: chatDeliveries.id }); - await credentialLease.assertOwned(tx); - return Boolean(row); - }); - if (promoted) - scheduleConversationDrain(configuredEndpoint.id, threadId); - // Paperclip created the provider thread inside its credential/reach - // lease. Returning false prevents the adapter from creating it again; - // the durable drain owns the remaining task mutation. - return false; - }, - ); - } - async function stageInboundWakeup( tx: DbOrTransaction, input: { @@ -12161,197 +8822,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ); } - function discordRootThreadPending( - delivery: Pick, - ): boolean { - return ( - Boolean(delivery.normalizedEvent) && - typeof delivery.normalizedEvent === "object" && - !Array.isArray(delivery.normalizedEvent) && - (delivery.normalizedEvent as { providerThreadPending?: unknown }) - .providerThreadPending === true - ); - } - - async function reconcileDiscordRootThread( - endpoint: EndpointRow, - delivery: DeliveryRow, - threadId: string, - ): Promise { - try { - const normalized = delivery.normalizedEvent as { - message?: { providerMessageId?: unknown; text?: unknown }; - resource?: { providerResourceId?: unknown }; - }; - const discordThreadParts = threadId.split(":"); - const channelId = - discordThreadParts[0] === "discord" && discordThreadParts.length >= 4 - ? discordThreadParts[2] - : normalized.resource?.providerResourceId; - const messageId = normalized.message?.providerMessageId; - if (typeof channelId !== "string" || typeof messageId !== "string") { - throw new DiscordAdapterCompatibilityError( - "the provisional root receipt is missing its channel or message id", - ); - } - const runtimeFence = lifecycleRuntimeFence(delivery); - if (!runtimeFence) { - throw new DiscordAdapterCompatibilityError( - "the provisional root receipt is missing its runtime fence", - ); - } - return await withCredentialMutationLease( - endpoint, - async (credentialLease) => { - const currentEndpoint = await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const current = await runtimeCallbackEndpoint( - tx, - endpoint.id, - runtimeFence, - ["verifying", "active"], - ); - await credentialLease.assertOwned(tx); - return current; - }); - if (!currentEndpoint) { - throw conflict( - "Discord root thread credentials or connection state changed before recovery", - { code: "chat_endpoint_runtime_superseded" }, - ); - } - const endpointRuntime = await runtimeFor(currentEndpoint); - await credentialLease.assertOwned(); - await endpointRuntime.ensureDiscordRootThread({ - channelId, - messageId, - content: - typeof normalized.message?.text === "string" - ? normalized.message.text - : "Paperclip task", - }); - return await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const current = await runtimeCallbackEndpoint( - tx, - endpoint.id, - runtimeFence, - ["verifying", "active"], - ); - if (!current) return false; - await notifyChatDeliveryWork(tx); - const [promoted] = await tx - .update(chatDeliveries) - .set({ - state: "received", - normalizedEvent: sql`${chatDeliveries.normalizedEvent} - 'providerThreadPending'`, - nextAttemptAt: null, - redactedError: null, - updatedAt: new Date(), - }) - .where( - and( - eq(chatDeliveries.id, delivery.id), - eq(chatDeliveries.state, "retry"), - sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`, - ), - ) - .returning({ id: chatDeliveries.id }); - await credentialLease.assertOwned(tx); - return Boolean(promoted); - }); - }, - ); - } catch (error) { - const disposition = classifyChatPublicationError( - error, - delivery.attempts + 1, - ); - const terminal = - disposition.kind === "resource_unavailable" || - disposition.kind === "failed"; - const retryAfterMs = - disposition.kind === "retry" - ? disposition.retryAfterMs - : DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS; - const now = new Date(); - await db.transaction(async (tx) => { - if (disposition.kind === "resource_unavailable") { - const fence = lifecycleRuntimeFence(delivery); - const normalized = delivery.normalizedEvent as { - resource?: { providerResourceId?: unknown }; - }; - const providerResourceId = normalized.resource?.providerResourceId; - const current = fence - ? await runtimeCallbackEndpoint(tx, endpoint.id, fence, [ - "verifying", - "active", - "attention", - ]) - : null; - const resource = - current && typeof providerResourceId === "string" - ? await tx - .select({ id: chatEndpointResources.id }) - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.companyId, current.companyId), - eq(chatEndpointResources.endpointId, current.id), - eq( - chatEndpointResources.providerResourceId, - providerResourceId, - ), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - if (resource) { - await tx - .update(chatEndpointResources) - .set({ availability: "unavailable", updatedAt: now }) - .where(eq(chatEndpointResources.id, resource.id)); - await tx - .update(chatConversations) - .set({ state: "unavailable", updatedAt: now }) - .where( - and( - eq(chatConversations.companyId, current!.companyId), - eq(chatConversations.endpointId, current!.id), - eq(chatConversations.resourceId, resource.id), - inArray(chatConversations.state, ["active", "waiting"]), - ), - ); - } - } - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: terminal ? "filtered" : "retry", - attempts: delivery.attempts + 1, - nextAttemptAt: terminal - ? null - : new Date(now.getTime() + retryAfterMs), - processedAt: terminal ? now : null, - redactedError: terminal - ? "Discord thread creation was not confirmed" - : redactError(error), - updatedAt: now, - }) - .where( - and( - eq(chatDeliveries.id, delivery.id), - eq(chatDeliveries.state, "retry"), - sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`, - ), - ); - }); - return false; - } - } - function normalizedDeliveryThreadId(delivery: Pick): string | null { const normalized = delivery.normalizedEvent as { conversation?: { externalThreadId?: unknown }; @@ -14163,479 +10633,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ); } - function reactionConversationThreadCondition( - provider: ChatProvider, - threadId: string, - ): SQL | undefined { - const exact = externalThreadIdentityCondition( - sql`${chatConversations.externalThreadId}`, - threadId, - ); - const slackDmChannel = - provider === "slack" ? slackThreadChannelId(threadId) : null; - return slackDmChannel && /^D[A-Z0-9-]*$/i.test(slackDmChannel) - ? or( - exact, - and( - eq(chatConversations.isDirectMessage, true), - eq(chatConversations.externalThreadId, `slack:${slackDmChannel}:`), - ), - ) - : exact; - } - - async function reactionConversationForMessage( - database: DbOrTransaction, - endpoint: EndpointRow, - threadId: string, - providerMessageId: string, - direction?: "outbound", - ): Promise { - return database - .select({ conversation: chatConversations }) - .from(chatMessageLinks) - .innerJoin( - chatConversations, - and( - eq(chatConversations.companyId, chatMessageLinks.companyId), - eq(chatConversations.endpointId, chatMessageLinks.endpointId), - eq(chatConversations.id, chatMessageLinks.conversationId), - ), - ) - .where( - and( - eq(chatMessageLinks.companyId, endpoint.companyId), - eq(chatMessageLinks.endpointId, endpoint.id), - eq(chatMessageLinks.providerMessageId, providerMessageId), - direction ? eq(chatMessageLinks.direction, direction) : undefined, - reactionConversationThreadCondition(endpoint.provider, threadId), - ), - ) - .orderBy(desc(chatConversations.sessionGeneration)) - .limit(1) - .then((rows) => rows[0]?.conversation ?? null); - } - - async function lockReactionConversationForMessage( - tx: DbTransaction, - endpoint: EndpointRow, - threadId: string, - providerMessageId: string, - direction?: "outbound", - ): Promise { - const candidate = await reactionConversationForMessage( - tx, - endpoint, - threadId, - providerMessageId, - direction, - ); - if (!candidate) return null; - const current = await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, candidate.id), - ), - ) - // Publication settlement inserts the outbound message link before it - // updates the endpoint. NO KEY UPDATE serializes state changes while - // remaining compatible with that link's conversation FK KEY SHARE. - .for("no key update") - .then((rows) => rows[0] ?? null); - if ( - !current || - !(current.isDirectMessage - ? ["active", "waiting", "completed"].includes(current.state) - : ["active", "waiting"].includes(current.state)) - ) { - return null; - } - const linkStillExists = await tx - .select({ id: chatMessageLinks.id }) - .from(chatMessageLinks) - .where( - and( - eq(chatMessageLinks.companyId, endpoint.companyId), - eq(chatMessageLinks.endpointId, endpoint.id), - eq(chatMessageLinks.conversationId, current.id), - eq(chatMessageLinks.providerMessageId, providerMessageId), - direction ? eq(chatMessageLinks.direction, direction) : undefined, - ), - ) - .limit(1) - .then((rows) => rows.length > 0); - return linkStillExists ? current : null; - } - - async function streamingReactionConversation( - database: DbOrTransaction, - endpoint: EndpointRow, - threadId: string, - ): Promise { - const candidates = await database - .selectDistinct({ - conversation: chatConversations, - sessionGeneration: chatConversations.sessionGeneration, - }) - .from(chatConversations) - .innerJoin( - chatPublications, - and( - eq(chatPublications.companyId, chatConversations.companyId), - eq(chatPublications.endpointId, chatConversations.endpointId), - eq(chatPublications.conversationId, chatConversations.id), - ), - ) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - or( - inArray(chatConversations.state, ["active", "waiting"]), - and( - eq(chatConversations.isDirectMessage, true), - eq(chatConversations.state, "completed"), - ), - ), - reactionConversationThreadCondition(endpoint.provider, threadId), - eq(chatPublications.state, "streaming"), - ), - ) - .orderBy(desc(chatConversations.sessionGeneration)) - .limit(2); - if (candidates.length !== 1) return null; - return candidates[0]!.conversation; - } - - async function lockStreamingReactionConversation( - tx: DbTransaction, - endpoint: EndpointRow, - threadId: string, - ): Promise { - const candidate = await streamingReactionConversation( - tx, - endpoint, - threadId, - ); - if (!candidate) return null; - const current = await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, candidate.id), - or( - inArray(chatConversations.state, ["active", "waiting"]), - and( - eq(chatConversations.isDirectMessage, true), - eq(chatConversations.state, "completed"), - ), - ), - ), - ) - // Keep the same lock strength/order as the exact-link path above so an - // in-flight message-link insert cannot deadlock reaction admission. - .for("no key update") - .then((rows) => rows[0] ?? null); - if (!current) return null; - const stillStreaming = await tx - .select({ id: chatPublications.id }) - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, endpoint.companyId), - eq(chatPublications.endpointId, endpoint.id), - eq(chatPublications.conversationId, current.id), - eq(chatPublications.state, "streaming"), - ), - ) - .limit(1) - .then((rows) => rows.length > 0); - return stillStreaming ? current : null; - } - - async function authorizeReactionConversation( - tx: DbTransaction, - endpoint: EndpointRow, - conversation: ConversationRow, - principalId: string, - ): Promise { - const resource = - conversation.resourceId && !conversation.isDirectMessage - ? await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.id, conversation.resourceId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const destinationAllowed = conversation.isDirectMessage - ? endpoint.allowDirectMessages - : nonDirectDestinationAllowed(endpoint, resource); - if (!destinationAllowed) return false; - return (await lockCurrentPrincipalAuthorization(tx, endpoint, principalId)) - .allowed; - } - - async function handleReaction( - event: ChatSdkCallbackEvent, - runtimeContext: RuntimeContext, - ) { - const record = await runtimeCallbackRecord( - event.endpointId, - runtimeContext, - ["active"], - ); - if ( - !record || - record.endpoint.status !== "active" || - record.endpoint.provider !== event.provider || - record.endpoint.capabilities.reactions !== true || - event.event.user.isMe || - event.event.user.isBot === true || - event.event.user.isSystem - ) { - return; - } - // A DM can have a newer task generation while this reaction still belongs - // to an older linked message. Resolve its exact lineage before choosing a - // generation; never attach it to the latest task merely sharing the chat. - // A just-sent provider message can be visible before its publication/link - // transaction commits. Only that exact thread's streaming publication is - // enough evidence to stage an otherwise-unbound reaction for replay. - let conversation = await reactionConversationForMessage( - db, - record.endpoint, - event.event.threadId, - event.event.messageId, - ); - await options.reactionLinkPreflightBarrier?.(); - const plausibleStreamingConversation = conversation - ? null - : await streamingReactionConversation( - db, - record.endpoint, - event.event.threadId, - ); - if (!conversation && !plausibleStreamingConversation) { - // Settlement can commit between the first exact-link lookup and the - // streaming-publication lookup. Recheck the durable link before treating - // the callback as an arbitrary unknown message. - conversation = await reactionConversationForMessage( - db, - record.endpoint, - event.event.threadId, - event.event.messageId, - ); - if (!conversation) return; - } - - const principal = await ensurePrincipal( - record.endpoint, - event.event.user, - event.event.raw, - ); - let rawFingerprint: string; - try { - rawFingerprint = createHash("sha256") - .update(JSON.stringify(event.event.raw ?? null)) - .digest("hex") - .slice(0, 24); - } catch { - rawFingerprint = "unavailable"; - } - const eventKind: ChatEventKind = event.event.added - ? "reaction_added" - : "reaction_removed"; - const providerEventId = [ - eventKind, - durableExternalThreadIdentity(event.event.threadId), - event.event.messageId, - stableExternalPrincipalId( - record.endpoint.provider, - event.event.user, - event.event.raw, - ), - event.event.rawEmoji, - rawFingerprint, - ].join(":"); - const normalizedEvent = { - providerEventId, - kind: eventKind, - conversation: { externalThreadId: event.event.threadId }, - message: { providerMessageId: event.event.messageId }, - reaction: { - emoji: event.event.emoji.name, - rawEmoji: event.event.rawEmoji, - added: event.event.added, - }, - runtimeContext: { - generation: runtimeContext.generation, - credentialFingerprint: runtimeContext.credentialFingerprint, - }, - }; - const admitted = await db.transaction(async (tx) => { - const currentEndpoint = await runtimeCallbackEndpoint( - tx, - event.endpointId, - runtimeContext, - ["active"], - ); - if (!currentEndpoint) return false; - const linkedConversation = await lockReactionConversationForMessage( - tx, - currentEndpoint, - event.event.threadId, - event.event.messageId, - ); - const currentConversation = - linkedConversation ?? - (await lockStreamingReactionConversation( - tx, - currentEndpoint, - event.event.threadId, - )); - if ( - !currentConversation || - !(await authorizeReactionConversation( - tx, - currentEndpoint, - currentConversation, - principal.principal.id, - )) - ) { - return false; - } - const now = new Date(); - if (linkedConversation) { - const expiryCutoff = new Date(now.getTime() - REACTION_LINK_MAX_AGE_MS); - const hasOutboundLink = await tx - .select({ id: chatMessageLinks.id }) - .from(chatMessageLinks) - .where( - and( - eq(chatMessageLinks.companyId, currentEndpoint.companyId), - eq(chatMessageLinks.endpointId, currentEndpoint.id), - eq(chatMessageLinks.conversationId, linkedConversation.id), - eq(chatMessageLinks.providerMessageId, event.event.messageId), - eq(chatMessageLinks.direction, "outbound"), - ), - ) - .limit(1) - .then((rows) => rows.length > 0); - await notifyChatDeliveryWork(tx); - const reconciled = hasOutboundLink - ? await tx - .update(chatDeliveries) - .set({ - conversationId: linkedConversation.id, - state: "processed", - attempts: sql`${chatDeliveries.attempts} + 1`, - nextAttemptAt: null, - processedAt: now, - redactedError: null, - updatedAt: now, - }) - .where( - and( - eq(chatDeliveries.endpointId, currentEndpoint.id), - eq(chatDeliveries.providerEventId, providerEventId), - inArray(chatDeliveries.state, ["received", "retry"]), - gt(chatDeliveries.receivedAt, expiryCutoff), - lt(chatDeliveries.attempts, REACTION_LINK_MAX_ATTEMPTS), - sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'generation' = ${String(runtimeContext.generation)}`, - sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'credentialFingerprint' = ${runtimeContext.credentialFingerprint}`, - ), - ) - .returning({ id: chatDeliveries.id }) - : []; - if (reconciled.length > 0) return true; - await notifyChatDeliveryWork(tx); - const expired = await tx - .update(chatDeliveries) - .set({ - state: "filtered", - nextAttemptAt: null, - processedAt: now, - redactedError: - "Reaction target did not become linked before replay expiry", - updatedAt: now, - }) - .where( - and( - eq(chatDeliveries.endpointId, currentEndpoint.id), - eq(chatDeliveries.providerEventId, providerEventId), - inArray(chatDeliveries.state, ["received", "retry"]), - or( - lte(chatDeliveries.receivedAt, expiryCutoff), - gte(chatDeliveries.attempts, REACTION_LINK_MAX_ATTEMPTS), - ), - sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'generation' = ${String(runtimeContext.generation)}`, - sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'credentialFingerprint' = ${runtimeContext.credentialFingerprint}`, - ), - ) - .returning({ id: chatDeliveries.id }); - if (expired.length > 0) return false; - } - await notifyChatDeliveryWork(tx); - const inserted = await tx - .insert(chatDeliveries) - .values({ - companyId: currentEndpoint.companyId, - endpointId: currentEndpoint.id, - conversationId: linkedConversation?.id ?? null, - principalId: principal.principal.id, - providerEventId, - deduplicationKey: createHash("sha256") - .update(providerEventId) - .digest("hex"), - eventKind, - normalizedEvent, - state: linkedConversation ? "processed" : "received", - attempts: linkedConversation ? 1 : 0, - nextAttemptAt: linkedConversation - ? null - : new Date(now.getTime() + REACTION_LINK_RETRY_DELAY_MS), - processedAt: linkedConversation ? now : null, - }) - .onConflictDoNothing() - .returning({ id: chatDeliveries.id }); - if (inserted.length > 0) return true; - return tx - .select({ id: chatDeliveries.id }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.endpointId, currentEndpoint.id), - eq(chatDeliveries.providerEventId, providerEventId), - inArray(chatDeliveries.state, ["received", "retry", "processed"]), - ), - ) - .limit(1) - .then((rows) => rows.length > 0); - }); - if (admitted) { - await recordCurrentMicrosoftTeamsRoute( - record.endpoint, - runtimeContext, - event.event.threadId, - event.event.raw, - ); - } - } - async function denyExternalAction( endpoint: EndpointRow, event: ChatSdkCallbackEvent, @@ -18629,56 +14626,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } } - async function verifySlackRequestUrl(endpoint: EndpointRow, request: Request): Promise { - const callback = await inspectSlackCallback(request.clone()); - if (!callback?.isUrlVerification) return null; - const body = await request.clone().text(); - let notifyAccount = false; - // URL verification needs only the signing secret. Do not initialize the - // SDK (which calls Slack auth.test), wait for OAuth, or acquire the creation - // lease: Slack can deliver this check while app setup still owns that lease. - const response = await db.transaction(async tx => { - const [current] = await tx.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpoint.id)).for("no key update"); - if (!current || ["archived", "paused", "revoked"].includes(current.status)) return new Response("ignored", { status: 200 }); - const [connection] = await tx.select({ status: toolConnections.status, refs: toolConnections.credentialSecretRefs }).from(toolConnections) - .where(and(eq(toolConnections.id, current.connectionId), eq(toolConnections.companyId, current.companyId))); - if (!connection || connection.status === "archived") return new Response("ignored", { status: 200 }); - let secret = (await resolveCredentialRefs(current, connection.refs.filter(ref => ref.configPath.replace(/^credentials\./, "") === "signingSecret"), tx)).signingSecret; - if (!secret && isProvisionedSlackSetup(current.setup.slackSetupMethod)) { - const [registration] = await tx.select().from(chatSlackRegistrations) - .where(and(eq(chatSlackRegistrations.endpointId, current.id), eq(chatSlackRegistrations.companyId, current.companyId))); - if (registration?.status !== "removed" && registration?.secretIds.signingSecret) { - secret = await secretService(tx as unknown as Db).resolveSecretValue(current.companyId, registration.secretIds.signingSecret, "latest", { - accessContext: { consumerType: "system", consumerId: `slack-registration:${current.id}`, configPath: "slack_registration.signingSecret", actorType: "system" }, - }); - } - } - if (!secret) return new Response("Slack signing secret is not ready yet", { status: 503 }); - if (!slackRequestSignatureIsValid(request, body, secret)) return new Response("Invalid signature", { status: 401 }); - const workspaceId = slackRequestWorkspaceId(body, request.headers.get("content-type") ?? ""); - if (workspaceId && current.providerAccountId && workspaceId !== current.providerAccountId) return new Response("ignored", { status: 200 }); - const observedAt = new Date(); - const setup = current.setup as InternalSetupState; - const matchesCurrentUrl = slackCallbackMatchesPublicUrl(callback.url, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${current.publicId}/slack`); - await notifyChatVerificationWork(tx); - await tx.update(chatEndpoints).set({ setup: { - ...setup, - ...(matchesCurrentUrl ? { webhookVerifiedAt: setup.webhookVerifiedAt ?? observedAt.toISOString(), - slackVerificationSigningFingerprint: createHash("sha256").update(secret).digest("hex") } : {}), - slackCallbackSurfaces: { ...setup.slackCallbackSurfaces, events: { url: callback.url, observedAt: observedAt.toISOString() } }, - ...(matchesCurrentUrl && setup.slackAccount?.status === "linked" && !setup.slackAccount.verificationStatus - ? { slackAccount: { ...setup.slackAccount, verificationStatus: "pending" as const } } : {}), - } as InternalSetupState, ...(matchesCurrentUrl ? { healthMessage: "Slack Events Request URL verified" } : {}), updatedAt: observedAt }).where(eq(chatEndpoints.id, current.id)); - if (matchesCurrentUrl && !setup.webhookVerifiedAt) await logActivity(tx as unknown as Db, { companyId: current.companyId, actorType: "system", actorId: "slack-webhook", - action: "chat_endpoint.webhook_verified", entityType: "tool_connection", entityId: current.connectionId, - details: { endpointId: current.id, provider: "slack" } }); - notifyAccount = matchesCurrentUrl && setup.slackAccount?.status === "linked" && !setup.slackAccount.verificationStatus; - return Response.json({ challenge: (JSON.parse(body) as { challenge: string }).challenge }); - }); - if (notifyAccount) scheduleMessageProcessing(() => slackRegistration.notifyVerified(endpoint.id)); - return response; - } - async function handleWebhook( publicId: string, provider: ChatSdkProvider, @@ -19365,334 +15312,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return response; } - function normalizedPendingReaction(delivery: DeliveryRow): { - added: boolean; - emoji: string; - messageId: string; - rawEmoji: string; - runtimeContext: LifecycleRuntimeFence; - threadId: string; - } | null { - if ( - delivery.eventKind !== "reaction_added" && - delivery.eventKind !== "reaction_removed" - ) { - return null; - } - const normalized = delivery.normalizedEvent; - if ( - !normalized || - typeof normalized !== "object" || - Array.isArray(normalized) - ) { - return null; - } - const value = normalized as Record; - const conversation = value.conversation; - const message = value.message; - const reaction = value.reaction; - const runtimeContext = lifecycleRuntimeFence(delivery); - if ( - !conversation || - typeof conversation !== "object" || - Array.isArray(conversation) || - !message || - typeof message !== "object" || - Array.isArray(message) || - !reaction || - typeof reaction !== "object" || - Array.isArray(reaction) || - !runtimeContext - ) { - return null; - } - const threadId = (conversation as Record).externalThreadId; - const messageId = (message as Record).providerMessageId; - const reactionRecord = reaction as Record; - const added = reactionRecord.added; - const emoji = reactionRecord.emoji; - const rawEmoji = reactionRecord.rawEmoji; - return typeof threadId === "string" && - threadId.length > 0 && - typeof messageId === "string" && - messageId.length > 0 && - typeof added === "boolean" && - added === (delivery.eventKind === "reaction_added") && - typeof emoji === "string" && - emoji.length > 0 && - typeof rawEmoji === "string" && - rawEmoji.length > 0 - ? { added, emoji, messageId, rawEmoji, runtimeContext, threadId } - : null; - } - - async function processPendingReactionDelivery( - candidate: DeliveryRow, - ): Promise { - const reaction = normalizedPendingReaction(candidate); - if (!reaction || !candidate.principalId) { - const now = new Date(); - await db.transaction(async (tx) => { - await notifyChatDeliveryWork(tx); - return tx - .update(chatDeliveries) - .set({ - state: "failed", - nextAttemptAt: null, - processedAt: now, - redactedError: "Normalized reaction delivery is incomplete", - updatedAt: now, - }) - .where( - and( - eq(chatDeliveries.id, candidate.id), - inArray(chatDeliveries.state, ["received", "retry"]), - ), - ); - }); - return; - } - const principalId = candidate.principalId; - - await db.transaction(async (tx) => { - const currentEndpoint = await runtimeCallbackEndpoint( - tx, - candidate.endpointId, - reaction.runtimeContext, - ["active"], - ); - let conversation: ConversationRow | null = null; - let disposition: - | { kind: "processed"; conversationId: string } - | { kind: "retry" } - | { kind: "filtered"; reason: string }; - - if (!currentEndpoint) { - disposition = { - kind: "filtered", - reason: "Reaction callback belonged to an unavailable runtime", - }; - } else { - await options.reactionReplayEndpointLockBarrier?.(); - conversation = await lockReactionConversationForMessage( - tx, - currentEndpoint, - reaction.threadId, - reaction.messageId, - "outbound", - ); - if (conversation) { - await options.reactionReplayConversationLockBarrier?.(); - } - if (conversation) { - disposition = (await authorizeReactionConversation( - tx, - currentEndpoint, - conversation, - principalId, - )) - ? { kind: "processed", conversationId: conversation.id } - : { - kind: "filtered", - reason: - "Reaction destination or principal is no longer authorized", - }; - } else { - conversation = await lockStreamingReactionConversation( - tx, - currentEndpoint, - reaction.threadId, - ); - if (conversation) { - await options.reactionReplayConversationLockBarrier?.(); - } - if (!conversation) { - disposition = { - kind: "filtered", - reason: "Reaction target was not linked to a sent message", - }; - } else { - disposition = (await authorizeReactionConversation( - tx, - currentEndpoint, - conversation, - principalId, - )) - ? { kind: "retry" } - : { - kind: "filtered", - reason: - "Reaction destination or principal is no longer authorized", - }; - } - } - } - - // Endpoint, conversation, destination and identity locks are acquired - // before the delivery row so pause/revoke and replay use one lock order. - const locked = await tx - .select() - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.id, candidate.id), - inArray(chatDeliveries.state, ["received", "retry"]), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - // Authorization locks may wait behind a concurrent lifecycle mutation. - // Sample the expiry clock only after this delivery is exclusively held. - const decisionAt = new Date(); - if (!locked || !deliveryReady(locked, decisionAt)) return; - const lockedReaction = normalizedPendingReaction(locked); - if ( - !lockedReaction || - lockedReaction.runtimeContext.generation !== - reaction.runtimeContext.generation || - lockedReaction.runtimeContext.credentialFingerprint !== - reaction.runtimeContext.credentialFingerprint - ) { - disposition = { - kind: "filtered", - reason: "Reaction delivery identity changed before replay", - }; - } - - const nextAttempts = locked.attempts + 1; - const expired = - decisionAt.getTime() - locked.receivedAt.getTime() >= - REACTION_LINK_MAX_AGE_MS; - if (expired || locked.attempts >= REACTION_LINK_MAX_ATTEMPTS) { - disposition = { - kind: "filtered", - reason: "Reaction target did not become linked before replay expiry", - }; - } - if (disposition.kind === "retry" && !expired) { - if (nextAttempts < REACTION_LINK_MAX_ATTEMPTS) { - const delay = Math.min( - REACTION_LINK_RETRY_DELAY_MS * - 2 ** Math.min(Math.max(locked.attempts, 0), 5), - REACTION_LINK_MAX_DELAY_MS, - ); - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: "retry", - attempts: nextAttempts, - nextAttemptAt: new Date(decisionAt.getTime() + delay), - redactedError: "Waiting for the sent message link", - updatedAt: decisionAt, - }) - .where( - and( - eq(chatDeliveries.id, locked.id), - eq(chatDeliveries.state, locked.state), - eq(chatDeliveries.attempts, locked.attempts), - ), - ); - return; - } - disposition = { - kind: "filtered", - reason: "Reaction target did not become linked before retry expiry", - }; - } else if (disposition.kind === "retry") { - disposition = { - kind: "filtered", - reason: "Reaction target did not become linked before age expiry", - }; - } - - const processedAt = new Date(); - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set( - disposition.kind === "processed" - ? { - conversationId: disposition.conversationId, - state: "processed", - attempts: nextAttempts, - nextAttemptAt: null, - processedAt, - redactedError: null, - updatedAt: processedAt, - } - : { - state: "filtered", - attempts: nextAttempts, - nextAttemptAt: null, - processedAt, - redactedError: disposition.reason, - updatedAt: processedAt, - }, - ) - .where( - and( - eq(chatDeliveries.id, locked.id), - eq(chatDeliveries.state, locked.state), - eq(chatDeliveries.attempts, locked.attempts), - ), - ); - }); - } - - async function processPendingReactionDeliveries( - limit: number, - onlyDeliveryId?: string, - ): Promise { - const now = new Date(); - const candidates = await db - .select() - .from(chatDeliveries) - .where( - and( - sql`not exists (select 1 from chat_endpoints e where e.id = ${chatDeliveries.endpointId} and e.provider = 'agentmail')`, - onlyDeliveryId ? eq(chatDeliveries.id, onlyDeliveryId) : undefined, - inArray(chatDeliveries.eventKind, [ - "reaction_added", - "reaction_removed", - ]), - or( - and( - eq(chatDeliveries.state, "received"), - or( - isNull(chatDeliveries.nextAttemptAt), - lte(chatDeliveries.nextAttemptAt, now), - ), - ), - and( - eq(chatDeliveries.state, "retry"), - or( - isNull(chatDeliveries.nextAttemptAt), - lte(chatDeliveries.nextAttemptAt, now), - ), - ), - ), - ), - ) - .orderBy(asc(chatDeliveries.receivedAt), asc(chatDeliveries.id)) - .limit(limit); - for (const candidate of candidates) { - try { - await processPendingReactionDelivery(candidate); - } catch (error) { - logger.warn( - { - endpointId: candidate.endpointId, - deliveryId: candidate.id, - error: redactError(error), - }, - "chat reaction-link reconciliation failed", - ); - } - } - return candidates.length; - } - /** * Reconcile a verified normalized delivery whose original request was * interrupted. Closed, credential-free provider descriptors reconstruct @@ -19926,96 +15545,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return reactionCount + rows.length; } - async function listConversations(endpointId: string) { - const record = await endpointRecord(endpointId); - if (!record) throw notFound("Chat endpoint not found"); - const conversations = await db - .select() - .from(chatConversations) - .where(eq(chatConversations.endpointId, endpointId)) - .orderBy( - desc(chatConversations.lastActivityAt), - desc(chatConversations.createdAt), - ); - if (!conversations.length) return []; - const issueRows = await db - .select({ - id: issues.id, - identifier: issues.identifier, - title: issues.title, - status: issues.status, - updatedAt: issues.updatedAt, - }) - .from(issues) - .where( - inArray( - issues.id, - conversations.map((row) => row.issueId), - ), - ); - const publications = await db - .select() - .from(chatPublications) - .where(eq(chatPublications.endpointId, endpointId)) - .orderBy(desc(chatPublications.createdAt)); - const issueById = new Map(issueRows.map((row) => [row.id, row])); - const lastPublication = new Map< - string, - typeof chatPublications.$inferSelect - >(); - for (const publication of publications) - if (!lastPublication.has(publication.conversationId)) - lastPublication.set(publication.conversationId, publication); - return conversations.map((conversation) => { - const issue = issueById.get(conversation.issueId); - const externalUrl = - record.endpoint.provider === "telegram" && conversation.isDirectMessage - ? chatProviderConversationUrl({ - provider: "telegram", - botUsername: record.endpoint.botUsername, - threadId: conversation.externalThreadId, - providerMessageId: "", - raw: { chat: { type: "private" } }, - }) - : (conversation.providerUrl ?? - (record.endpoint.provider === "slack" - ? chatProviderConversationUrl({ - provider: "slack", - providerAccountId: record.endpoint.providerAccountId, - threadId: conversation.externalThreadId, - providerMessageId: "", - }) - : null)); - return { - id: conversation.id, - companyId: conversation.companyId, - endpointId: conversation.endpointId, - resourceId: conversation.resourceId, - externalLabel: conversation.externalLabel, - externalUrl, - externalConversationId: conversation.externalConversationId, - externalThreadId: conversation.externalThreadId, - sessionGeneration: conversation.sessionGeneration, - issueId: conversation.issueId, - issueIdentifier: issue?.identifier ?? null, - issueTitle: issue?.title ?? null, - isDirectMessage: conversation.isDirectMessage, - state: - record.endpoint.provider !== "imessage-photon" && - (issue?.status === "done" || issue?.status === "cancelled") - ? "completed" - : conversation.state, - lastActivityAt: conversation.lastActivityAt?.toISOString() ?? null, - createdAt: conversation.createdAt.toISOString(), - updatedAt: ( - conversation.lastActivityAt ?? conversation.updatedAt - ).toISOString(), - lastPublicationStatus: - lastPublication.get(conversation.id)?.state ?? null, - }; - }); - } - async function recordGitHubAutomaticSkip( endpoint: EndpointRow, deliveryId: string, event: string, reason: string | undefined, runtimeContext: LifecycleRuntimeFence, ) { @@ -20043,457 +15572,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }); } - async function listActivity(endpointId: string, page?: { limit: number; before?: { createdAt: string; id: string } }) { - const limit = page ? page.limit + 1 : 100; - // Match the millisecond precision of the public timestamp and use a UUID - // tie-breaker so equal timestamps never skip records between pages. - const before = (date: AnyPgColumn, id: AnyPgColumn) => page?.before - ? sql`(date_trunc('milliseconds', ${date}), ${id}) < (${page.before.createdAt}::timestamptz, ${page.before.id}::uuid)` - : undefined; - const recoveryIngress = alias(chatActions, "github_recovery_ingress"); - const [ - deliveries, - publications, - slashActions, - providerEffects, - githubIngressFailures, - slackSessionActions, - githubRecoveries, - githubRecoveryScans, - ] = await Promise.all([ - db - .select() - .from(chatDeliveries) - .where(and(eq(chatDeliveries.endpointId, endpointId), before(chatDeliveries.createdAt, chatDeliveries.id))) - .orderBy(desc(sql`date_trunc('milliseconds', ${chatDeliveries.createdAt})`), desc(chatDeliveries.id)) - .limit(limit), - db - .select() - .from(chatPublications) - .where(and(eq(chatPublications.endpointId, endpointId), before(chatPublications.createdAt, chatPublications.id))) - .orderBy(desc(sql`date_trunc('milliseconds', ${chatPublications.createdAt})`), desc(chatPublications.id)) - .limit(limit), - db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, endpointId), - before(chatActions.createdAt, chatActions.id), - eq(chatActions.kind, "slash_task_start"), - ), - ) - .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id)) - .limit(limit), - db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, endpointId), - before(chatActions.createdAt, chatActions.id), - eq(chatActions.kind, "provider_effect"), - eq(chatActions.status, "delivery_unknown"), - ), - ) - .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id)) - .limit(limit), - db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, endpointId), - before(chatActions.createdAt, chatActions.id), - eq(chatActions.kind, "github_webhook_ingress"), - eq(chatActions.status, "failed"), - sql`coalesce(${chatActions.result}->>'retryable', 'false') = 'false'`, - ), - ) - .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id)) - .limit(limit), - db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, endpointId), - before(chatActions.updatedAt, chatActions.id), - inArray(chatActions.kind, [ - "slack_session_sync", - "slack_session_stop", - ]), - ), - ) - .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.updatedAt})`), desc(chatActions.id)) - .limit(limit), - db - .select({ - action: chatActions, - ingressStatus: recoveryIngress.status, - }) - .from(chatActions) - .leftJoin( - recoveryIngress, - and( - eq(recoveryIngress.companyId, chatActions.companyId), - eq(recoveryIngress.endpointId, chatActions.endpointId), - eq(recoveryIngress.kind, "github_webhook_ingress"), - sql`${recoveryIngress.providerActionId} = 'github_webhook_ingress:' || (${chatActions.payload}#>>'{original,guid}')`, - ), - ) - .where( - and( - eq(chatActions.endpointId, endpointId), - before(chatActions.updatedAt, chatActions.id), - eq(chatActions.kind, "github_webhook_recovery"), - ), - ) - .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.updatedAt})`), desc(chatActions.id)) - .limit(limit), - db - .select() - .from(chatSdkState) - .where( - and( - eq(chatSdkState.endpointId, endpointId), - before(chatSdkState.updatedAt, chatSdkState.id), - eq(chatSdkState.stateKey, GITHUB_RECOVERY_STATE_KEY), - ), - ) - .limit(1), - ]); - // A provider effect can be on another page. Replay safety must still - // consider every unresolved effect associated with these deliveries. - const ambiguousEffects = deliveries.length ? await db.select({ deliveryId: chatActions.deliveryId }) - .from(chatActions).where(and( - eq(chatActions.endpointId, endpointId), - eq(chatActions.kind, "provider_effect"), - eq(chatActions.status, "delivery_unknown"), - inArray(chatActions.deliveryId, deliveries.map((row) => row.id)), - )) : []; - const ambiguousProviderEffectDeliveryIds = new Set(ambiguousEffects.map((row) => row.deliveryId)); - const transferRows = publications.length - ? await db - .select(fileTransferProjectionColumns) - .from(chatTeamsFileTransfers) - .innerJoin( - chatPublications, - and( - eq(chatPublications.id, chatTeamsFileTransfers.publicationId), - eq(chatPublications.companyId, chatTeamsFileTransfers.companyId), - eq( - chatPublications.endpointId, - chatTeamsFileTransfers.endpointId, - ), - eq( - chatPublications.conversationId, - chatTeamsFileTransfers.conversationId, - ), - ), - ) - .where( - and( - eq(chatTeamsFileTransfers.companyId, publications[0]!.companyId), - eq(chatTeamsFileTransfers.endpointId, endpointId), - inArray( - chatTeamsFileTransfers.publicationId, - publications.map((row) => row.id), - ), - ), - ) - : []; - const transfers = new Map( - transferRows.map((row) => [row.publicationId, row]), - ); - const cancellableConflicts = new Map(); - for (const transfer of transferRows) { - if (transfer.phase !== "conflict") continue; - const publication = publications.find( - (row) => row.id === transfer.publicationId, - ); - if (!publication?.conversationId) continue; - if ( - await teamsTransferProtocol().canCancelConflict({ - companyId: publication.companyId, - endpointId, - conversationId: publication.conversationId, - publicationId: publication.id, - version: transfer.version, - }) - ) - cancellableConflicts.set(publication.id, transfer.version); - } - return [ - ...deliveries.map((row) => { - const normalized = - row.normalizedEvent && - typeof row.normalizedEvent === "object" && - !Array.isArray(row.normalizedEvent) - ? (row.normalizedEvent as Record) - : {}; - const deduplication = - normalized.deduplication && - typeof normalized.deduplication === "object" && - !Array.isArray(normalized.deduplication) - ? (normalized.deduplication as Record) - : null; - const duplicateCount = - typeof deduplication?.duplicateCount === "number" - ? deduplication.duplicateCount - : 0; - const label = row.eventKind.replaceAll("_", " "); - const outcome = - row.state === "filtered" - ? "ignored" - : row.state === "processed" - ? "processed" - : row.state; - return { - id: row.id, - kind: "delivery" as const, - status: row.state, - summary: `${label} ${outcome}${duplicateCount ? ` · ${duplicateCount} duplicate${duplicateCount === 1 ? "" : "s"} ignored` : ""}`, - detail: row.redactedError, - createdAt: row.createdAt.toISOString(), - replayable: - row.state === "failed" && - Boolean(row.conversationId) && - !ambiguousProviderEffectDeliveryIds.has(row.id), - resolutionActions: [], - }; - }), - ...publications.map((row) => { - const transfer = transfers.get(row.id); - const projected = projectChatFileTransfer( - publicationSummary(row), - transfer, - ); - const payload = row.payload as Partial; - const summary = payload.progressState - ? `${payload.progressState.replaceAll("_", " ")} update` - : payload.interactionId - ? "Interactive question" - : "Response to external conversation"; - return { - id: row.id, - kind: "publication" as const, - status: projected.state, - summary, - detail: projected.redactedError ?? null, - createdAt: row.createdAt.toISOString(), - replayable: !transfer && row.state === "failed", - ...(projected.fileTransfer - ? { fileTransfer: projected.fileTransfer } - : {}), - resolutionActions: transfer - ? chatFileTransferResolutionActions( - projected, - cancellableConflicts.has(row.id) - ? { - publicationId: row.id, - version: cancellableConflicts.get(row.id)!, - } - : undefined, - ) - : row.state === "delivery_unknown" - ? (["mark_delivered", "retry_anyway", "cancel"] as const) - : [], - }; - }), - ...slashActions.map((row) => { - const recovery = slackSlashTaskRecoveryPayload(row.payload); - // Keep this GET read-only while still surfacing a crashed attempt as - // actionable. The resolution POST persists the derived quarantine - // before it applies the operator's audited decision. - const status = slackTaskStartActivityStatus(row); - return { - id: row.id, - kind: "action" as const, - actionType: "slash_task_start" as const, - status, - summary: `Slack slash-command task start ${status.replaceAll("_", " ")}`, - detail: - status === "delivery_unknown" - ? recovery - ? "Slack may have accepted the task-start message, so Paperclip will not replay it automatically. Check Slack first; an explicit retry can create a duplicate starter message and task." - : "Slack may have accepted the task-start message. This older action lacks the context required for a safe explicit retry, so check Slack and cancel it here before submitting a new command." - : status === "provider_confirmed" - ? "Slack accepted the task-start message. Paperclip is completing durable task admission without sending another Slack message." - : status === "admitting" - ? "Slack accepted the task-start message. A Paperclip worker is admitting the task without replaying the Slack send." - : status === "failed" - ? "Slack rejected the task-start message. Submit the command again to retry." - : status === "cancelled" - ? "An operator cancelled this unconfirmed task start." - : null, - createdAt: row.createdAt.toISOString(), - replayable: false, - resolutionActions: - status === "delivery_unknown" - ? recovery - ? (["retry_anyway", "cancel"] as const) - : (["cancel"] as const) - : [], - }; - }), - ...providerEffects.map((row) => { - const payload = providerEffectPayload(row.payload); - return { - id: row.id, - kind: "action" as const, - actionType: "provider_effect" as const, - status: row.status, - summary: "Provider reply delivery unknown", - detail: payload?.completeConversationId - ? "The provider may have accepted this reply, but Paperclip could not confirm it or close the task conversation. Check the provider first. Marking it delivered closes the conversation; retrying can create a duplicate message." - : "The provider may have accepted this reply, but Paperclip could not confirm it. Check the provider first. Retrying can create a duplicate message.", - createdAt: row.createdAt.toISOString(), - replayable: false, - resolutionActions: [ - "mark_delivered", - "retry_anyway", - "cancel", - ] as const, - }; - }), - ...slackSessionActions.map((row) => { - const isSync = row.kind === "slack_session_sync"; - const retrying = - row.status === "received" && - row.result?.code === "slack_session_sync_retry"; - return { - id: row.id, - kind: "action" as const, - actionType: isSync - ? ("slack_session_sync" as const) - : ("slack_session_stop" as const), - status: retrying ? "retry" : row.status, - summary: isSync ? "Slack session status" : "Slack Stop request", - detail: isSync - ? row.result?.outcome === "unavailable" - ? "Slack has not enabled native session status for this destination. Message delivery continues." - : row.result?.code === "slack_session_sync_rejected" - ? "Slack rejected the session indicator update. Check app permissions and channel access; message delivery is tracked separately." - : retrying - ? "The Slack session indicator is waiting to sync. Paperclip will not resend the response." - : row.result?.sessionStatus === "processing" - ? "Working status is refreshed automatically while the run remains active." - : null - : row.status === "processed" - ? "Paperclip stopped the work authorized by this request." - : row.status === "cancelled" - ? "No work was stopped: this request was no longer authorized or its target was no longer current." - : row.status === "failed" - ? row.result?.retryable === true - ? "The Stop request could not finish yet. Paperclip will retry against the original work only." - : "The Stop request could not be completed. Check the task's current run before trying again." - : "Paperclip is processing this Stop request against its original task and run.", - createdAt: row.updatedAt.toISOString(), - replayable: false, - resolutionActions: [], - }; - }), - ...githubIngressFailures.map((row) => { - const deliveryId = - typeof row.payload.deliveryId === "string" - ? row.payload.deliveryId - : null; - return { - id: row.id, - kind: "action" as const, - actionType: "github_webhook_ingress" as const, - status: row.status, - summary: "GitHub webhook could not be processed", - detail: deliveryId - ? `GitHub delivery ${deliveryId} was authenticated, but Paperclip could not finish processing it. Fix the connection or destination, then redeliver this delivery from the GitHub App's Recent Deliveries page.` - : "An authenticated GitHub webhook could not be processed. Fix the connection or destination, then redeliver it from the GitHub App's Recent Deliveries page.", - createdAt: row.createdAt.toISOString(), - replayable: false, - resolutionActions: [], - }; - }), - ...githubRecoveries.map(({ action: row, ingressStatus }) => ({ - id: row.id, - kind: "repair" as const, - status: - row.status === "cancelled" - ? "cancelled" - : ingressStatus - ? "received" - : "pending", - summary: - row.status === "cancelled" - ? "GitHub webhook recovery skipped" - : ingressStatus - ? "GitHub webhook received after recovery request" - : "GitHub webhook redelivery requested", - detail: - row.status === "cancelled" - ? row.result?.code === "source_changed_or_unavailable" - ? "The original comment changed or is no longer available. Paperclip did not replay its old contents." - : "This callback was not an eligible current user comment. Paperclip did not replay it." - : ingressStatus - ? "Paperclip received this callback. Its normal access checks and processing still apply." - : "Paperclip asked GitHub to resend a recent missed message. This does not yet confirm receipt or a reply. Automatic requests are limited; if it remains unanswered, check the App's Recent Deliveries and send your request again in the current conversation.", - createdAt: row.updatedAt.toISOString(), - replayable: false, - resolutionActions: [], - })), - ...githubRecoveryScans.flatMap((row) => { - const value = row.value as { outcome?: string } | null; - const detail = - value?.outcome === "history_limit_reached" - ? "Recent webhook volume exceeded automatic recovery's bounded scan. No partial history was replayed. Check the App's Recent Deliveries for missed messages." - : value?.outcome === "callback_mismatch" - ? "The GitHub App's callback no longer matches this connection. Reconnect the App to repair it; repository access will not change." - : value?.outcome === "scan_failed" - ? "Paperclip could not check GitHub's failed deliveries. The check will retry with backoff; normal callbacks are still processed." - : null; - return detail - ? [ - { - id: row.id, - kind: "health" as const, - status: "attention", - summary: "GitHub webhook recovery needs attention", - detail, - createdAt: row.updatedAt.toISOString(), - replayable: false, - resolutionActions: [], - }, - ] - : []; - }), - ] - .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)) - .slice(0, limit); - } - - async function listActivityPage(endpointId: string, limit = 25, cursor?: string) { - if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw badRequest("Activity limit must be between 1 and 100"); - let before: { createdAt: string; id: string } | undefined; - if (cursor !== undefined) { - try { - if (cursor.length > 256) throw new Error("Invalid cursor"); - const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); - if (!Array.isArray(value) || value.length !== 2 - || typeof value[0] !== "string" || new Date(value[0]).toISOString() !== value[0] - || typeof value[1] !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value[1])) throw new Error("Invalid cursor"); - before = { createdAt: value[0], id: value[1] }; - } catch { throw badRequest("Invalid activity cursor"); } - } - const rows = await listActivity(endpointId, { limit, before }); - const items = rows.slice(0, limit); - const last = items.at(-1); - return { - items, - nextCursor: rows.length > limit && last - ? Buffer.from(JSON.stringify([last.createdAt, last.id])).toString("base64url") - : null, - }; - } - async function replayDelivery(endpointId: string, deliveryId: string) { const delivery = await db .select() @@ -22685,118 +17763,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }); } - async function publicationFiles( - publication: typeof chatPublications.$inferSelect, - payload: SafeChatPublicationPayload, - preparation: { teamsInlineImagesOnly?: boolean } = {}, - ): Promise { - if (!payload.attachmentIds?.length) return []; - try { - const rows = await db - .select({ - id: issueAttachments.id, - issueId: issueAttachments.issueId, - issueCommentId: issueAttachments.issueCommentId, - objectKey: assets.objectKey, - contentType: assets.contentType, - byteSize: assets.byteSize, - sha256: assets.sha256, - originalFilename: assets.originalFilename, - }) - .from(issueAttachments) - .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) - .where( - and( - eq(issueAttachments.companyId, publication.companyId), - eq(issueAttachments.issueId, publication.issueId), - inArray(issueAttachments.id, payload.attachmentIds), - ), - ); - const byId = new Map(rows.map((row) => [row.id, row])); - const orderedRows = payload.attachmentIds.map((attachmentId) => { - const row = byId.get(attachmentId); - if ( - !row || - (publication.commentId && - row.issueCommentId !== publication.commentId) || - row.byteSize <= 0 || - row.byteSize > MAX_ATTACHMENT_BYTES || - !row.objectKey.trim() || - !/^[0-9a-f]{64}$/iu.test(row.sha256) || - !isAllowedContentType(row.contentType) - ) - throw new OutboundAttachmentValidationError( - "Chat publication attachment is invalid or outside its authorized task comment", - ); - return row; - }); - // Picture messages have a different Teams contract from arbitrary files. - // Preserve the normal scope/metadata checks above, but never hydrate a - // document or oversized image just to discover it needs a task link. - if ( - preparation.teamsInlineImagesOnly && - orderedRows.some( - (row) => - row.byteSize > TEAMS_INLINE_IMAGE_MAX_BYTES || - !isTeamsInlineImageContentType(row.contentType), - ) - ) { - return []; - } - // Authorization and persisted metadata must fail definitively even when - // storage is unavailable; do not mask an invalid file as a transient outage. - if (!options.storage) - throw new OutboundAttachmentHydrationError( - "Attachment storage is unavailable for chat publication", - ); - const files: FileUpload[] = []; - for (const row of orderedRows) { - files.push( - await hydrateOutboundAttachment({ - storage: options.storage, - companyId: publication.companyId, - objectKey: row.objectKey, - byteSize: row.byteSize, - sha256: row.sha256, - filename: row.originalFilename ?? `attachment-${row.id}`, - mimeType: row.contentType, - }), - ); - } - return files; - } catch (error) { - if ( - isOutboundAttachmentHydrationError(error) || - isOutboundAttachmentValidationError(error) - ) { - throw error; - } - throw new OutboundAttachmentHydrationError( - "Chat publication attachment preparation failed", - error, - ); - } - } - - function discordMarkdownAttachment(text: string): FileUpload { - return { - data: Buffer.from(text, "utf8"), - filename: "paperclip-response.md", - mimeType: "text/markdown; charset=utf-8", - }; - } - - function telegramMarkdownAttachment(text: string): Attachment { - const data = Buffer.from(text, "utf8"); - return { - data, - mimeType: "text/markdown; charset=utf-8", - name: "paperclip-response.md", - size: data.byteLength, - type: "file", - }; - } - async function recordExactOutboundPublicationLink( tx: DbOrTransaction, input: { @@ -26176,6 +21142,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }, lease, actor.userId); }, }); + const { verifySlackRequestUrl } = createSlackCallbackVerification(db, { resolveCredentialRefs, getWebhookPublicBaseUrl, scheduleMessageProcessing, slackRegistration }); const sendSlackTestMessage = slackTestMessageService(db, { fetch: fetchImpl, diff --git a/server/src/services/chat-channels/activity.ts b/server/src/services/chat-channels/activity.ts new file mode 100644 index 00000000000..0a9824ae577 --- /dev/null +++ b/server/src/services/chat-channels/activity.ts @@ -0,0 +1,616 @@ +import { providerEffectPayload } from "./provider-effects.js"; + +import { slackSlashTaskRecoveryPayload } from "./slack/task-start.js"; +import { GITHUB_RECOVERY_STATE_KEY } from "./github/webhook-ingress.js"; + +import { + and, + desc, + eq, + inArray, + sql, +} from "drizzle-orm"; +import { alias, type AnyPgColumn } from "drizzle-orm/pg-core"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatDeliveries, + chatPublications, + chatSdkState, + chatTeamsFileTransfers, + issues, +} from "@paperclipai/db"; +import type { + ChatPublicationSummary, + SafeChatPublicationPayload, +} from "@paperclipai/shared"; + +import { + teamsFileTransferService, +} from "../chat-teams-file-transfers.js"; + +import { + chatFileTransferResolutionActions, + projectChatFileTransfer, +} from "../chat-publication-batches.js"; + +import { + badRequest, + notFound, +} from "../../errors.js"; + +import { chatProviderConversationUrl } from "../chat-provider-links.js"; + +import type { EndpointRecord } from "./runtime-types.js"; +import { slackTaskStartActivityStatus } from "./slack/task-start.js"; +import { fileTransferProjectionColumns } from "./microsoft-teams/file-transfer-projection.js"; + +function publicationSummary( + row: typeof chatPublications.$inferSelect, +): ChatPublicationSummary { + return { + id: row.id, + state: row.state, + providerUrl: row.providerUrl, + attempts: row.attempts, + redactedError: row.redactedError, + nextAttemptAt: row.nextAttemptAt?.toISOString() ?? null, + publishedAt: row.publishedAt?.toISOString() ?? null, + }; +} +export { publicationSummary }; + +type ChatActivityOptions = { + endpointRecord: (endpointId: string) => Promise; + teamsTransferProtocol: () => Pick, "canCancelConflict">; +}; + +export function createChatActivity(db: Db, options: ChatActivityOptions) { + const { endpointRecord, teamsTransferProtocol } = options; + + + async function listConversations(endpointId: string) { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + const conversations = await db + .select() + .from(chatConversations) + .where(eq(chatConversations.endpointId, endpointId)) + .orderBy( + desc(chatConversations.lastActivityAt), + desc(chatConversations.createdAt), + ); + if (!conversations.length) return []; + const issueRows = await db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + updatedAt: issues.updatedAt, + }) + .from(issues) + .where( + inArray( + issues.id, + conversations.map((row) => row.issueId), + ), + ); + const publications = await db + .select() + .from(chatPublications) + .where(eq(chatPublications.endpointId, endpointId)) + .orderBy(desc(chatPublications.createdAt)); + const issueById = new Map(issueRows.map((row) => [row.id, row])); + const lastPublication = new Map< + string, + typeof chatPublications.$inferSelect + >(); + for (const publication of publications) + if (!lastPublication.has(publication.conversationId)) + lastPublication.set(publication.conversationId, publication); + return conversations.map((conversation) => { + const issue = issueById.get(conversation.issueId); + const externalUrl = + record.endpoint.provider === "telegram" && conversation.isDirectMessage + ? chatProviderConversationUrl({ + provider: "telegram", + botUsername: record.endpoint.botUsername, + threadId: conversation.externalThreadId, + providerMessageId: "", + raw: { chat: { type: "private" } }, + }) + : (conversation.providerUrl ?? + (record.endpoint.provider === "slack" + ? chatProviderConversationUrl({ + provider: "slack", + providerAccountId: record.endpoint.providerAccountId, + threadId: conversation.externalThreadId, + providerMessageId: "", + }) + : null)); + return { + id: conversation.id, + companyId: conversation.companyId, + endpointId: conversation.endpointId, + resourceId: conversation.resourceId, + externalLabel: conversation.externalLabel, + externalUrl, + externalConversationId: conversation.externalConversationId, + externalThreadId: conversation.externalThreadId, + sessionGeneration: conversation.sessionGeneration, + issueId: conversation.issueId, + issueIdentifier: issue?.identifier ?? null, + issueTitle: issue?.title ?? null, + isDirectMessage: conversation.isDirectMessage, + state: + record.endpoint.provider !== "imessage-photon" && + (issue?.status === "done" || issue?.status === "cancelled") + ? "completed" + : conversation.state, + lastActivityAt: conversation.lastActivityAt?.toISOString() ?? null, + createdAt: conversation.createdAt.toISOString(), + updatedAt: ( + conversation.lastActivityAt ?? conversation.updatedAt + ).toISOString(), + lastPublicationStatus: + lastPublication.get(conversation.id)?.state ?? null, + }; + }); + } + + async function listActivity(endpointId: string, page?: { limit: number; before?: { createdAt: string; id: string } }) { + const limit = page ? page.limit + 1 : 100; + // Match the millisecond precision of the public timestamp and use a UUID + // tie-breaker so equal timestamps never skip records between pages. + const before = (date: AnyPgColumn, id: AnyPgColumn) => page?.before + ? sql`(date_trunc('milliseconds', ${date}), ${id}) < (${page.before.createdAt}::timestamptz, ${page.before.id}::uuid)` + : undefined; + const recoveryIngress = alias(chatActions, "github_recovery_ingress"); + const [ + deliveries, + publications, + slashActions, + providerEffects, + githubIngressFailures, + slackSessionActions, + githubRecoveries, + githubRecoveryScans, + ] = await Promise.all([ + db + .select() + .from(chatDeliveries) + .where(and(eq(chatDeliveries.endpointId, endpointId), before(chatDeliveries.createdAt, chatDeliveries.id))) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatDeliveries.createdAt})`), desc(chatDeliveries.id)) + .limit(limit), + db + .select() + .from(chatPublications) + .where(and(eq(chatPublications.endpointId, endpointId), before(chatPublications.createdAt, chatPublications.id))) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatPublications.createdAt})`), desc(chatPublications.id)) + .limit(limit), + db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, endpointId), + before(chatActions.createdAt, chatActions.id), + eq(chatActions.kind, "slash_task_start"), + ), + ) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id)) + .limit(limit), + db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, endpointId), + before(chatActions.createdAt, chatActions.id), + eq(chatActions.kind, "provider_effect"), + eq(chatActions.status, "delivery_unknown"), + ), + ) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id)) + .limit(limit), + db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, endpointId), + before(chatActions.createdAt, chatActions.id), + eq(chatActions.kind, "github_webhook_ingress"), + eq(chatActions.status, "failed"), + sql`coalesce(${chatActions.result}->>'retryable', 'false') = 'false'`, + ), + ) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id)) + .limit(limit), + db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, endpointId), + before(chatActions.updatedAt, chatActions.id), + inArray(chatActions.kind, [ + "slack_session_sync", + "slack_session_stop", + ]), + ), + ) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.updatedAt})`), desc(chatActions.id)) + .limit(limit), + db + .select({ + action: chatActions, + ingressStatus: recoveryIngress.status, + }) + .from(chatActions) + .leftJoin( + recoveryIngress, + and( + eq(recoveryIngress.companyId, chatActions.companyId), + eq(recoveryIngress.endpointId, chatActions.endpointId), + eq(recoveryIngress.kind, "github_webhook_ingress"), + sql`${recoveryIngress.providerActionId} = 'github_webhook_ingress:' || (${chatActions.payload}#>>'{original,guid}')`, + ), + ) + .where( + and( + eq(chatActions.endpointId, endpointId), + before(chatActions.updatedAt, chatActions.id), + eq(chatActions.kind, "github_webhook_recovery"), + ), + ) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.updatedAt})`), desc(chatActions.id)) + .limit(limit), + db + .select() + .from(chatSdkState) + .where( + and( + eq(chatSdkState.endpointId, endpointId), + before(chatSdkState.updatedAt, chatSdkState.id), + eq(chatSdkState.stateKey, GITHUB_RECOVERY_STATE_KEY), + ), + ) + .limit(1), + ]); + // A provider effect can be on another page. Replay safety must still + // consider every unresolved effect associated with these deliveries. + const ambiguousEffects = deliveries.length ? await db.select({ deliveryId: chatActions.deliveryId }) + .from(chatActions).where(and( + eq(chatActions.endpointId, endpointId), + eq(chatActions.kind, "provider_effect"), + eq(chatActions.status, "delivery_unknown"), + inArray(chatActions.deliveryId, deliveries.map((row) => row.id)), + )) : []; + const ambiguousProviderEffectDeliveryIds = new Set(ambiguousEffects.map((row) => row.deliveryId)); + const transferRows = publications.length + ? await db + .select(fileTransferProjectionColumns) + .from(chatTeamsFileTransfers) + .innerJoin( + chatPublications, + and( + eq(chatPublications.id, chatTeamsFileTransfers.publicationId), + eq(chatPublications.companyId, chatTeamsFileTransfers.companyId), + eq( + chatPublications.endpointId, + chatTeamsFileTransfers.endpointId, + ), + eq( + chatPublications.conversationId, + chatTeamsFileTransfers.conversationId, + ), + ), + ) + .where( + and( + eq(chatTeamsFileTransfers.companyId, publications[0]!.companyId), + eq(chatTeamsFileTransfers.endpointId, endpointId), + inArray( + chatTeamsFileTransfers.publicationId, + publications.map((row) => row.id), + ), + ), + ) + : []; + const transfers = new Map( + transferRows.map((row) => [row.publicationId, row]), + ); + const cancellableConflicts = new Map(); + for (const transfer of transferRows) { + if (transfer.phase !== "conflict") continue; + const publication = publications.find( + (row) => row.id === transfer.publicationId, + ); + if (!publication?.conversationId) continue; + if ( + await teamsTransferProtocol().canCancelConflict({ + companyId: publication.companyId, + endpointId, + conversationId: publication.conversationId, + publicationId: publication.id, + version: transfer.version, + }) + ) + cancellableConflicts.set(publication.id, transfer.version); + } + return [ + ...deliveries.map((row) => { + const normalized = + row.normalizedEvent && + typeof row.normalizedEvent === "object" && + !Array.isArray(row.normalizedEvent) + ? (row.normalizedEvent as Record) + : {}; + const deduplication = + normalized.deduplication && + typeof normalized.deduplication === "object" && + !Array.isArray(normalized.deduplication) + ? (normalized.deduplication as Record) + : null; + const duplicateCount = + typeof deduplication?.duplicateCount === "number" + ? deduplication.duplicateCount + : 0; + const label = row.eventKind.replaceAll("_", " "); + const outcome = + row.state === "filtered" + ? "ignored" + : row.state === "processed" + ? "processed" + : row.state; + return { + id: row.id, + kind: "delivery" as const, + status: row.state, + summary: `${label} ${outcome}${duplicateCount ? ` · ${duplicateCount} duplicate${duplicateCount === 1 ? "" : "s"} ignored` : ""}`, + detail: row.redactedError, + createdAt: row.createdAt.toISOString(), + replayable: + row.state === "failed" && + Boolean(row.conversationId) && + !ambiguousProviderEffectDeliveryIds.has(row.id), + resolutionActions: [], + }; + }), + ...publications.map((row) => { + const transfer = transfers.get(row.id); + const projected = projectChatFileTransfer( + publicationSummary(row), + transfer, + ); + const payload = row.payload as Partial; + const summary = payload.progressState + ? `${payload.progressState.replaceAll("_", " ")} update` + : payload.interactionId + ? "Interactive question" + : "Response to external conversation"; + return { + id: row.id, + kind: "publication" as const, + status: projected.state, + summary, + detail: projected.redactedError ?? null, + createdAt: row.createdAt.toISOString(), + replayable: !transfer && row.state === "failed", + ...(projected.fileTransfer + ? { fileTransfer: projected.fileTransfer } + : {}), + resolutionActions: transfer + ? chatFileTransferResolutionActions( + projected, + cancellableConflicts.has(row.id) + ? { + publicationId: row.id, + version: cancellableConflicts.get(row.id)!, + } + : undefined, + ) + : row.state === "delivery_unknown" + ? (["mark_delivered", "retry_anyway", "cancel"] as const) + : [], + }; + }), + ...slashActions.map((row) => { + const recovery = slackSlashTaskRecoveryPayload(row.payload); + // Keep this GET read-only while still surfacing a crashed attempt as + // actionable. The resolution POST persists the derived quarantine + // before it applies the operator's audited decision. + const status = slackTaskStartActivityStatus(row); + return { + id: row.id, + kind: "action" as const, + actionType: "slash_task_start" as const, + status, + summary: `Slack slash-command task start ${status.replaceAll("_", " ")}`, + detail: + status === "delivery_unknown" + ? recovery + ? "Slack may have accepted the task-start message, so Paperclip will not replay it automatically. Check Slack first; an explicit retry can create a duplicate starter message and task." + : "Slack may have accepted the task-start message. This older action lacks the context required for a safe explicit retry, so check Slack and cancel it here before submitting a new command." + : status === "provider_confirmed" + ? "Slack accepted the task-start message. Paperclip is completing durable task admission without sending another Slack message." + : status === "admitting" + ? "Slack accepted the task-start message. A Paperclip worker is admitting the task without replaying the Slack send." + : status === "failed" + ? "Slack rejected the task-start message. Submit the command again to retry." + : status === "cancelled" + ? "An operator cancelled this unconfirmed task start." + : null, + createdAt: row.createdAt.toISOString(), + replayable: false, + resolutionActions: + status === "delivery_unknown" + ? recovery + ? (["retry_anyway", "cancel"] as const) + : (["cancel"] as const) + : [], + }; + }), + ...providerEffects.map((row) => { + const payload = providerEffectPayload(row.payload); + return { + id: row.id, + kind: "action" as const, + actionType: "provider_effect" as const, + status: row.status, + summary: "Provider reply delivery unknown", + detail: payload?.completeConversationId + ? "The provider may have accepted this reply, but Paperclip could not confirm it or close the task conversation. Check the provider first. Marking it delivered closes the conversation; retrying can create a duplicate message." + : "The provider may have accepted this reply, but Paperclip could not confirm it. Check the provider first. Retrying can create a duplicate message.", + createdAt: row.createdAt.toISOString(), + replayable: false, + resolutionActions: [ + "mark_delivered", + "retry_anyway", + "cancel", + ] as const, + }; + }), + ...slackSessionActions.map((row) => { + const isSync = row.kind === "slack_session_sync"; + const retrying = + row.status === "received" && + row.result?.code === "slack_session_sync_retry"; + return { + id: row.id, + kind: "action" as const, + actionType: isSync + ? ("slack_session_sync" as const) + : ("slack_session_stop" as const), + status: retrying ? "retry" : row.status, + summary: isSync ? "Slack session status" : "Slack Stop request", + detail: isSync + ? row.result?.outcome === "unavailable" + ? "Slack has not enabled native session status for this destination. Message delivery continues." + : row.result?.code === "slack_session_sync_rejected" + ? "Slack rejected the session indicator update. Check app permissions and channel access; message delivery is tracked separately." + : retrying + ? "The Slack session indicator is waiting to sync. Paperclip will not resend the response." + : row.result?.sessionStatus === "processing" + ? "Working status is refreshed automatically while the run remains active." + : null + : row.status === "processed" + ? "Paperclip stopped the work authorized by this request." + : row.status === "cancelled" + ? "No work was stopped: this request was no longer authorized or its target was no longer current." + : row.status === "failed" + ? row.result?.retryable === true + ? "The Stop request could not finish yet. Paperclip will retry against the original work only." + : "The Stop request could not be completed. Check the task's current run before trying again." + : "Paperclip is processing this Stop request against its original task and run.", + createdAt: row.updatedAt.toISOString(), + replayable: false, + resolutionActions: [], + }; + }), + ...githubIngressFailures.map((row) => { + const deliveryId = + typeof row.payload.deliveryId === "string" + ? row.payload.deliveryId + : null; + return { + id: row.id, + kind: "action" as const, + actionType: "github_webhook_ingress" as const, + status: row.status, + summary: "GitHub webhook could not be processed", + detail: deliveryId + ? `GitHub delivery ${deliveryId} was authenticated, but Paperclip could not finish processing it. Fix the connection or destination, then redeliver this delivery from the GitHub App's Recent Deliveries page.` + : "An authenticated GitHub webhook could not be processed. Fix the connection or destination, then redeliver it from the GitHub App's Recent Deliveries page.", + createdAt: row.createdAt.toISOString(), + replayable: false, + resolutionActions: [], + }; + }), + ...githubRecoveries.map(({ action: row, ingressStatus }) => ({ + id: row.id, + kind: "repair" as const, + status: + row.status === "cancelled" + ? "cancelled" + : ingressStatus + ? "received" + : "pending", + summary: + row.status === "cancelled" + ? "GitHub webhook recovery skipped" + : ingressStatus + ? "GitHub webhook received after recovery request" + : "GitHub webhook redelivery requested", + detail: + row.status === "cancelled" + ? row.result?.code === "source_changed_or_unavailable" + ? "The original comment changed or is no longer available. Paperclip did not replay its old contents." + : "This callback was not an eligible current user comment. Paperclip did not replay it." + : ingressStatus + ? "Paperclip received this callback. Its normal access checks and processing still apply." + : "Paperclip asked GitHub to resend a recent missed message. This does not yet confirm receipt or a reply. Automatic requests are limited; if it remains unanswered, check the App's Recent Deliveries and send your request again in the current conversation.", + createdAt: row.updatedAt.toISOString(), + replayable: false, + resolutionActions: [], + })), + ...githubRecoveryScans.flatMap((row) => { + const value = row.value as { outcome?: string } | null; + const detail = + value?.outcome === "history_limit_reached" + ? "Recent webhook volume exceeded automatic recovery's bounded scan. No partial history was replayed. Check the App's Recent Deliveries for missed messages." + : value?.outcome === "callback_mismatch" + ? "The GitHub App's callback no longer matches this connection. Reconnect the App to repair it; repository access will not change." + : value?.outcome === "scan_failed" + ? "Paperclip could not check GitHub's failed deliveries. The check will retry with backoff; normal callbacks are still processed." + : null; + return detail + ? [ + { + id: row.id, + kind: "health" as const, + status: "attention", + summary: "GitHub webhook recovery needs attention", + detail, + createdAt: row.updatedAt.toISOString(), + replayable: false, + resolutionActions: [], + }, + ] + : []; + }), + ] + .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)) + .slice(0, limit); + } + + async function listActivityPage(endpointId: string, limit = 25, cursor?: string) { + if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw badRequest("Activity limit must be between 1 and 100"); + let before: { createdAt: string; id: string } | undefined; + if (cursor !== undefined) { + try { + if (cursor.length > 256) throw new Error("Invalid cursor"); + const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); + if (!Array.isArray(value) || value.length !== 2 + || typeof value[0] !== "string" || new Date(value[0]).toISOString() !== value[0] + || typeof value[1] !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value[1])) throw new Error("Invalid cursor"); + before = { createdAt: value[0], id: value[1] }; + } catch { throw badRequest("Invalid activity cursor"); } + } + const rows = await listActivity(endpointId, { limit, before }); + const items = rows.slice(0, limit); + const last = items.at(-1); + return { + items, + nextCursor: rows.length > limit && last + ? Buffer.from(JSON.stringify([last.createdAt, last.id])).toString("base64url") + : null, + }; + } + return { listConversations, listActivity, listActivityPage }; +} diff --git a/server/src/services/chat-channels/discord/gateway-events.ts b/server/src/services/chat-channels/discord/gateway-events.ts new file mode 100644 index 00000000000..e283fdc8630 --- /dev/null +++ b/server/src/services/chat-channels/discord/gateway-events.ts @@ -0,0 +1,253 @@ +import type { EndpointRow, DbTransaction, RuntimeContext, LifecycleRuntimeFence, InternalSetupState } from "../runtime-types.js"; + +import { notifyChatEndpointWork } from "../../chat-work-notifications.js"; + +import { + and, + eq, + inArray, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatConversations, + chatEndpointResources, + chatEndpoints, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatEndpointSetupState, +} from "@paperclipai/shared"; + +import { + type DiscordGatewayCallbackEvent, +} from "../../chat-sdk-runtime.js"; + +import { + type ChatProviderLifecycleEffect, +} from "../../chat-provider-lifecycle.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type DiscordGatewayEventsOptions = { + ensureDiscordGatewayRuntimeIsCurrent: (endpointId: string, context: RuntimeContext) => Promise; + discordGatewayRuntimeIsCurrent: (endpointId: string, context: RuntimeContext) => boolean; + runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + applyProviderLifecycleEffect: (endpoint: EndpointRow, effect: ChatProviderLifecycleEffect, context?: RuntimeContext) => Promise; + runtimeGeneration: (setup: ChatEndpointSetupState) => number; + discordGatewayEventBarrier?: (event: DiscordGatewayCallbackEvent) => Promise; +}; + +export function createDiscordGatewayEvents(db: Db, options: DiscordGatewayEventsOptions) { + const { ensureDiscordGatewayRuntimeIsCurrent, discordGatewayRuntimeIsCurrent, runtimeCallbackRecord, runtimeCallbackEndpoint, applyProviderLifecycleEffect, runtimeGeneration } = options; + + + function discordGatewayFailureDetail( + event: DiscordGatewayCallbackEvent["event"], + ): string { + if (event.type === "guild_removed") { + return "Discord bot was removed from the configured server"; + } + if (event.type === "guild_unavailable") { + return "Discord server is temporarily unavailable; reconnecting"; + } + if (event.type === "disconnected") { + return `Discord Gateway disconnected${event.code === undefined ? "" : ` (code ${event.code})`}; reconnecting`; + } + if (event.type === "failure") { + const code = event.error.code ?? event.error.status; + return `Discord Gateway failed${code === undefined ? "" : ` (${String(code).slice(0, 64)})`}; ${event.fatal ? "credentials or privileged intents need attention" : "reconnecting"}`; + } + return "Discord Gateway connection needs attention"; + } + + function handleDiscordGatewayEvent( + callback: DiscordGatewayCallbackEvent, + context: RuntimeContext, + ): Promise { + const pending = (context.discordGatewayEventQueue ?? Promise.resolve()) + .catch(() => undefined) + .then(() => processDiscordGatewayEvent(callback, context)); + context.discordGatewayEventQueue = pending; + return pending; + } + + async function processDiscordGatewayEvent( + callback: DiscordGatewayCallbackEvent, + context: RuntimeContext, + ): Promise { + if ( + !(await ensureDiscordGatewayRuntimeIsCurrent( + callback.endpointId, + context, + )) + ) + return; + if (callback.sequence <= (context.discordGatewaySequence ?? 0)) return; + context.discordGatewaySequence = callback.sequence; + await options.discordGatewayEventBarrier?.(callback); + const record = await runtimeCallbackRecord(callback.endpointId, context, [ + "verifying", + "active", + "attention", + ]); + if (!record || record.endpoint.provider !== "discord") return; + const event = callback.event; + if (event.type === "channel_removed") { + await applyProviderLifecycleEffect( + record.endpoint, + { + kind: "resource", + provider: "discord", + providerEventId: `gateway:channel_removed:${event.channelId}`, + providerResourceId: event.channelId, + resourceType: "channel", + label: event.label?.trim() || event.channelId, + availability: "removed", + metadata: { source: "discord_gateway" }, + }, + context, + ); + return; + } + + await db.transaction(async (tx) => { + const current = await runtimeCallbackEndpoint( + tx, + callback.endpointId, + context, + ["verifying", "active", "attention"], + ); + if ( + !current || + current.provider !== "discord" || + !discordGatewayRuntimeIsCurrent(callback.endpointId, context) + ) { + return; + } + const now = new Date(); + if (event.type === "connecting") { + await tx + .update(chatEndpoints) + .set({ + healthMessage: "Discord Gateway connecting", + lastError: null, + lastEventAt: now, + updatedAt: now, + }) + .where(eq(chatEndpoints.id, current.id)); + await tx + .update(toolConnections) + .set({ + healthStatus: "unchecked", + healthMessage: "Discord Gateway connecting", + lastError: null, + healthCheckedAt: now, + updatedAt: now, + }) + .where(eq(toolConnections.id, current.connectionId)); + return; + } + if (event.type === "ready" || event.type === "guild_available") { + const gatewayOwnedAttention = + current.status === "attention" && + current.healthMessage?.startsWith("Discord ") === true; + if (current.status === "attention" && !gatewayOwnedAttention) return; + const status = + current.setup.step === "complete" ? "active" : "verifying"; + if (status !== current.status) await notifyChatEndpointWork(tx); + await tx + .update(chatEndpoints) + .set({ + status, + healthMessage: + status === "active" + ? "Connected" + : "Waiting for a test conversation", + lastError: null, + lastEventAt: now, + updatedAt: now, + }) + .where(eq(chatEndpoints.id, current.id)); + await tx + .update(toolConnections) + .set({ + status: "active", + enabled: true, + healthStatus: "healthy", + healthMessage: "Connected", + lastError: null, + healthCheckedAt: now, + updatedAt: now, + }) + .where(eq(toolConnections.id, current.connectionId)); + return; + } + + const detail = discordGatewayFailureDetail(event); + const transientGuildOutage = event.type === "guild_unavailable"; + const terminal = + event.type === "guild_removed" || + ((event.type === "failure" || event.type === "disconnected") && + event.fatal); + await tx + .update(chatEndpoints) + .set({ + status: transientGuildOutage + ? current.status + : event.type === "guild_removed" + ? "revoked" + : "attention", + healthMessage: detail, + lastError: detail, + lastEventAt: now, + ...(terminal + ? { + setup: { + ...current.setup, + runtimeGeneration: runtimeGeneration(current.setup) + 1, + } as InternalSetupState, + } + : {}), + updatedAt: now, + }) + .where(eq(chatEndpoints.id, current.id)); + await tx + .update(toolConnections) + .set({ + status: terminal ? "disabled" : "active", + enabled: !terminal, + healthStatus: event.type === "guild_removed" ? "failed" : "degraded", + healthMessage: detail, + lastError: detail, + healthCheckedAt: now, + updatedAt: now, + }) + .where(eq(toolConnections.id, current.connectionId)); + if (event.type === "guild_removed") { + await tx + .update(chatEndpointResources) + .set({ availability: "unavailable", updatedAt: now }) + .where( + and( + eq(chatEndpointResources.companyId, current.companyId), + eq(chatEndpointResources.endpointId, current.id), + ), + ); + await tx + .update(chatConversations) + .set({ state: "unavailable", updatedAt: now }) + .where( + and( + eq(chatConversations.companyId, current.companyId), + eq(chatConversations.endpointId, current.id), + inArray(chatConversations.state, ["active", "waiting"]), + ), + ); + } + }); + } + return { handleDiscordGatewayEvent }; +} diff --git a/server/src/services/chat-channels/discord/messages.ts b/server/src/services/chat-channels/discord/messages.ts index f61b2a2e8ef..61582abed26 100644 --- a/server/src/services/chat-channels/discord/messages.ts +++ b/server/src/services/chat-channels/discord/messages.ts @@ -1,3 +1,4 @@ +import type { FileUpload } from "chat"; import { createHash } from "node:crypto"; @@ -28,3 +29,11 @@ export function discordLifecycleFilesDigest(raw: unknown): string { : []; return createHash("sha256").update(JSON.stringify(projection)).digest("hex"); } + +export function discordMarkdownAttachment(text: string): FileUpload { + return { + data: Buffer.from(text, "utf8"), + filename: "paperclip-response.md", + mimeType: "text/markdown; charset=utf-8", + }; + } diff --git a/server/src/services/chat-channels/discord/root-threads.ts b/server/src/services/chat-channels/discord/root-threads.ts new file mode 100644 index 00000000000..1772ce375bb --- /dev/null +++ b/server/src/services/chat-channels/discord/root-threads.ts @@ -0,0 +1,570 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext } from "../runtime-types.js"; + +import { notifyChatDeliveryWork } from "../../chat-work-notifications.js"; + +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; + +import { chatCredentialMutationLease } from "../../chat-credential-mutation-lease.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + eq, + inArray, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatConversations, + chatDeliveries, + chatEndpointResources, + chatEndpoints, + chatExternalPrincipals, + companyMemberships, +} from "@paperclipai/db"; + +import { + conflict, +} from "../../../errors.js"; +import { logger } from "../../../middleware/logger.js"; + +import { + DiscordAdapterCompatibilityError, + type ChatSdkMessageCallbackEvent, + type DiscordRootMentionAdmissionEvent, +} from "../../chat-sdk-runtime.js"; + +import { classifyChatPublicationError } from "../../chat-publication-errors.js"; + +import type { + Message, + Thread, +} from "chat"; + +type DeliveryRow = typeof chatDeliveries.$inferSelect; + +type DiscordRootThreadsOptions = { + ensureDiscordGatewayRuntimeIsCurrent: (endpointId: string, context: RuntimeContext) => Promise; + withCredentialMutationLease: ReturnType; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean; linkedDenied: boolean }>; + processMessage: (endpoint: EndpointRow, thread: Thread, message: Message, trigger: ChatSdkMessageCallbackEvent["trigger"], ingressOnly?: boolean, recoveredProviderUrl?: string | null, runtimeContext?: InboundRuntimeContext, providerUpdateId?: number, admittedDeliveryId?: string | null, receiptReactionSupported?: boolean, preauthorizedUserId?: string | null, deferDrainUntilFollowup?: boolean) => Promise; + redactError: (error: unknown) => string; + scheduleConversationDrain: (endpointId: string, threadId: string) => void; + lifecycleRuntimeFence: (delivery: DeliveryRow) => LifecycleRuntimeFence | null; + runtimeFor: (endpoint: EndpointRow) => Promise; + DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS: number; + discordRootThreadTransportBarrier?: () => Promise; +}; + +export function createDiscordRootThreads(db: Db, options: DiscordRootThreadsOptions) { + const { ensureDiscordGatewayRuntimeIsCurrent, withCredentialMutationLease, runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization, processMessage, redactError, scheduleConversationDrain, lifecycleRuntimeFence, runtimeFor, DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS } = options; + + + async function admitDiscordRootMention( + configuredEndpoint: EndpointRow, + event: DiscordRootMentionAdmissionEvent, + context: RuntimeContext, + ): Promise { + if ( + !(await ensureDiscordGatewayRuntimeIsCurrent( + configuredEndpoint.id, + context, + )) + ) { + return false; + } + return withCredentialMutationLease( + configuredEndpoint, + async (credentialLease) => { + const threadId = `discord:${event.guildId}:${event.channelId}:${event.messageId}`; + const providerEventId = `${threadId}:${event.messageId}`; + const allowed = await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const endpoint = await runtimeCallbackEndpoint( + tx, + configuredEndpoint.id, + context, + ["verifying", "active"], + ); + const existing = await tx + .select({ state: chatDeliveries.state }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.endpointId, configuredEndpoint.id), + eq(chatDeliveries.providerEventId, providerEventId), + ), + ) + .then((rows) => rows[0] ?? null); + if ( + existing && + ["processed", "filtered", "failed"].includes(existing.state) + ) + return false; + + let reason: string | null = null; + if (!endpoint || endpoint.providerAccountId !== event.guildId) { + reason = "Connection is not active"; + } + const resource = endpoint + ? await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.type, "channel"), + eq( + chatEndpointResources.providerResourceId, + event.channelId, + ), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const enabledCount = + endpoint?.status === "verifying" + ? await tx + .select({ count: sql`count(*)::int` }) + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.type, "channel"), + eq(chatEndpointResources.enabled, true), + eq(chatEndpointResources.availability, "available"), + ), + ) + .then((rows) => rows[0]?.count ?? 0) + : 0; + const setupDestination = + endpoint?.status === "verifying" && + enabledCount === 0 && + resource?.availability === "available"; + if ( + !reason && + !( + endpoint?.allowGroupChats && + resource?.availability === "available" && + (resource.enabled || setupDestination) + ) + ) { + reason = "Destination is not enabled in Paperclip"; + } + + if (!reason && endpoint) { + const principal = await tx + .select({ id: chatExternalPrincipals.id }) + .from(chatExternalPrincipals) + .where( + and( + eq(chatExternalPrincipals.companyId, endpoint.companyId), + eq(chatExternalPrincipals.provider, "discord"), + eq(chatExternalPrincipals.providerAccountId, event.guildId), + eq(chatExternalPrincipals.externalId, event.userId), + ), + ) + .then((rows) => rows[0] ?? null); + let allowed: boolean; + let linkedDenied = false; + if (principal) { + const authorization = await lockCurrentPrincipalAuthorization( + tx, + endpoint, + principal.id, + ); + allowed = authorization.allowed; + linkedDenied = authorization.linkedDenied; + } else if (!endpoint.allowUnlinkedPeople) { + allowed = false; + } else if (!endpoint.sponsorUserId) { + allowed = true; + } else { + const sponsor = await tx + .select({ + status: companyMemberships.status, + membershipRole: companyMemberships.membershipRole, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, endpoint.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, endpoint.sponsorUserId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + allowed = + sponsor?.status === "active" && + sponsor.membershipRole !== "viewer"; + } + if (!allowed) { + reason = linkedDenied + ? "Linked Paperclip account is not currently permitted" + : endpoint.allowUnlinkedPeople + ? "Endpoint sponsor can no longer authorize external guests" + : "External identity must be linked to a Paperclip account"; + } + } + if (!reason) { + await credentialLease.assertOwned(tx); + return true; + } + + const filteredAt = new Date(); + const normalizedEvent = { + providerEventId, + kind: "mention", + trigger: "mention", + resource: { + type: "channel", + providerResourceId: event.channelId, + }, + conversation: { externalThreadId: threadId }, + message: { providerMessageId: event.messageId }, + filtering: { contentRetained: false, providerThreadCreated: false }, + }; + await notifyChatDeliveryWork(tx); + await tx + .insert(chatDeliveries) + .values({ + companyId: configuredEndpoint.companyId, + endpointId: configuredEndpoint.id, + providerEventId, + deduplicationKey: createHash("sha256") + .update(providerEventId) + .digest("hex"), + eventKind: "mention", + normalizedEvent, + state: "filtered", + redactedError: reason, + processedAt: filteredAt, + }) + .onConflictDoUpdate({ + target: [ + chatDeliveries.endpointId, + chatDeliveries.providerEventId, + ], + set: { + normalizedEvent, + principalId: null, + state: "filtered", + nextAttemptAt: null, + redactedError: reason, + processedAt: filteredAt, + updatedAt: filteredAt, + }, + }); + await tx + .update(chatEndpoints) + .set({ lastEventAt: filteredAt, updatedAt: filteredAt }) + .where(eq(chatEndpoints.id, configuredEndpoint.id)); + await credentialLease.assertOwned(tx); + return false; + }); + if (!allowed || !event.message || !context.endpointRuntime) { + return false; + } + const thread = context.endpointRuntime.thread(event.threadId); + await processMessage( + configuredEndpoint, + thread, + event.message, + "mention", + true, + null, + context, + undefined, + null, + true, + undefined, + true, + ); + const staged = await db + .select({ state: chatDeliveries.state }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.endpointId, configuredEndpoint.id), + eq(chatDeliveries.providerEventId, providerEventId), + ), + ) + .then((rows) => rows[0] ?? null); + if ( + !staged || + !["received", "retry", "processing"].includes(staged.state) + ) { + return false; + } + + await options.discordRootThreadTransportBarrier?.(); + await credentialLease.assertOwned(); + if ( + !(await ensureDiscordGatewayRuntimeIsCurrent( + configuredEndpoint.id, + context, + )) + ) { + return false; + } + try { + await context.endpointRuntime.ensureDiscordRootThread({ + channelId: event.channelId, + content: event.message.text, + messageId: event.messageId, + }); + } catch (error) { + // The durable provisional receipt owns recovery. Do not let a + // transient REST failure escape the Gateway event handler or ask the + // adapter to create the same thread outside Paperclip's lifecycle + // fence. + logger.warn( + { + endpointId: configuredEndpoint.id, + error: redactError(error), + providerEventId, + }, + "Discord root thread creation will be retried from the durable receipt", + ); + return false; + } + const promoted = await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const current = await runtimeCallbackEndpoint( + tx, + configuredEndpoint.id, + context, + ["verifying", "active"], + ); + if (!current) return false; + await notifyChatDeliveryWork(tx); + const [row] = await tx + .update(chatDeliveries) + .set({ + state: "received", + normalizedEvent: sql`${chatDeliveries.normalizedEvent} - 'providerThreadPending'`, + nextAttemptAt: null, + redactedError: null, + updatedAt: new Date(), + }) + .where( + and( + eq(chatDeliveries.endpointId, configuredEndpoint.id), + eq(chatDeliveries.providerEventId, providerEventId), + eq(chatDeliveries.state, "retry"), + sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`, + ), + ) + .returning({ id: chatDeliveries.id }); + await credentialLease.assertOwned(tx); + return Boolean(row); + }); + if (promoted) + scheduleConversationDrain(configuredEndpoint.id, threadId); + // Paperclip created the provider thread inside its credential/reach + // lease. Returning false prevents the adapter from creating it again; + // the durable drain owns the remaining task mutation. + return false; + }, + ); + } + + function discordRootThreadPending( + delivery: Pick, + ): boolean { + return ( + Boolean(delivery.normalizedEvent) && + typeof delivery.normalizedEvent === "object" && + !Array.isArray(delivery.normalizedEvent) && + (delivery.normalizedEvent as { providerThreadPending?: unknown }) + .providerThreadPending === true + ); + } + + async function reconcileDiscordRootThread( + endpoint: EndpointRow, + delivery: DeliveryRow, + threadId: string, + ): Promise { + try { + const normalized = delivery.normalizedEvent as { + message?: { providerMessageId?: unknown; text?: unknown }; + resource?: { providerResourceId?: unknown }; + }; + const discordThreadParts = threadId.split(":"); + const channelId = + discordThreadParts[0] === "discord" && discordThreadParts.length >= 4 + ? discordThreadParts[2] + : normalized.resource?.providerResourceId; + const messageId = normalized.message?.providerMessageId; + if (typeof channelId !== "string" || typeof messageId !== "string") { + throw new DiscordAdapterCompatibilityError( + "the provisional root receipt is missing its channel or message id", + ); + } + const runtimeFence = lifecycleRuntimeFence(delivery); + if (!runtimeFence) { + throw new DiscordAdapterCompatibilityError( + "the provisional root receipt is missing its runtime fence", + ); + } + return await withCredentialMutationLease( + endpoint, + async (credentialLease) => { + const currentEndpoint = await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const current = await runtimeCallbackEndpoint( + tx, + endpoint.id, + runtimeFence, + ["verifying", "active"], + ); + await credentialLease.assertOwned(tx); + return current; + }); + if (!currentEndpoint) { + throw conflict( + "Discord root thread credentials or connection state changed before recovery", + { code: "chat_endpoint_runtime_superseded" }, + ); + } + const endpointRuntime = await runtimeFor(currentEndpoint); + await credentialLease.assertOwned(); + await endpointRuntime.ensureDiscordRootThread({ + channelId, + messageId, + content: + typeof normalized.message?.text === "string" + ? normalized.message.text + : "Paperclip task", + }); + return await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const current = await runtimeCallbackEndpoint( + tx, + endpoint.id, + runtimeFence, + ["verifying", "active"], + ); + if (!current) return false; + await notifyChatDeliveryWork(tx); + const [promoted] = await tx + .update(chatDeliveries) + .set({ + state: "received", + normalizedEvent: sql`${chatDeliveries.normalizedEvent} - 'providerThreadPending'`, + nextAttemptAt: null, + redactedError: null, + updatedAt: new Date(), + }) + .where( + and( + eq(chatDeliveries.id, delivery.id), + eq(chatDeliveries.state, "retry"), + sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`, + ), + ) + .returning({ id: chatDeliveries.id }); + await credentialLease.assertOwned(tx); + return Boolean(promoted); + }); + }, + ); + } catch (error) { + const disposition = classifyChatPublicationError( + error, + delivery.attempts + 1, + ); + const terminal = + disposition.kind === "resource_unavailable" || + disposition.kind === "failed"; + const retryAfterMs = + disposition.kind === "retry" + ? disposition.retryAfterMs + : DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS; + const now = new Date(); + await db.transaction(async (tx) => { + if (disposition.kind === "resource_unavailable") { + const fence = lifecycleRuntimeFence(delivery); + const normalized = delivery.normalizedEvent as { + resource?: { providerResourceId?: unknown }; + }; + const providerResourceId = normalized.resource?.providerResourceId; + const current = fence + ? await runtimeCallbackEndpoint(tx, endpoint.id, fence, [ + "verifying", + "active", + "attention", + ]) + : null; + const resource = + current && typeof providerResourceId === "string" + ? await tx + .select({ id: chatEndpointResources.id }) + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.companyId, current.companyId), + eq(chatEndpointResources.endpointId, current.id), + eq( + chatEndpointResources.providerResourceId, + providerResourceId, + ), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + if (resource) { + await tx + .update(chatEndpointResources) + .set({ availability: "unavailable", updatedAt: now }) + .where(eq(chatEndpointResources.id, resource.id)); + await tx + .update(chatConversations) + .set({ state: "unavailable", updatedAt: now }) + .where( + and( + eq(chatConversations.companyId, current!.companyId), + eq(chatConversations.endpointId, current!.id), + eq(chatConversations.resourceId, resource.id), + inArray(chatConversations.state, ["active", "waiting"]), + ), + ); + } + } + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: terminal ? "filtered" : "retry", + attempts: delivery.attempts + 1, + nextAttemptAt: terminal + ? null + : new Date(now.getTime() + retryAfterMs), + processedAt: terminal ? now : null, + redactedError: terminal + ? "Discord thread creation was not confirmed" + : redactError(error), + updatedAt: now, + }) + .where( + and( + eq(chatDeliveries.id, delivery.id), + eq(chatDeliveries.state, "retry"), + sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`, + ), + ); + }); + return false; + } + } + return { admitDiscordRootMention, discordRootThreadPending, reconcileDiscordRootThread }; +} diff --git a/server/src/services/chat-channels/inbound-attachments.ts b/server/src/services/chat-channels/inbound-attachments.ts new file mode 100644 index 00000000000..4be37251b61 --- /dev/null +++ b/server/src/services/chat-channels/inbound-attachments.ts @@ -0,0 +1,674 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "./runtime-types.js"; + +import type { ChatEndpointRuntime } from "../voice/voice-runtime.js"; + +import { takePhotonCompanion } from "../photon/attachments.js"; + +import { HEIF_CONTENT_TYPES, photonHeifPreview, validatePhotonImage } from "../photon/media.js"; + +import { PhotonError } from "../photon/cloud.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + eq, + inArray, + isNull, + ne, + or, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + assets, + chatActions, + chatConversations, + chatDeliveries, + issueComments, + issueAttachments, +} from "@paperclipai/db"; + +import { + isAllowedContentType, + MAX_ATTACHMENT_BYTES, + normalizeContentType, + normalizeUploadAttachmentContentType, +} from "../../attachment-types.js"; + +import { + hasTelegramMediaProvenance, + identifyTelegramMedia, + telegramMediaNeedsIdentification, +} from "../chat-telegram-media-intake.js"; + +import { + forbidden, +} from "../../errors.js"; +import { logger } from "../../middleware/logger.js"; + +import type { StorageService } from "../../storage/types.js"; + +import { issueService } from "../issues.js"; + +import { isExternalChatWaitAuthorizationContention } from "../native-runtime/chat-attachment-reuse.js"; + +import { + GITHUB_ATTACHMENT_BATCH_TIMEOUT_MS, + GitHubAttachmentUnavailableError, + prepareGitHubPublicAttachment, +} from "../chat-github-attachments.js"; + +import type { + Attachment, +} from "chat"; + +type DeliveryRow = typeof chatDeliveries.$inferSelect; + +type InboundAttachmentsOptions = { + storage?: StorageService; + authorizeInboundWakeup: (tx: DbOrTransaction, action: typeof chatActions.$inferSelect) => Promise<{ delivery: DeliveryRow; conversation: typeof chatConversations.$inferSelect }>; + lifecycleRuntimeFence: (delivery: DeliveryRow) => LifecycleRuntimeFence | null; + sanitizeFilename: (value: string | undefined) => string | null; + issuesSvc: Pick, "createAttachment">; + isExternalActionAuthorizationChange: (error: unknown) => boolean; + redactError: (error: unknown) => string; +}; + +export function createInboundAttachments(db: Db, options: InboundAttachmentsOptions) { + const { authorizeInboundWakeup, lifecycleRuntimeFence, sanitizeFilename, issuesSvc, isExternalActionAuthorizationChange, redactError } = options; + + + async function ingestAttachments(input: { + endpoint: EndpointRow; + endpointRuntime: ChatEndpointRuntime; + deliveryId: string; + issueId: string; + issueCommentId: string; + attachments: Attachment[]; + attachmentLimitOmissions?: number; + unavailableReferenceCount?: number; + actorUserId: string | null; + }): Promise<{ + storedIds: string[]; + omissionReasons: Record; + }> { + const omissionReasons: Record = {}; + const omit = (reason: string, count = 1) => { + omissionReasons[reason] = (omissionReasons[reason] ?? 0) + count; + }; + const boundedAttachments = input.attachments.slice(0, 20); + const photonDerivatives = new Map(); + const originalPhotonAttachments = new Map(); + if ( + input.endpoint.provider === "microsoft-teams" && + input.unavailableReferenceCount + ) { + // A channel/group reference is not a download capability. Still tell + // the exact current-turn consumer that those bytes are unavailable, + // including when there are no ingestible attachments at all. + omit("download_unavailable", input.unavailableReferenceCount); + } + if ( + ["github", "imessage-photon"].includes(input.endpoint.provider) && + input.attachmentLimitOmissions + ) { + omit("attachment_limit", input.attachmentLimitOmissions); + } + if (input.attachments.length > boundedAttachments.length) { + omit( + "attachment_limit", + input.attachments.length - boundedAttachments.length, + ); + } + if (!options.storage) { + if (boundedAttachments.length) + omit("storage_unavailable", boundedAttachments.length); + return { storedIds: [], omissionReasons }; + } + const existingByFingerprint = new Map(); + const existingAttachments = await db + .select({ + id: issueAttachments.id, + byteSize: assets.byteSize, + contentType: assets.contentType, + originalFilename: assets.originalFilename, + sha256: assets.sha256, + }) + .from(issueAttachments) + .innerJoin( + assets, + and( + eq(assets.companyId, issueAttachments.companyId), + eq(assets.id, issueAttachments.assetId), + ), + ) + .where( + and( + eq(issueAttachments.companyId, input.endpoint.companyId), + eq(issueAttachments.issueId, input.issueId), + eq(issueAttachments.issueCommentId, input.issueCommentId), + ), + ); + for (const existing of existingAttachments) { + const fingerprint = JSON.stringify([ + existing.sha256, + existing.byteSize, + existing.contentType, + existing.originalFilename, + ]); + const ids = existingByFingerprint.get(fingerprint) ?? []; + ids.push(existing.id); + existingByFingerprint.set(fingerprint, ids); + } + const storedIds: string[] = []; + const withCurrentAttachmentAuthorization = async ( + work: (tx: DbTransaction) => Promise, + enforceUnchangedSource = false, + telegramMedia?: Attachment, + ): Promise => { + for (let attempt = 0; ; attempt++) { + try { + return await db.transaction(async (tx) => { + const action = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, input.endpoint.companyId), + eq(chatActions.endpointId, input.endpoint.id), + eq(chatActions.deliveryId, input.deliveryId), + eq(chatActions.kind, "inbound_wakeup"), + ), + ) + .limit(1) + .then((rows) => rows[0]); + if ( + !action || + !["preparing", "issued", "processing", "processed"].includes( + action.status, + ) || + action.payload.issueId !== input.issueId || + action.payload.commentId !== input.issueCommentId + ) + throw forbidden( + "The attachment's current chat admission is unavailable", + { code: "chat_action_authorization_changed" }, + ); + const current = await authorizeInboundWakeup(tx, action); + // Delivery settlement/replay can update these receipts without + // taking the issue lock. Lock in delivery -> action order only + // after policy authorization; NOWAIT breaks cross-worker lock + // inversions and the outer loop retries the whole transaction. + const lockedDelivery = await tx + .select() + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.companyId, input.endpoint.companyId), + eq(chatDeliveries.id, input.deliveryId), + ), + ) + .for("update", { noWait: true }) + .limit(1) + .then((rows) => rows[0]); + const lockedAction = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, input.endpoint.companyId), + eq(chatActions.id, action.id), + ), + ) + .for("update", { noWait: true }) + .limit(1) + .then((rows) => rows[0]); + const scopeKeys = [ + "version", + "issueId", + "commentId", + "agentId", + "sessionGeneration", + "requestedByActorType", + "requestedByActorId", + ] as const; + if ( + current.delivery.state !== "processing" || + !lockedDelivery || + !lockedAction || + lockedDelivery.state !== "processing" || + lockedDelivery.endpointId !== action.endpointId || + lockedDelivery.conversationId !== action.conversationId || + lockedDelivery.principalId !== action.principalId || + lockedDelivery.attempts !== current.delivery.attempts || + JSON.stringify(lifecycleRuntimeFence(lockedDelivery)) !== + JSON.stringify(lifecycleRuntimeFence(current.delivery)) || + lockedAction.endpointId !== action.endpointId || + lockedAction.deliveryId !== action.deliveryId || + lockedAction.conversationId !== action.conversationId || + lockedAction.principalId !== action.principalId || + lockedAction.kind !== "inbound_wakeup" || + !["preparing", "issued", "processing", "processed"].includes( + lockedAction.status, + ) || + scopeKeys.some( + (key) => lockedAction.payload[key] !== action.payload[key], + ) + ) + throw forbidden( + "The attachment's current chat admission is unavailable", + { code: "chat_action_authorization_changed" }, + ); + if (enforceUnchangedSource) { + if (telegramMedia) { + const normalized = lockedDelivery.normalizedEvent as { + principal?: { externalId?: string }; + message?: { + providerMessageId?: string; + attachments?: Array<{ recovery?: unknown }>; + }; + }; + const fence = lifecycleRuntimeFence(lockedDelivery); + const descriptor = + input.endpointRuntime.attachmentRecoveryDescriptor( + telegramMedia, + { + threadId: current.conversation.externalThreadId, + messageId: normalized.message?.providerMessageId ?? "", + principalExternalId: normalized.principal?.externalId, + runtimeGeneration: fence?.generation, + credentialFingerprint: fence?.credentialFingerprint, + }, + ); + if ( + descriptor?.locator.kind !== "telegram_media" || + !normalized.message?.attachments?.some((item) => { + const retained = item.recovery as { + version?: unknown; + provider?: unknown; + locator?: Record; + attachment?: Record; + } | null; + return ( + retained?.version === 1 && + retained.provider === "telegram" && + Object.entries(descriptor.locator).every( + ([key, value]) => retained.locator?.[key] === value, + ) && + Object.entries(descriptor.attachment).every( + ([key, value]) => retained.attachment?.[key] === value, + ) + ); + }) + ) + throw forbidden( + "The media's admitted source is unavailable", + { code: "chat_action_authorization_changed" }, + ); + } + const [source] = await tx + .select({ id: issueComments.id }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, input.endpoint.companyId), + eq(issueComments.issueId, input.issueId), + eq(issueComments.id, input.issueCommentId), + isNull(issueComments.deletedAt), + sql`${issueComments.updatedAt} = ${issueComments.createdAt}`, + ), + ) + .for("share", { noWait: true }); + // Lifecycle admission precedes its conversation drain. Pending + // source changes already invalidate this image download, even + // when the append-only original comment is still untouched. + const [lifecycle] = await tx + .select({ id: chatDeliveries.id }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.companyId, input.endpoint.companyId), + eq(chatDeliveries.endpointId, input.endpoint.id), + or( + isNull(chatDeliveries.conversationId), + eq( + chatDeliveries.conversationId, + current.conversation.id, + ), + ), + ne(chatDeliveries.state, "filtered"), + inArray(chatDeliveries.eventKind, [ + "message_updated", + "message_deleted", + "message_restored", + ]), + sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(lifecycleRuntimeFence(lockedDelivery))}::jsonb`, + sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${lockedDelivery.providerEventId}`, + ), + ) + .limit(1); + if (!source || lifecycle) + throw forbidden( + "The image's admitted source changed before attachment registration", + { + code: "chat_action_authorization_changed", + }, + ); + } + return await work(tx); + }); + } catch (error) { + if (!isExternalChatWaitAuthorizationContention(error) || attempt >= 4) + throw error; + // Retry the complete short transaction; no network/storage work or + // backoff occurs while governance rows are locked. + await new Promise((resolve) => + setTimeout(resolve, 20 * (attempt + 1)), + ); + } + } + }; + // One shared deadline bounds the sequential batch; an expired batch never + // starts another GitHub request. Other providers retain their own policy. + const githubBatchSignal = + input.endpoint.provider === "github" + ? AbortSignal.timeout(GITHUB_ATTACHMENT_BATCH_TIMEOUT_MS) + : undefined; + // This new image-only lane shares one budget across token acquisition and + // downloads. DB/storage commit semantics remain explicit and unchanged. + const teamsInlineBatchSignal = + input.endpoint.provider === "microsoft-teams" + ? AbortSignal.timeout(10_000) + : undefined; + for (let attachment of boundedAttachments) { + const teamsInlineImage = + input.endpoint.provider === "microsoft-teams" && + input.endpointRuntime.attachmentRecoveryDescriptor(attachment)?.locator + .kind === "teams_inline_image"; + const telegramMedia = + input.endpoint.provider === "telegram" && + hasTelegramMediaProvenance(attachment); + const sourceBoundMedia = + teamsInlineImage || + telegramMedia || + input.endpoint.provider === "imessage-photon"; + const requireCurrentAttachmentAuthorization = + input.endpoint.provider === "github" || sourceBoundMedia; + try { + if (teamsInlineImage && teamsInlineBatchSignal?.aborted) { + omit("download_unavailable"); + continue; + } + // GitHub's anonymized upload URLs carry no trustworthy MIME metadata. + // Resolve public bytes only here, after durable comment admission, and + // then apply the same storage/type policy as every native attachment. + if (input.endpoint.provider === "github") { + await withCurrentAttachmentAuthorization(async () => {}); + attachment = await prepareGitHubPublicAttachment( + attachment, + githubBatchSignal, + async (request, signal) => { + await withCurrentAttachmentAuthorization(async () => {}); + return ( + (await input.endpointRuntime.resolveGitHubAttachmentComment?.( + request, + signal, + )) ?? null + ); + }, + ); + } + if ( + requireCurrentAttachmentAuthorization && + input.endpoint.provider !== "github" + ) + await withCurrentAttachmentAuthorization( + async () => {}, + sourceBoundMedia, + telegramMedia ? attachment : undefined, + ); + if ( + attachment.size !== undefined && + attachment.size > MAX_ATTACHMENT_BYTES + ) { + omit("declared_too_large"); + continue; + } + if (!attachment.fetchData) { + omit("download_unavailable"); + continue; + } + const originalFilename = sanitizeFilename(attachment.name); + let contentType = normalizeUploadAttachmentContentType({ + contentType: normalizeContentType( + attachment.mimeType ?? "application/octet-stream", + ), + originalFilename, + isAllowedContentType, + }); + // Reject a provider-declared type before allocating the downloaded + // payload. The actual byte length is checked again after the adapter's + // bounded fetch contract resolves. + // A filename may infer an Office MIME for ordinary documents, but it + // must not bypass byte identification for native media without MIME. + const identifyTelegram = + telegramMedia && + telegramMediaNeedsIdentification(attachment) && + normalizeContentType( + attachment.mimeType ?? "application/octet-stream", + ) === "application/octet-stream"; + if (!isAllowedContentType(contentType) && !identifyTelegram) { + omit("unsupported_type"); + continue; + } + const fetched = teamsInlineImage + ? await input.endpointRuntime.fetchTeamsInlineImage( + attachment, + teamsInlineBatchSignal!, + ) + : await attachment.fetchData(); + const body = Buffer.isBuffer(fetched) ? fetched : Buffer.from(fetched); + if (body.length === 0) { + omit("empty_download"); + continue; + } + if (body.length > MAX_ATTACHMENT_BYTES) { + omit("downloaded_too_large"); + continue; + } + if (identifyTelegram) { + contentType = + identifyTelegramMedia(attachment, body) ?? + "application/octet-stream"; + if (!isAllowedContentType(contentType)) { + omit("unsupported_type"); + continue; + } + } + const photonSourceHash = createHash("sha256") + .update(body) + .digest("hex"); + if (input.endpoint.provider === "imessage-photon") { + await validatePhotonImage(body, contentType); + const companion = takePhotonCompanion(body); + if (companion?.unavailable) omit("companion_unavailable"); + else if (companion) { + const video: Attachment = { type: "video", name: companion.fileName, mimeType: companion.mimeType, size: companion.data.length, fetchData: async () => companion.data }; + photonDerivatives.set(video, { source: attachment, sourceHash: photonSourceHash, kind: "live_photo_video" }); + boundedAttachments.push(video); + } + if ( + HEIF_CONTENT_TYPES.has(contentType) && + isAllowedContentType("image/jpeg") + ) { + try { + const preview = await photonHeifPreview(body); + const derivative: Attachment = { + type: "image", + name: `${originalFilename} (JPEG preview).jpg`, + mimeType: "image/jpeg", + size: preview.length, + fetchData: async () => preview, + }; + photonDerivatives.set(derivative, { source: attachment, sourceHash: photonSourceHash, kind: "heif_jpeg_preview" }); + boundedAttachments.push(derivative); + } catch { + omit("preview_unavailable"); + } + } + } + const fingerprint = JSON.stringify([ + photonSourceHash, + body.length, + contentType, + originalFilename, + ]); + const existingIds = existingByFingerprint.get(fingerprint); + const existingId = existingIds?.shift(); + if (requireCurrentAttachmentAuthorization) { + // Revoke during either API resolution or the anonymous byte download + // must prevent storage and attachment registration for that input. + await withCurrentAttachmentAuthorization( + async () => {}, + sourceBoundMedia, + telegramMedia ? attachment : undefined, + ); + } + const registerPhotonProvenance = async (attachmentId: string) => { + if (input.endpoint.provider !== "imessage-photon") return; + const relation = photonDerivatives.get(attachment); + if (!relation) { + originalPhotonAttachments.set(attachment, attachmentId); + return; + } + const originalAttachmentId = + originalPhotonAttachments.get(relation.source); + if (!originalAttachmentId) + throw new Error("Photon related attachment source is missing"); + await db + .insert(chatActions) + .values({ + companyId: input.endpoint.companyId, + endpointId: input.endpoint.id, + deliveryId: input.deliveryId, + kind: relation.kind === "heif_jpeg_preview" ? "attachment_derivative" : "attachment_companion", + providerActionId: `photon-${relation.kind}:${input.deliveryId}:${originalAttachmentId}`, + payload: { + originalAttachmentId, + derivativeAttachmentId: attachmentId, + originalSha256: relation.sourceHash, + derivativeSha256: photonSourceHash, + kind: relation.kind, + }, + status: "processed", + }) + .onConflictDoNothing(); + }; + if (existingId) { + storedIds.push(existingId); + await registerPhotonProvenance(existingId); + continue; + } + const stored = await options.storage.putFile({ + companyId: input.endpoint.companyId, + namespace: `issues/${input.issueId}`, + originalFilename, + contentType, + body, + }); + const registration = { + issueId: input.issueId, + issueCommentId: input.issueCommentId, + provider: stored.provider, + objectKey: stored.objectKey, + contentType: stored.contentType, + byteSize: stored.byteSize, + sha256: stored.sha256, + originalFilename: stored.originalFilename, + createdByUserId: input.actorUserId, + }; + let row; + try { + row = requireCurrentAttachmentAuthorization + ? await withCurrentAttachmentAuthorization( + (tx) => + issueService(tx as unknown as Db).createAttachment( + registration, + ), + sourceBoundMedia, + telegramMedia ? attachment : undefined, + ) + : await issuesSvc.createAttachment(registration); + } catch (error) { + // An explicit authorization denial happens before registration. Do + // not delete on ambiguous database errors which may have committed. + if ( + requireCurrentAttachmentAuthorization && + isExternalActionAuthorizationChange(error) + ) + await options.storage + .deleteObject(input.endpoint.companyId, stored.objectKey) + .catch(() => undefined); + throw error; + } + storedIds.push(row.id); + await registerPhotonProvenance(row.id); + } catch (error) { + if (isExternalActionAuthorizationChange(error)) throw error; + if ( + error instanceof PhotonError && + ["attachment_not_ready", "network", "quota", "credentials"].includes( + error.code, + ) + ) + throw error; + // Use the closed current-input omission vocabulary consumed by native + // prompts; provider-specific diagnostics remain redacted log codes. + omit( + error instanceof GitHubAttachmentUnavailableError + ? "download_unavailable" + : "processing_failed", + ); + // A malformed or unavailable provider attachment must not strand the + // durable text delivery. The rejected file is intentionally omitted; + // the delivery remains auditable through its normalized attachment + // metadata without persisting provider credentials or download URLs. + logger.warn( + { + endpointId: input.endpoint.id, + issueId: input.issueId, + ...(error instanceof GitHubAttachmentUnavailableError + ? { + deliveryId: input.deliveryId, + attachmentDiagnosticCode: error.code, + } + : {}), + attachmentName: sanitizeFilename(attachment.name), + error: redactError(error), + }, + "external chat attachment was rejected", + ); + } + } + return { storedIds, omissionReasons }; + } + + function attachmentOmissionDetail(result: { + omissionReasons: Record; + }): string | null { + const entries = Object.entries(result.omissionReasons).filter( + ([, count]) => count > 0, + ); + const omitted = entries.reduce((total, [, count]) => total + count, 0); + if (!omitted) return null; + const reasons = entries + .map(([reason, count]) => `${reason.replaceAll("_", " ")}: ${count}`) + .join(", "); + return `${omitted} external attachment${omitted === 1 ? " was" : "s were"} omitted (${reasons})`; + } + return { ingestAttachments, attachmentOmissionDetail }; +} diff --git a/server/src/services/chat-channels/microsoft-teams/file-transfer-projection.ts b/server/src/services/chat-channels/microsoft-teams/file-transfer-projection.ts new file mode 100644 index 00000000000..34365a46184 --- /dev/null +++ b/server/src/services/chat-channels/microsoft-teams/file-transfer-projection.ts @@ -0,0 +1,18 @@ +import { sql } from "drizzle-orm"; +import { chatTeamsFileTransfers } from "@paperclipai/db"; + +export const fileTransferProjectionColumns = { + publicationId: chatTeamsFileTransfers.publicationId, + phase: chatTeamsFileTransfers.phase, + version: chatTeamsFileTransfers.version, + filename: chatTeamsFileTransfers.filename, + expiresAt: chatTeamsFileTransfers.expiresAt, + consentMessageId: chatTeamsFileTransfers.consentMessageId, + responseActivityId: chatTeamsFileTransfers.responseActivityId, + fileInfoMessageId: chatTeamsFileTransfers.fileInfoMessageId, + operatorConfirmed: sql` + coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,schema}', '') = 'paperclip.teams.file-resolution.v1' + and coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,action}', '') = 'mark_delivered' + and coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,fromPhase}', '') = 'file_info_unknown' + `, +}; diff --git a/server/src/services/chat-channels/outbound-attachments.ts b/server/src/services/chat-channels/outbound-attachments.ts new file mode 100644 index 00000000000..0f2e3a8b02e --- /dev/null +++ b/server/src/services/chat-channels/outbound-attachments.ts @@ -0,0 +1,294 @@ +import { + createHash, +} from "node:crypto"; +import { + and, + eq, + inArray, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + assets, + chatPublications, + issueAttachments, +} from "@paperclipai/db"; +import type { + SafeChatPublicationPayload, +} from "@paperclipai/shared"; + +import { + isAllowedContentType, + MAX_ATTACHMENT_BYTES, +} from "../../attachment-types.js"; + +import type { StorageService } from "../../storage/types.js"; + +import { + isTeamsInlineImageContentType, + TEAMS_INLINE_IMAGE_MAX_BYTES, +} from "../chat-teams-inline-images.js"; + +import type { + FileUpload, +} from "chat"; + +const OUTBOUND_ATTACHMENT_STORAGE_TIMEOUT_MS = 10_000; + +class OutboundAttachmentHydrationError extends Error { + readonly code = "CHAT_ATTACHMENT_HYDRATION_FAILED"; + + constructor(message: string, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "OutboundAttachmentHydrationError"; + } +} + +class OutboundAttachmentValidationError extends Error { + readonly code = "CHAT_ATTACHMENT_VALIDATION_FAILED"; + + constructor(message: string) { + super(message); + this.name = "OutboundAttachmentValidationError"; + } +} + +function isOutboundAttachmentHydrationError( + error: unknown, +): error is OutboundAttachmentHydrationError { + return error instanceof OutboundAttachmentHydrationError; +} + +function isOutboundAttachmentValidationError( + error: unknown, +): error is OutboundAttachmentValidationError { + return error instanceof OutboundAttachmentValidationError; +} + +/** + * Read one already-authorized attachment before any provider transport starts. + * The database digest, rather than mutable object-store metadata, is the + * integrity boundary. Both acquisition and streaming are bounded so a stuck + * storage backend cannot hold the publication lane indefinitely. + */ +export async function hydrateOutboundAttachment(input: { + storage: StorageService; + companyId: string; + objectKey: string; + byteSize: number; + sha256: string; + filename: string; + mimeType: string; + timeoutMs?: number; +}): Promise { + const timeoutMs = + typeof input.timeoutMs === "number" && + Number.isFinite(input.timeoutMs) && + input.timeoutMs > 0 + ? input.timeoutMs + : OUTBOUND_ATTACHMENT_STORAGE_TIMEOUT_MS; + if ( + !Number.isSafeInteger(input.byteSize) || + input.byteSize <= 0 || + input.byteSize > MAX_ATTACHMENT_BYTES || + !/^[0-9a-f]{64}$/iu.test(input.sha256) + ) { + throw new OutboundAttachmentHydrationError( + "Chat publication attachment metadata is invalid", + ); + } + const acquisitionTimeoutError = new OutboundAttachmentHydrationError( + "Chat publication attachment storage read timed out", + ); + let acquisitionTimedOut = false; + let rejectAcquisition!: (error: Error) => void; + const acquisitionTimeout = new Promise((_resolve, reject) => { + rejectAcquisition = reject; + }); + const acquisitionTimer = setTimeout(() => { + acquisitionTimedOut = true; + rejectAcquisition(acquisitionTimeoutError); + }, timeoutMs); + acquisitionTimer.unref?.(); + const objectPromise = Promise.resolve() + .then(() => input.storage.getObject(input.companyId, input.objectKey)) + .then((object) => { + if (acquisitionTimedOut) object.stream.destroy(); + return object; + }) + .catch((error) => { + throw isOutboundAttachmentHydrationError(error) + ? error + : new OutboundAttachmentHydrationError( + "Chat publication attachment storage read failed", + error, + ); + }); + let object: Awaited>; + try { + object = await Promise.race([objectPromise, acquisitionTimeout]); + } finally { + clearTimeout(acquisitionTimer); + } + + if ( + object.contentLength !== undefined && + object.contentLength !== input.byteSize + ) { + object.stream.destroy(); + throw new OutboundAttachmentHydrationError( + "Chat publication attachment size changed after registration", + ); + } + + const streamTimeoutError = new OutboundAttachmentHydrationError( + "Chat publication attachment storage read timed out", + ); + const streamTimer = setTimeout(() => { + object.stream.destroy(streamTimeoutError); + }, timeoutMs); + streamTimer.unref?.(); + const chunks: Buffer[] = []; + const digest = createHash("sha256"); + let bytes = 0; + try { + for await (const chunk of object.stream) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.length; + if (bytes > input.byteSize || bytes > MAX_ATTACHMENT_BYTES) { + object.stream.destroy(); + throw new OutboundAttachmentHydrationError( + "Chat publication attachment exceeded its streaming byte limit", + ); + } + digest.update(buffer); + chunks.push(buffer); + } + } catch (error) { + throw isOutboundAttachmentHydrationError(error) + ? error + : new OutboundAttachmentHydrationError( + "Chat publication attachment storage read failed", + error, + ); + } finally { + clearTimeout(streamTimer); + } + + const actualDigest = digest.digest("hex"); + if (bytes !== input.byteSize || actualDigest !== input.sha256.toLowerCase()) { + if (!object.stream.destroyed) object.stream.destroy(); + throw new OutboundAttachmentHydrationError( + "Chat publication attachment integrity changed after registration", + ); + } + + return { + data: Buffer.concat(chunks, bytes), + filename: input.filename, + mimeType: input.mimeType, + }; +} +export { isOutboundAttachmentHydrationError, isOutboundAttachmentValidationError }; + +type OutboundAttachmentsOptions = { storage?: StorageService; }; + +export function createOutboundAttachments(db: Db, options: OutboundAttachmentsOptions) { + + + + async function publicationFiles( + publication: typeof chatPublications.$inferSelect, + payload: SafeChatPublicationPayload, + preparation: { teamsInlineImagesOnly?: boolean } = {}, + ): Promise { + if (!payload.attachmentIds?.length) return []; + try { + const rows = await db + .select({ + id: issueAttachments.id, + issueId: issueAttachments.issueId, + issueCommentId: issueAttachments.issueCommentId, + objectKey: assets.objectKey, + contentType: assets.contentType, + byteSize: assets.byteSize, + sha256: assets.sha256, + originalFilename: assets.originalFilename, + }) + .from(issueAttachments) + .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) + .where( + and( + eq(issueAttachments.companyId, publication.companyId), + eq(issueAttachments.issueId, publication.issueId), + inArray(issueAttachments.id, payload.attachmentIds), + ), + ); + const byId = new Map(rows.map((row) => [row.id, row])); + const orderedRows = payload.attachmentIds.map((attachmentId) => { + const row = byId.get(attachmentId); + if ( + !row || + (publication.commentId && + row.issueCommentId !== publication.commentId) || + row.byteSize <= 0 || + row.byteSize > MAX_ATTACHMENT_BYTES || + !row.objectKey.trim() || + !/^[0-9a-f]{64}$/iu.test(row.sha256) || + !isAllowedContentType(row.contentType) + ) + throw new OutboundAttachmentValidationError( + "Chat publication attachment is invalid or outside its authorized task comment", + ); + return row; + }); + // Picture messages have a different Teams contract from arbitrary files. + // Preserve the normal scope/metadata checks above, but never hydrate a + // document or oversized image just to discover it needs a task link. + if ( + preparation.teamsInlineImagesOnly && + orderedRows.some( + (row) => + row.byteSize > TEAMS_INLINE_IMAGE_MAX_BYTES || + !isTeamsInlineImageContentType(row.contentType), + ) + ) { + return []; + } + // Authorization and persisted metadata must fail definitively even when + // storage is unavailable; do not mask an invalid file as a transient outage. + if (!options.storage) + throw new OutboundAttachmentHydrationError( + "Attachment storage is unavailable for chat publication", + ); + const files: FileUpload[] = []; + for (const row of orderedRows) { + files.push( + await hydrateOutboundAttachment({ + storage: options.storage, + companyId: publication.companyId, + objectKey: row.objectKey, + byteSize: row.byteSize, + sha256: row.sha256, + filename: row.originalFilename ?? `attachment-${row.id}`, + mimeType: row.contentType, + }), + ); + } + return files; + } catch (error) { + if ( + isOutboundAttachmentHydrationError(error) || + isOutboundAttachmentValidationError(error) + ) { + throw error; + } + throw new OutboundAttachmentHydrationError( + "Chat publication attachment preparation failed", + error, + ); + } + } + return { publicationFiles }; +} diff --git a/server/src/services/chat-channels/provider-effects.ts b/server/src/services/chat-channels/provider-effects.ts new file mode 100644 index 00000000000..5ca3b4b1786 --- /dev/null +++ b/server/src/services/chat-channels/provider-effects.ts @@ -0,0 +1,957 @@ +import { createTelegramCallbackNoticeAuthority } from "./telegram/callback-notices.js"; + +import { slackThreadChannelId } from "./slack/messages.js"; + +import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "./runtime-types.js"; + +import { baseTeamsConversationId } from "./microsoft-teams/resources.js"; + +import { dueChatAction, PROVIDER_EFFECT_STALE_MS } from "../chat-action-work.js"; + +import { notifyChatDeliveryWork, notifyChatActionWork } from "../chat-work-notifications.js"; + +import type { ChatEndpointRuntime } from "../voice/voice-runtime.js"; + +import { chatCredentialMutationLease } from "../chat-credential-mutation-lease.js"; + +import { projectSafeChatPublicationText } from "../chat-publication-projection.js"; + +import { PhotonChatAdapter } from "../photon/adapter.js"; + +import { + and, + asc, + eq, + inArray, + lte, + sql, +} from "drizzle-orm"; + +import { + teamsConversationId, +} from "../chat-control-chronology.js"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatDeliveries, + chatEndpointResources, + chatEndpoints, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatProvider, +} from "@paperclipai/shared"; + +import { + parseTelegramCallbackReceipt, + telegramCallbackThreadId, + TELEGRAM_PRIVATE_ACTION_UNAVAILABLE, + type TelegramCallbackReceipt, +} from "../chat-telegram-ephemeral.js"; + +import { logger } from "../../middleware/logger.js"; +import { redactSensitiveText } from "../../redaction.js"; + +import { classifyChatPublicationError } from "../chat-publication-errors.js"; + +import type { + Thread, +} from "chat"; + +import type { EndpointRecord } from "./runtime-types.js"; + +type ProviderEffectTarget = { + post: Thread["post"]; + postEphemeral?: Thread["postEphemeral"]; +}; +type ProviderEffectPayload = { + version: 1; + effect: "thread_message" | "ephemeral_message" | "telegram_callback_notice"; + telegramCallback?: TelegramCallbackReceipt; + telegramAuthorizationSha256?: string; + authorizationMode?: "principal" | "safe_notice"; + threadId: string; + userId?: string; + text: string; + fallbackText?: string; + settleDelivery: boolean; + completeConversationId?: string; + resourceId?: string; + runtimeGeneration: number; + credentialFingerprint: string; +}; + +function providerEffectPayload( + payload: Record, +): ProviderEffectPayload | null { + if ( + payload.version !== 1 || + (payload.effect !== "thread_message" && + payload.effect !== "ephemeral_message" && + payload.effect !== "telegram_callback_notice") || + typeof payload.threadId !== "string" || + !payload.threadId || + typeof payload.text !== "string" || + !payload.text || + typeof payload.settleDelivery !== "boolean" || + typeof payload.runtimeGeneration !== "number" || + !Number.isSafeInteger(payload.runtimeGeneration) || + typeof payload.credentialFingerprint !== "string" + ) { + return null; + } + if ( + payload.effect === "ephemeral_message" && + (typeof payload.userId !== "string" || !payload.userId) + ) { + return null; + } + for (const key of [ + "authorizationMode", + "userId", + "fallbackText", + "completeConversationId", + "resourceId", + ] as const) { + const value = payload[key]; + if (value !== undefined && (typeof value !== "string" || !value)) { + return null; + } + } + if ( + payload.authorizationMode !== undefined && + payload.authorizationMode !== "principal" && + payload.authorizationMode !== "safe_notice" + ) { + return null; + } + if (payload.effect === "telegram_callback_notice") { + const receipt = parseTelegramCallbackReceipt(payload.telegramCallback); + if ( + !receipt || + payload.authorizationMode !== "safe_notice" || + payload.text !== TELEGRAM_PRIVATE_ACTION_UNAVAILABLE || + payload.threadId !== telegramCallbackThreadId(receipt) || + payload.userId !== receipt.receiverUserId || + payload.settleDelivery !== false || + payload.fallbackText !== undefined || + payload.completeConversationId !== undefined || + typeof payload.telegramAuthorizationSha256 !== "string" || + !/^[a-f0-9]{64}$/.test(payload.telegramAuthorizationSha256) + ) + return null; + } else if ( + payload.telegramCallback !== undefined || + payload.telegramAuthorizationSha256 !== undefined + ) + return null; + return payload as ProviderEffectPayload; +} +export type { ProviderEffectTarget, ProviderEffectPayload }; +export { providerEffectPayload }; + +type ProviderEffectsOptions = { + endpointRecord: (endpointId: string) => Promise; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + withCredentialMutationLease: ReturnType; + runtimeFor: (endpoint: EndpointRow) => Promise; + invalidateRuntime: (endpointId: string) => Promise; + lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean }>; + redactError: (error: unknown) => string; + MAX_ERROR_TEXT: number; + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + lockTelegramCallbackNoticeAuthority: ReturnType["lockTelegramCallbackNoticeAuthority"]; +}; + +export function createProviderEffects(db: Db, options: ProviderEffectsOptions) { + const { endpointRecord, runtimeCallbackEndpoint, withCredentialMutationLease, runtimeFor, invalidateRuntime, lockCurrentPrincipalAuthorization, redactError, MAX_ERROR_TEXT, nonDirectDestinationAllowed, lockTelegramCallbackNoticeAuthority } = options; + + + async function stageProviderEffect( + database: DbOrTransaction, + input: { + endpoint: EndpointRow; + deliveryId?: string | null; + conversationId?: string | null; + principalId?: string | null; + providerActionId: string; + payload: Omit< + ProviderEffectPayload, + "runtimeGeneration" | "credentialFingerprint" + >; + runtimeContext: LifecycleRuntimeFence; + }, + ) { + await notifyChatActionWork(database, "provider_effect"); + const [inserted] = await database + .insert(chatActions) + .values({ + companyId: input.endpoint.companyId, + endpointId: input.endpoint.id, + deliveryId: input.deliveryId ?? null, + conversationId: input.conversationId ?? null, + principalId: input.principalId ?? null, + kind: "provider_effect", + providerActionId: input.providerActionId, + payload: { + ...input.payload, + runtimeGeneration: input.runtimeContext.generation, + credentialFingerprint: input.runtimeContext.credentialFingerprint, + }, + status: "received", + }) + .onConflictDoNothing() + .returning(); + if (inserted) return inserted; + return database + .select() + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, input.endpoint.id), + eq(chatActions.providerActionId, input.providerActionId), + eq(chatActions.kind, "provider_effect"), + ), + ) + .then((rows) => rows[0] ?? null); + } + + function providerEffectDisposition( + error: unknown, + attempt: number, + providerEffectCompleted: boolean, + ) { + if (!providerEffectCompleted) + return classifyChatPublicationError(error, attempt); + return { + kind: "delivery_unknown" as const, + reason: + "Provider effect completed, but Paperclip could not confirm its durable result", + }; + } + + async function finalizeProviderEffectFailure(input: { + action: typeof chatActions.$inferSelect; + endpoint: EndpointRow; + payload: ProviderEffectPayload; + disposition: ReturnType; + attempt: number; + }): Promise { + const failure = redactSensitiveText(input.disposition.reason).slice( + 0, + MAX_ERROR_TEXT, + ); + const retryable = input.disposition.kind === "retry"; + const retryAt = + input.disposition.kind === "retry" + ? new Date(Date.now() + input.disposition.retryAfterMs) + : null; + let invalidateCurrentRuntime = false; + const finalized = await db.transaction(async (tx) => { + // Admission locks the endpoint before its delivery. Keep that order + // when failure settlement also needs to change endpoint/resource health. + const currentEndpoint = + input.disposition.kind === "endpoint_attention" || + input.disposition.kind === "resource_unavailable" + ? await runtimeCallbackEndpoint( + tx, + input.endpoint.id, + { + generation: input.payload.runtimeGeneration, + credentialFingerprint: input.payload.credentialFingerprint, + }, + ["verifying", "active"], + ) + : null; + await notifyChatActionWork(tx, "provider_effect"); + const [ownedAction] = await tx + .update(chatActions) + .set({ + status: + input.disposition.kind === "delivery_unknown" + ? "delivery_unknown" + : "failed", + result: { + code: `provider_effect_${input.disposition.kind}`, + attempts: input.attempt, + redactedError: failure, + retryable, + retryAt: retryAt?.toISOString() ?? null, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, input.action.id), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${input.attempt}`, + ), + ) + .returning({ id: chatActions.id }); + if (!ownedAction) return false; + if (input.payload.settleDelivery && input.action.deliveryId) { + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: retryable ? "retry" : "failed", + nextAttemptAt: retryAt, + redactedError: + input.disposition.kind === "delivery_unknown" + ? `Provider response is unconfirmed: ${failure}`.slice( + 0, + MAX_ERROR_TEXT, + ) + : failure, + updatedAt: new Date(), + }) + .where( + and( + eq(chatDeliveries.id, input.action.deliveryId), + eq(chatDeliveries.state, "processing"), + ), + ); + } + if (input.disposition.kind === "endpoint_attention" && currentEndpoint) { + await tx + .update(chatEndpoints) + .set({ + status: "attention", + healthMessage: "Provider credentials or permissions need attention", + lastError: failure, + updatedAt: new Date(), + }) + .where(eq(chatEndpoints.id, currentEndpoint.id)); + await tx + .update(toolConnections) + .set({ + status: "disabled", + enabled: false, + healthStatus: "degraded", + healthMessage: "Provider credentials or permissions need attention", + lastError: failure, + healthCheckedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(toolConnections.id, currentEndpoint.connectionId)); + invalidateCurrentRuntime = true; + } else if ( + input.disposition.kind === "resource_unavailable" && + input.payload.resourceId && + currentEndpoint + ) { + await tx + .update(chatEndpointResources) + .set({ availability: "unavailable", updatedAt: new Date() }) + .where( + and( + eq(chatEndpointResources.id, input.payload.resourceId), + eq(chatEndpointResources.endpointId, currentEndpoint.id), + ), + ); + } + return true; + }); + if (finalized && invalidateCurrentRuntime) { + await invalidateRuntime(input.endpoint.id).catch(() => undefined); + } + return finalized; + } + + async function quarantineStaleProviderEffect( + action: typeof chatActions.$inferSelect, + ) { + const payload = providerEffectPayload(action.payload); + const failure = + "Provider delivery could not be confirmed after the worker stopped. The effect will not be replayed automatically."; + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "provider_effect"); + const [quarantined] = await tx + .update(chatActions) + .set({ + status: "delivery_unknown", + result: { + code: "provider_effect_delivery_unknown", + redactedError: failure, + retryable: false, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + eq(chatActions.updatedAt, action.updatedAt), + sql`(${chatActions.result}->>'attempts')::int = ${ + typeof action.result?.attempts === "number" + ? action.result.attempts + : -1 + }`, + lte( + chatActions.updatedAt, + new Date(Date.now() - PROVIDER_EFFECT_STALE_MS), + ), + ), + ) + .returning({ id: chatActions.id }); + if (quarantined && payload?.settleDelivery && action.deliveryId) { + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: "failed", + nextAttemptAt: null, + redactedError: failure, + updatedAt: new Date(), + }) + .where( + and( + eq(chatDeliveries.id, action.deliveryId), + eq(chatDeliveries.state, "processing"), + ), + ); + } + }); + } + + function providerEffectThreadResourceId( + provider: ChatProvider, + threadId: string, + ): string | null { + if (provider === "slack") { + return ( + slackThreadChannelId(threadId) ?? + (!threadId.includes(":") ? threadId : null) + ); + } + if (provider === "telegram") { + return /^telegram:([^:]+)/.exec(threadId)?.[1] ?? null; + } + if (provider === "github") { + return /^github:([^:]+)/.exec(threadId)?.[1]?.toLowerCase() ?? null; + } + if (provider === "discord") { + return /^discord:[^:]+:([^:]+)/.exec(threadId)?.[1] ?? null; + } + const conversationId = teamsConversationId(threadId); + return conversationId ? baseTeamsConversationId(conversationId) : null; + } + + async function lockCurrentProviderEffectAuthorization( + tx: DbTransaction, + action: typeof chatActions.$inferSelect, + endpoint: EndpointRow, + payload: ProviderEffectPayload, + ): Promise { + if (payload.effect === "telegram_callback_notice") { + const receipt = parseTelegramCallbackReceipt(payload.telegramCallback); + if (!receipt || !action.deliveryId) return false; + const delivery = await tx + .select() + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.id, action.deliveryId), + eq(chatDeliveries.companyId, action.companyId), + eq(chatDeliveries.endpointId, action.endpointId), + eq(chatDeliveries.state, "filtered"), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const retained = + delivery && + parseTelegramCallbackReceipt(delivery.normalizedEvent.telegramCallback); + if ( + !delivery || + delivery.principalId !== action.principalId || + retained?.sourceSha256 !== receipt.sourceSha256 || + delivery.normalizedEvent.telegramAuthorizationSha256 !== + payload.telegramAuthorizationSha256 + ) + return false; + return ( + (await lockTelegramCallbackNoticeAuthority( + tx, + endpoint, + receipt, + action.principalId, + )) === payload.telegramAuthorizationSha256 + ); + } + const conversation = action.conversationId + ? await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, action.conversationId), + inArray(chatConversations.state, ["active", "waiting"]), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + if (action.conversationId && !conversation) return false; + const inferredResourceId = providerEffectThreadResourceId( + endpoint.provider, + payload.threadId, + ); + const resource = await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + payload.resourceId || conversation?.resourceId + ? eq( + chatEndpointResources.id, + payload.resourceId ?? conversation!.resourceId!, + ) + : inferredResourceId + ? eq(chatEndpointResources.providerResourceId, inferredResourceId) + : sql`false`, + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const slackChannelId = + endpoint.provider === "slack" + ? slackThreadChannelId(payload.threadId) + : null; + const isDirectMessage = + conversation?.isDirectMessage === true || + resource?.type === "direct_message" || + Boolean(slackChannelId && /^D[A-Z0-9]+$/i.test(slackChannelId)); + const destinationAllowed = isDirectMessage + ? endpoint.allowDirectMessages + : nonDirectDestinationAllowed(endpoint, resource); + if (payload.authorizationMode === "safe_notice") return true; + if (!destinationAllowed) return false; + if (!action.principalId) return false; + return ( + await lockCurrentPrincipalAuthorization(tx, endpoint, action.principalId) + ).allowed; + } + + async function cancelUnauthorizedProviderEffect( + tx: DbTransaction, + action: typeof chatActions.$inferSelect, + attempt: number, + ): Promise { + await notifyChatActionWork(tx, "provider_effect"); + const [cancelled] = await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + attempts: attempt, + code: "provider_effect_no_longer_authorized", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, + ), + ) + .returning({ id: chatActions.id }); + if (!cancelled) return false; + if (action.deliveryId) { + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: "filtered", + processedAt: new Date(), + nextAttemptAt: null, + redactedError: + "Provider reply was suppressed because current chat authorization changed", + updatedAt: new Date(), + }) + .where( + and( + eq(chatDeliveries.id, action.deliveryId), + eq(chatDeliveries.state, "processing"), + ), + ); + } + return true; + } + + async function processProviderEffect( + actionId: string, + liveTarget?: ProviderEffectTarget, + ): Promise<"processed" | "pending" | "failed" | "delivery_unknown"> { + const initial = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.id, actionId), + eq(chatActions.kind, "provider_effect"), + ), + ) + .then((rows) => rows[0] ?? null); + if (!initial) return "failed"; + const initialEndpoint = await endpointRecord(initial.endpointId); + if (!initialEndpoint) return "failed"; + + const processClaimedEffect = async () => { + let action = await db + .select() + .from(chatActions) + .where(eq(chatActions.id, actionId)) + .then((rows) => rows[0] ?? null); + if (!action) return "failed"; + const payload = providerEffectPayload(action.payload); + if (!payload) { + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "provider_effect"); + return tx + .update(chatActions) + .set({ + status: "failed", + result: { code: "provider_effect_payload_invalid" }, + updatedAt: new Date(), + }) + .where(eq(chatActions.id, action.id)); + }); + return "failed"; + } + if (action.status === "processed") return "processed"; + if (action.status === "delivery_unknown") return "delivery_unknown"; + if (action.status === "processing") { + if (action.updatedAt > new Date(Date.now() - PROVIDER_EFFECT_STALE_MS)) + return "pending"; + await quarantineStaleProviderEffect(action); + return "delivery_unknown"; + } + if (action.status === "failed") { + if (action.result?.retryable !== true) return "failed"; + const retryAt = + typeof action.result.retryAt === "string" + ? new Date(action.result.retryAt) + : null; + if (retryAt && retryAt > new Date()) return "pending"; + } else if (action.status !== "received") { + return "failed"; + } + + const attempt = + (typeof action.result?.attempts === "number" + ? action.result.attempts + : 0) + 1; + const record = await endpointRecord(action.endpointId); + let providerEffectCompleted = false; + try { + if (!record) { + throw Object.assign( + new Error("Chat endpoint is unavailable for this provider effect"), + { code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" }, + ); + } + return await withCredentialMutationLease( + record.endpoint, + async (credentialLease) => { + const claim = await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const currentAction = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.id, action!.id), + eq(chatActions.kind, "provider_effect"), + eq(chatActions.status, action!.status), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if (!currentAction) return null; + const currentEndpoint = await runtimeCallbackEndpoint( + tx, + currentAction.endpointId, + { + generation: payload.runtimeGeneration, + credentialFingerprint: payload.credentialFingerprint, + }, + ["verifying", "active"], + ); + const authorized = currentEndpoint + ? await lockCurrentProviderEffectAuthorization( + tx, + currentAction, + currentEndpoint, + payload, + ) + : false; + await notifyChatActionWork(tx, "provider_effect"); + const [transportClaim] = await tx + .update(chatActions) + .set({ + status: "processing", + result: { attempts: attempt }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, currentAction.id), + eq(chatActions.status, currentAction.status), + ), + ) + .returning(); + if (!transportClaim) return null; + if (!currentEndpoint || !authorized) { + const cancelled = await cancelUnauthorizedProviderEffect( + tx, + transportClaim, + attempt, + ); + if (!cancelled) { + throw new Error( + "Provider effect ownership changed before authorization cancellation", + ); + } + return { outcome: "cancelled" as const }; + } + await credentialLease.assertOwned(tx); + return { + action: transportClaim, + endpoint: currentEndpoint, + outcome: "claimed" as const, + }; + }); + if (!claim) return "pending"; + if (claim.outcome === "cancelled") return "failed"; + action = claim.action; + + // `processing` is the durable, single-owner transport claim and + // authorization linearization point. Its transaction has committed + // before provider I/O begins. The renewable credential lease keeps + // runtime identity stable without holding database row locks. + let sent: { id: string; threadId: string } | null = null; + if (payload.effect === "telegram_callback_notice") { + sent = await ( + await runtimeFor(claim.endpoint) + ).sendTelegramCallbackNotice( + payload.telegramCallback!, + payload.text, + ); + } else { + const target = + liveTarget ?? + (await runtimeFor(claim.endpoint)).thread(payload.threadId); + if (payload.effect === "ephemeral_message") { + if (!target.postEphemeral) { + if (!payload.fallbackText) { + throw Object.assign( + new Error("Provider does not support ephemeral messages"), + { code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" }, + ); + } + sent = await target.post(payload.fallbackText); + } else { + try { + sent = await target.postEphemeral( + payload.userId!, + payload.text, + { fallbackToDM: false }, + ); + } catch (error) { + if ( + !payload.fallbackText || + classifyChatPublicationError(error, attempt).kind !== + "failed" + ) { + throw error; + } + } + if (!sent && payload.fallbackText) { + sent = await target.post(payload.fallbackText); + } + } + } else if (claim.endpoint.provider === "imessage-photon") { + const adapter = (await runtimeFor(claim.endpoint)).getProviderAdapter(); + if (!(adapter instanceof PhotonChatAdapter)) throw new Error("Photon runtime unavailable"); + const receipt = await adapter.publish(payload.threadId, `effect:${action.id}`, projectSafeChatPublicationText(payload.text), { + assertCurrent: async () => { await credentialLease.assertOwned(); }, + }); + sent = { id: receipt.id, threadId: payload.threadId }; + } else { + sent = await target.post(payload.text); + } + } + if (!sent) { + throw Object.assign( + new Error("Provider did not accept the message"), + { code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" }, + ); + } + providerEffectCompleted = true; + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + // A duplicate webhook holds the endpoint while waiting for the + // delivery's unique-index entry. Lock this endpoint first too, + // before settling the action/conversation/delivery, so neither + // transaction can wait on a row already held by the other. + const currentEndpoint = await runtimeCallbackEndpoint( + tx, + claim.endpoint.id, + { + generation: payload.runtimeGeneration, + credentialFingerprint: payload.credentialFingerprint, + }, + ["verifying", "active"], + ); + await notifyChatActionWork(tx, "provider_effect"); + const [completed] = await tx + .update(chatActions) + .set({ + status: "processed", + result: { + attempts: attempt, + providerMessageId: sent.id, + threadId: sent.threadId, + ...(payload.effect === "telegram_callback_notice" + ? { + code: + payload.telegramCallback?.chatType === "private" + ? "telegram_private_api_accepted" + : "telegram_ephemeral_api_accepted", + } + : {}), + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action!.id), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, + ), + ) + .returning({ id: chatActions.id }); + if (!completed) { + throw new Error( + "Provider effect ownership changed before commit", + ); + } + if (payload.completeConversationId) { + await tx + .update(chatConversations) + .set({ state: "completed", updatedAt: new Date() }) + .where( + and( + eq(chatConversations.id, payload.completeConversationId), + eq(chatConversations.endpointId, claim.endpoint.id), + inArray(chatConversations.state, ["active", "waiting"]), + ), + ); + } + if (payload.settleDelivery && action!.deliveryId) { + await notifyChatDeliveryWork(tx); + const [settled] = await tx + .update(chatDeliveries) + .set({ + state: "processed", + processedAt: new Date(), + nextAttemptAt: null, + redactedError: null, + updatedAt: new Date(), + }) + .where( + and( + eq(chatDeliveries.id, action!.deliveryId), + eq(chatDeliveries.state, "processing"), + ), + ) + .returning({ id: chatDeliveries.id }); + if (!settled) { + throw new Error( + "Provider effect delivery ownership changed before commit", + ); + } + } + if (currentEndpoint) { + await tx + .update(chatEndpoints) + .set({ lastEventAt: new Date(), updatedAt: new Date() }) + .where(eq(chatEndpoints.id, currentEndpoint.id)); + } + await credentialLease.assertOwned(tx); + }); + return "processed"; + }, + ); + } catch (error) { + const disposition = providerEffectDisposition( + error, + attempt, + providerEffectCompleted, + ); + try { + await finalizeProviderEffectFailure({ + action, + endpoint: record?.endpoint ?? initialEndpoint.endpoint, + payload, + disposition, + attempt, + }); + } catch (finalizationError) { + if (disposition.kind === "delivery_unknown") { + throw Object.assign( + new Error( + "Provider effect is ambiguous and its durable quarantine failed", + { cause: finalizationError }, + ), + { code: "CHAT_PROVIDER_EFFECT_AMBIGUOUS" }, + ); + } + throw finalizationError; + } + throw error; + } + }; + return processClaimedEffect(); + } + + async function processPendingProviderEffects(limit = 25) { + const now = new Date(); + const staleBefore = new Date(now.getTime() - PROVIDER_EFFECT_STALE_MS); + const actions = await db + .select() + .from(chatActions) + .where(dueChatAction("provider_effect", now.getTime())) + .orderBy(asc(chatActions.createdAt)) + .limit(limit); + for (const action of actions) { + if (action.status === "processing" && action.updatedAt <= staleBefore) { + await quarantineStaleProviderEffect(action); + continue; + } + try { + await processProviderEffect(action.id); + } catch (error) { + logger.warn( + { + endpointId: action.endpointId, + actionId: action.id, + error: redactError(error), + }, + "chat provider effect reconciliation failed", + ); + } + } + return actions.length; + } + return { stageProviderEffect, processProviderEffect, processPendingProviderEffects }; +} diff --git a/server/src/services/chat-channels/reactions.ts b/server/src/services/chat-channels/reactions.ts new file mode 100644 index 00000000000..83df24432e9 --- /dev/null +++ b/server/src/services/chat-channels/reactions.ts @@ -0,0 +1,891 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext } from "./runtime-types.js"; + +import { + stableExternalPrincipalId, +} from "./message-lifecycle.js"; + +import { notifyChatDeliveryWork } from "../chat-work-notifications.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + asc, + desc, + eq, + gt, + gte, + inArray, + isNull, + lt, + lte, + or, + sql, + type SQL, +} from "drizzle-orm"; + +import { + externalThreadIdentityCondition, +} from "../chat-control-chronology.js"; + +import type { Db } from "@paperclipai/db"; + +import { + chatConversations, + chatDeliveries, + chatEndpointResources, + chatMessageLinks, + chatPublications, +} from "@paperclipai/db"; +import type { + ChatEventKind, + ChatProvider, +} from "@paperclipai/shared"; + +import { logger } from "../../middleware/logger.js"; + +import { + type ChatSdkCallbackEvent, +} from "../chat-sdk-runtime.js"; + +import type { + Author, + ReactionEvent, +} from "chat"; + +import type { EndpointRecord } from "./runtime-types.js"; +import { slackThreadChannelId } from "./slack/messages.js"; + +// Reactions can beat the transaction that persists a just-sent provider +// message link. Keep that narrow gap durable, but never retain an unbound +// reaction indefinitely or let it enter the ordinary inbound-message FIFO. +const REACTION_LINK_RETRY_DELAY_MS = 250; +const REACTION_LINK_MAX_DELAY_MS = 5_000; +const REACTION_LINK_MAX_ATTEMPTS = 20; +const REACTION_LINK_MAX_AGE_MS = 2 * 60_000; +type DeliveryRow = typeof chatDeliveries.$inferSelect; +type ConversationRow = typeof chatConversations.$inferSelect; + +type InboundReactionsOptions = { + lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean }>; + runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + ensurePrincipal: (endpoint: EndpointRow, author: Author, raw?: unknown) => Promise<{ principal: { id: string } }>; + durableExternalThreadIdentity: (threadId: string) => string; + recordCurrentMicrosoftTeamsRoute: (endpoint: EndpointRow, context: InboundRuntimeContext | undefined, threadId: string, raw: unknown) => Promise; + lifecycleRuntimeFence: (delivery: DeliveryRow) => LifecycleRuntimeFence | null; + deliveryReady: (delivery: DeliveryRow, now: Date) => boolean; + redactError: (error: unknown) => string; + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + reactionLinkPreflightBarrier?: () => Promise; + reactionReplayEndpointLockBarrier?: () => Promise; + reactionReplayConversationLockBarrier?: () => Promise; +}; + +export function createInboundReactions(db: Db, options: InboundReactionsOptions) { + const { lockCurrentPrincipalAuthorization, runtimeCallbackRecord, runtimeCallbackEndpoint, ensurePrincipal, durableExternalThreadIdentity, recordCurrentMicrosoftTeamsRoute, lifecycleRuntimeFence, deliveryReady, redactError, nonDirectDestinationAllowed } = options; + + + function reactionConversationThreadCondition( + provider: ChatProvider, + threadId: string, + ): SQL | undefined { + const exact = externalThreadIdentityCondition( + sql`${chatConversations.externalThreadId}`, + threadId, + ); + const slackDmChannel = + provider === "slack" ? slackThreadChannelId(threadId) : null; + return slackDmChannel && /^D[A-Z0-9-]*$/i.test(slackDmChannel) + ? or( + exact, + and( + eq(chatConversations.isDirectMessage, true), + eq(chatConversations.externalThreadId, `slack:${slackDmChannel}:`), + ), + ) + : exact; + } + + async function reactionConversationForMessage( + database: DbOrTransaction, + endpoint: EndpointRow, + threadId: string, + providerMessageId: string, + direction?: "outbound", + ): Promise { + return database + .select({ conversation: chatConversations }) + .from(chatMessageLinks) + .innerJoin( + chatConversations, + and( + eq(chatConversations.companyId, chatMessageLinks.companyId), + eq(chatConversations.endpointId, chatMessageLinks.endpointId), + eq(chatConversations.id, chatMessageLinks.conversationId), + ), + ) + .where( + and( + eq(chatMessageLinks.companyId, endpoint.companyId), + eq(chatMessageLinks.endpointId, endpoint.id), + eq(chatMessageLinks.providerMessageId, providerMessageId), + direction ? eq(chatMessageLinks.direction, direction) : undefined, + reactionConversationThreadCondition(endpoint.provider, threadId), + ), + ) + .orderBy(desc(chatConversations.sessionGeneration)) + .limit(1) + .then((rows) => rows[0]?.conversation ?? null); + } + + async function lockReactionConversationForMessage( + tx: DbTransaction, + endpoint: EndpointRow, + threadId: string, + providerMessageId: string, + direction?: "outbound", + ): Promise { + const candidate = await reactionConversationForMessage( + tx, + endpoint, + threadId, + providerMessageId, + direction, + ); + if (!candidate) return null; + const current = await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, candidate.id), + ), + ) + // Publication settlement inserts the outbound message link before it + // updates the endpoint. NO KEY UPDATE serializes state changes while + // remaining compatible with that link's conversation FK KEY SHARE. + .for("no key update") + .then((rows) => rows[0] ?? null); + if ( + !current || + !(current.isDirectMessage + ? ["active", "waiting", "completed"].includes(current.state) + : ["active", "waiting"].includes(current.state)) + ) { + return null; + } + const linkStillExists = await tx + .select({ id: chatMessageLinks.id }) + .from(chatMessageLinks) + .where( + and( + eq(chatMessageLinks.companyId, endpoint.companyId), + eq(chatMessageLinks.endpointId, endpoint.id), + eq(chatMessageLinks.conversationId, current.id), + eq(chatMessageLinks.providerMessageId, providerMessageId), + direction ? eq(chatMessageLinks.direction, direction) : undefined, + ), + ) + .limit(1) + .then((rows) => rows.length > 0); + return linkStillExists ? current : null; + } + + async function streamingReactionConversation( + database: DbOrTransaction, + endpoint: EndpointRow, + threadId: string, + ): Promise { + const candidates = await database + .selectDistinct({ + conversation: chatConversations, + sessionGeneration: chatConversations.sessionGeneration, + }) + .from(chatConversations) + .innerJoin( + chatPublications, + and( + eq(chatPublications.companyId, chatConversations.companyId), + eq(chatPublications.endpointId, chatConversations.endpointId), + eq(chatPublications.conversationId, chatConversations.id), + ), + ) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + or( + inArray(chatConversations.state, ["active", "waiting"]), + and( + eq(chatConversations.isDirectMessage, true), + eq(chatConversations.state, "completed"), + ), + ), + reactionConversationThreadCondition(endpoint.provider, threadId), + eq(chatPublications.state, "streaming"), + ), + ) + .orderBy(desc(chatConversations.sessionGeneration)) + .limit(2); + if (candidates.length !== 1) return null; + return candidates[0]!.conversation; + } + + async function lockStreamingReactionConversation( + tx: DbTransaction, + endpoint: EndpointRow, + threadId: string, + ): Promise { + const candidate = await streamingReactionConversation( + tx, + endpoint, + threadId, + ); + if (!candidate) return null; + const current = await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, candidate.id), + or( + inArray(chatConversations.state, ["active", "waiting"]), + and( + eq(chatConversations.isDirectMessage, true), + eq(chatConversations.state, "completed"), + ), + ), + ), + ) + // Keep the same lock strength/order as the exact-link path above so an + // in-flight message-link insert cannot deadlock reaction admission. + .for("no key update") + .then((rows) => rows[0] ?? null); + if (!current) return null; + const stillStreaming = await tx + .select({ id: chatPublications.id }) + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, endpoint.companyId), + eq(chatPublications.endpointId, endpoint.id), + eq(chatPublications.conversationId, current.id), + eq(chatPublications.state, "streaming"), + ), + ) + .limit(1) + .then((rows) => rows.length > 0); + return stillStreaming ? current : null; + } + + async function authorizeReactionConversation( + tx: DbTransaction, + endpoint: EndpointRow, + conversation: ConversationRow, + principalId: string, + ): Promise { + const resource = + conversation.resourceId && !conversation.isDirectMessage + ? await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.id, conversation.resourceId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const destinationAllowed = conversation.isDirectMessage + ? endpoint.allowDirectMessages + : nonDirectDestinationAllowed(endpoint, resource); + if (!destinationAllowed) return false; + return (await lockCurrentPrincipalAuthorization(tx, endpoint, principalId)) + .allowed; + } + + async function handleReaction( + event: ChatSdkCallbackEvent, + runtimeContext: RuntimeContext, + ) { + const record = await runtimeCallbackRecord( + event.endpointId, + runtimeContext, + ["active"], + ); + if ( + !record || + record.endpoint.status !== "active" || + record.endpoint.provider !== event.provider || + record.endpoint.capabilities.reactions !== true || + event.event.user.isMe || + event.event.user.isBot === true || + event.event.user.isSystem + ) { + return; + } + // A DM can have a newer task generation while this reaction still belongs + // to an older linked message. Resolve its exact lineage before choosing a + // generation; never attach it to the latest task merely sharing the chat. + // A just-sent provider message can be visible before its publication/link + // transaction commits. Only that exact thread's streaming publication is + // enough evidence to stage an otherwise-unbound reaction for replay. + let conversation = await reactionConversationForMessage( + db, + record.endpoint, + event.event.threadId, + event.event.messageId, + ); + await options.reactionLinkPreflightBarrier?.(); + const plausibleStreamingConversation = conversation + ? null + : await streamingReactionConversation( + db, + record.endpoint, + event.event.threadId, + ); + if (!conversation && !plausibleStreamingConversation) { + // Settlement can commit between the first exact-link lookup and the + // streaming-publication lookup. Recheck the durable link before treating + // the callback as an arbitrary unknown message. + conversation = await reactionConversationForMessage( + db, + record.endpoint, + event.event.threadId, + event.event.messageId, + ); + if (!conversation) return; + } + + const principal = await ensurePrincipal( + record.endpoint, + event.event.user, + event.event.raw, + ); + let rawFingerprint: string; + try { + rawFingerprint = createHash("sha256") + .update(JSON.stringify(event.event.raw ?? null)) + .digest("hex") + .slice(0, 24); + } catch { + rawFingerprint = "unavailable"; + } + const eventKind: ChatEventKind = event.event.added + ? "reaction_added" + : "reaction_removed"; + const providerEventId = [ + eventKind, + durableExternalThreadIdentity(event.event.threadId), + event.event.messageId, + stableExternalPrincipalId( + record.endpoint.provider, + event.event.user, + event.event.raw, + ), + event.event.rawEmoji, + rawFingerprint, + ].join(":"); + const normalizedEvent = { + providerEventId, + kind: eventKind, + conversation: { externalThreadId: event.event.threadId }, + message: { providerMessageId: event.event.messageId }, + reaction: { + emoji: event.event.emoji.name, + rawEmoji: event.event.rawEmoji, + added: event.event.added, + }, + runtimeContext: { + generation: runtimeContext.generation, + credentialFingerprint: runtimeContext.credentialFingerprint, + }, + }; + const admitted = await db.transaction(async (tx) => { + const currentEndpoint = await runtimeCallbackEndpoint( + tx, + event.endpointId, + runtimeContext, + ["active"], + ); + if (!currentEndpoint) return false; + const linkedConversation = await lockReactionConversationForMessage( + tx, + currentEndpoint, + event.event.threadId, + event.event.messageId, + ); + const currentConversation = + linkedConversation ?? + (await lockStreamingReactionConversation( + tx, + currentEndpoint, + event.event.threadId, + )); + if ( + !currentConversation || + !(await authorizeReactionConversation( + tx, + currentEndpoint, + currentConversation, + principal.principal.id, + )) + ) { + return false; + } + const now = new Date(); + if (linkedConversation) { + const expiryCutoff = new Date(now.getTime() - REACTION_LINK_MAX_AGE_MS); + const hasOutboundLink = await tx + .select({ id: chatMessageLinks.id }) + .from(chatMessageLinks) + .where( + and( + eq(chatMessageLinks.companyId, currentEndpoint.companyId), + eq(chatMessageLinks.endpointId, currentEndpoint.id), + eq(chatMessageLinks.conversationId, linkedConversation.id), + eq(chatMessageLinks.providerMessageId, event.event.messageId), + eq(chatMessageLinks.direction, "outbound"), + ), + ) + .limit(1) + .then((rows) => rows.length > 0); + await notifyChatDeliveryWork(tx); + const reconciled = hasOutboundLink + ? await tx + .update(chatDeliveries) + .set({ + conversationId: linkedConversation.id, + state: "processed", + attempts: sql`${chatDeliveries.attempts} + 1`, + nextAttemptAt: null, + processedAt: now, + redactedError: null, + updatedAt: now, + }) + .where( + and( + eq(chatDeliveries.endpointId, currentEndpoint.id), + eq(chatDeliveries.providerEventId, providerEventId), + inArray(chatDeliveries.state, ["received", "retry"]), + gt(chatDeliveries.receivedAt, expiryCutoff), + lt(chatDeliveries.attempts, REACTION_LINK_MAX_ATTEMPTS), + sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'generation' = ${String(runtimeContext.generation)}`, + sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'credentialFingerprint' = ${runtimeContext.credentialFingerprint}`, + ), + ) + .returning({ id: chatDeliveries.id }) + : []; + if (reconciled.length > 0) return true; + await notifyChatDeliveryWork(tx); + const expired = await tx + .update(chatDeliveries) + .set({ + state: "filtered", + nextAttemptAt: null, + processedAt: now, + redactedError: + "Reaction target did not become linked before replay expiry", + updatedAt: now, + }) + .where( + and( + eq(chatDeliveries.endpointId, currentEndpoint.id), + eq(chatDeliveries.providerEventId, providerEventId), + inArray(chatDeliveries.state, ["received", "retry"]), + or( + lte(chatDeliveries.receivedAt, expiryCutoff), + gte(chatDeliveries.attempts, REACTION_LINK_MAX_ATTEMPTS), + ), + sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'generation' = ${String(runtimeContext.generation)}`, + sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'credentialFingerprint' = ${runtimeContext.credentialFingerprint}`, + ), + ) + .returning({ id: chatDeliveries.id }); + if (expired.length > 0) return false; + } + await notifyChatDeliveryWork(tx); + const inserted = await tx + .insert(chatDeliveries) + .values({ + companyId: currentEndpoint.companyId, + endpointId: currentEndpoint.id, + conversationId: linkedConversation?.id ?? null, + principalId: principal.principal.id, + providerEventId, + deduplicationKey: createHash("sha256") + .update(providerEventId) + .digest("hex"), + eventKind, + normalizedEvent, + state: linkedConversation ? "processed" : "received", + attempts: linkedConversation ? 1 : 0, + nextAttemptAt: linkedConversation + ? null + : new Date(now.getTime() + REACTION_LINK_RETRY_DELAY_MS), + processedAt: linkedConversation ? now : null, + }) + .onConflictDoNothing() + .returning({ id: chatDeliveries.id }); + if (inserted.length > 0) return true; + return tx + .select({ id: chatDeliveries.id }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.endpointId, currentEndpoint.id), + eq(chatDeliveries.providerEventId, providerEventId), + inArray(chatDeliveries.state, ["received", "retry", "processed"]), + ), + ) + .limit(1) + .then((rows) => rows.length > 0); + }); + if (admitted) { + await recordCurrentMicrosoftTeamsRoute( + record.endpoint, + runtimeContext, + event.event.threadId, + event.event.raw, + ); + } + } + + function normalizedPendingReaction(delivery: DeliveryRow): { + added: boolean; + emoji: string; + messageId: string; + rawEmoji: string; + runtimeContext: LifecycleRuntimeFence; + threadId: string; + } | null { + if ( + delivery.eventKind !== "reaction_added" && + delivery.eventKind !== "reaction_removed" + ) { + return null; + } + const normalized = delivery.normalizedEvent; + if ( + !normalized || + typeof normalized !== "object" || + Array.isArray(normalized) + ) { + return null; + } + const value = normalized as Record; + const conversation = value.conversation; + const message = value.message; + const reaction = value.reaction; + const runtimeContext = lifecycleRuntimeFence(delivery); + if ( + !conversation || + typeof conversation !== "object" || + Array.isArray(conversation) || + !message || + typeof message !== "object" || + Array.isArray(message) || + !reaction || + typeof reaction !== "object" || + Array.isArray(reaction) || + !runtimeContext + ) { + return null; + } + const threadId = (conversation as Record).externalThreadId; + const messageId = (message as Record).providerMessageId; + const reactionRecord = reaction as Record; + const added = reactionRecord.added; + const emoji = reactionRecord.emoji; + const rawEmoji = reactionRecord.rawEmoji; + return typeof threadId === "string" && + threadId.length > 0 && + typeof messageId === "string" && + messageId.length > 0 && + typeof added === "boolean" && + added === (delivery.eventKind === "reaction_added") && + typeof emoji === "string" && + emoji.length > 0 && + typeof rawEmoji === "string" && + rawEmoji.length > 0 + ? { added, emoji, messageId, rawEmoji, runtimeContext, threadId } + : null; + } + + async function processPendingReactionDelivery( + candidate: DeliveryRow, + ): Promise { + const reaction = normalizedPendingReaction(candidate); + if (!reaction || !candidate.principalId) { + const now = new Date(); + await db.transaction(async (tx) => { + await notifyChatDeliveryWork(tx); + return tx + .update(chatDeliveries) + .set({ + state: "failed", + nextAttemptAt: null, + processedAt: now, + redactedError: "Normalized reaction delivery is incomplete", + updatedAt: now, + }) + .where( + and( + eq(chatDeliveries.id, candidate.id), + inArray(chatDeliveries.state, ["received", "retry"]), + ), + ); + }); + return; + } + const principalId = candidate.principalId; + + await db.transaction(async (tx) => { + const currentEndpoint = await runtimeCallbackEndpoint( + tx, + candidate.endpointId, + reaction.runtimeContext, + ["active"], + ); + let conversation: ConversationRow | null = null; + let disposition: + | { kind: "processed"; conversationId: string } + | { kind: "retry" } + | { kind: "filtered"; reason: string }; + + if (!currentEndpoint) { + disposition = { + kind: "filtered", + reason: "Reaction callback belonged to an unavailable runtime", + }; + } else { + await options.reactionReplayEndpointLockBarrier?.(); + conversation = await lockReactionConversationForMessage( + tx, + currentEndpoint, + reaction.threadId, + reaction.messageId, + "outbound", + ); + if (conversation) { + await options.reactionReplayConversationLockBarrier?.(); + } + if (conversation) { + disposition = (await authorizeReactionConversation( + tx, + currentEndpoint, + conversation, + principalId, + )) + ? { kind: "processed", conversationId: conversation.id } + : { + kind: "filtered", + reason: + "Reaction destination or principal is no longer authorized", + }; + } else { + conversation = await lockStreamingReactionConversation( + tx, + currentEndpoint, + reaction.threadId, + ); + if (conversation) { + await options.reactionReplayConversationLockBarrier?.(); + } + if (!conversation) { + disposition = { + kind: "filtered", + reason: "Reaction target was not linked to a sent message", + }; + } else { + disposition = (await authorizeReactionConversation( + tx, + currentEndpoint, + conversation, + principalId, + )) + ? { kind: "retry" } + : { + kind: "filtered", + reason: + "Reaction destination or principal is no longer authorized", + }; + } + } + } + + // Endpoint, conversation, destination and identity locks are acquired + // before the delivery row so pause/revoke and replay use one lock order. + const locked = await tx + .select() + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.id, candidate.id), + inArray(chatDeliveries.state, ["received", "retry"]), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + // Authorization locks may wait behind a concurrent lifecycle mutation. + // Sample the expiry clock only after this delivery is exclusively held. + const decisionAt = new Date(); + if (!locked || !deliveryReady(locked, decisionAt)) return; + const lockedReaction = normalizedPendingReaction(locked); + if ( + !lockedReaction || + lockedReaction.runtimeContext.generation !== + reaction.runtimeContext.generation || + lockedReaction.runtimeContext.credentialFingerprint !== + reaction.runtimeContext.credentialFingerprint + ) { + disposition = { + kind: "filtered", + reason: "Reaction delivery identity changed before replay", + }; + } + + const nextAttempts = locked.attempts + 1; + const expired = + decisionAt.getTime() - locked.receivedAt.getTime() >= + REACTION_LINK_MAX_AGE_MS; + if (expired || locked.attempts >= REACTION_LINK_MAX_ATTEMPTS) { + disposition = { + kind: "filtered", + reason: "Reaction target did not become linked before replay expiry", + }; + } + if (disposition.kind === "retry" && !expired) { + if (nextAttempts < REACTION_LINK_MAX_ATTEMPTS) { + const delay = Math.min( + REACTION_LINK_RETRY_DELAY_MS * + 2 ** Math.min(Math.max(locked.attempts, 0), 5), + REACTION_LINK_MAX_DELAY_MS, + ); + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: "retry", + attempts: nextAttempts, + nextAttemptAt: new Date(decisionAt.getTime() + delay), + redactedError: "Waiting for the sent message link", + updatedAt: decisionAt, + }) + .where( + and( + eq(chatDeliveries.id, locked.id), + eq(chatDeliveries.state, locked.state), + eq(chatDeliveries.attempts, locked.attempts), + ), + ); + return; + } + disposition = { + kind: "filtered", + reason: "Reaction target did not become linked before retry expiry", + }; + } else if (disposition.kind === "retry") { + disposition = { + kind: "filtered", + reason: "Reaction target did not become linked before age expiry", + }; + } + + const processedAt = new Date(); + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set( + disposition.kind === "processed" + ? { + conversationId: disposition.conversationId, + state: "processed", + attempts: nextAttempts, + nextAttemptAt: null, + processedAt, + redactedError: null, + updatedAt: processedAt, + } + : { + state: "filtered", + attempts: nextAttempts, + nextAttemptAt: null, + processedAt, + redactedError: disposition.reason, + updatedAt: processedAt, + }, + ) + .where( + and( + eq(chatDeliveries.id, locked.id), + eq(chatDeliveries.state, locked.state), + eq(chatDeliveries.attempts, locked.attempts), + ), + ); + }); + } + + async function processPendingReactionDeliveries( + limit: number, + onlyDeliveryId?: string, + ): Promise { + const now = new Date(); + const candidates = await db + .select() + .from(chatDeliveries) + .where( + and( + sql`not exists (select 1 from chat_endpoints e where e.id = ${chatDeliveries.endpointId} and e.provider = 'agentmail')`, + onlyDeliveryId ? eq(chatDeliveries.id, onlyDeliveryId) : undefined, + inArray(chatDeliveries.eventKind, [ + "reaction_added", + "reaction_removed", + ]), + or( + and( + eq(chatDeliveries.state, "received"), + or( + isNull(chatDeliveries.nextAttemptAt), + lte(chatDeliveries.nextAttemptAt, now), + ), + ), + and( + eq(chatDeliveries.state, "retry"), + or( + isNull(chatDeliveries.nextAttemptAt), + lte(chatDeliveries.nextAttemptAt, now), + ), + ), + ), + ), + ) + .orderBy(asc(chatDeliveries.receivedAt), asc(chatDeliveries.id)) + .limit(limit); + for (const candidate of candidates) { + try { + await processPendingReactionDelivery(candidate); + } catch (error) { + logger.warn( + { + endpointId: candidate.endpointId, + deliveryId: candidate.id, + error: redactError(error), + }, + "chat reaction-link reconciliation failed", + ); + } + } + return candidates.length; + } + return { handleReaction, processPendingReactionDeliveries }; +} diff --git a/server/src/services/chat-channels/receipt-reactions.ts b/server/src/services/chat-channels/receipt-reactions.ts new file mode 100644 index 00000000000..a733091a127 --- /dev/null +++ b/server/src/services/chat-channels/receipt-reactions.ts @@ -0,0 +1,586 @@ +import type { ChatRuntime } from "../chat-runtime.js"; +import type { EndpointRow, RuntimeContext, LifecycleRuntimeFence } from "./runtime-types.js"; + +import { dueChatAction, PROVIDER_EFFECT_STALE_MS } from "../chat-action-work.js"; + +import { notifyChatActionWork } from "../chat-work-notifications.js"; + +import type { ChatEndpointRuntime } from "../voice/voice-runtime.js"; + +import { + receiptReactionPayload, + type ReceiptReactionPayload, +} from "../chat-receipt-reactions.js"; + +import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../chat-credential-mutation-lease.js"; + +import { + and, + asc, + eq, + like, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatDeliveries, + chatEndpoints, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatEndpointSetupState, + ChatProvider, + ToolCredentialSecretRef, +} from "@paperclipai/shared"; + +import { + parseGitHubReceiptIdentity, + type GitHubReceiptIdentity, +} from "../chat-github-receipt-reactions.js"; + +import { logger } from "../../middleware/logger.js"; + +import { classifyChatPublicationError } from "../chat-publication-errors.js"; + +import type { + Message, + Thread, +} from "chat"; + +import type { EndpointRecord } from "./runtime-types.js"; +import { isSlackReceiptReactionAlreadyApplied } from "./slack/messages.js"; + +type ReceiptReactionsOptions = { + endpointRecord: (endpointId: string) => Promise; + runtimeContextForRecord: (record: EndpointRecord) => RuntimeContext; + withCredentialMutationLease: ReturnType; + runtimeFor: (endpoint: EndpointRow) => Promise; + fetchImpl: typeof fetch; + runtime: Pick; + runtimeGeneration: (setup: ChatEndpointSetupState) => number; + credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string; + redactError: (error: unknown) => string; + MAX_ERROR_TEXT: number; + receiptReactionTransportBarrier?: () => Promise; +}; + +export function createReceiptReactions(db: Db, options: ReceiptReactionsOptions) { + const { endpointRecord, runtimeContextForRecord, withCredentialMutationLease, runtimeFor, fetchImpl, runtime, runtimeGeneration, credentialFingerprint, redactError, MAX_ERROR_TEXT } = options; + + + async function addReceiptReaction(input: { + deliveryId: string; + endpoint: EndpointRow; + message: Message; + runtimeContext?: LifecycleRuntimeFence; + thread: Thread; + }): Promise { + // GitHub acknowledges through one editable response comment. Continue to + // drain legacy eyes removals, but never create new eyes for these requests. + if (input.endpoint.provider === "github") return; + const record = await endpointRecord(input.endpoint.id); + if (!record) return; + // A live SDK object remains bound to the credentials that authenticated + // its callback. Preserve that original fence rather than sampling the + // endpoint again after a rotation and accidentally blessing an obsolete + // adapter with the new generation. + const context = input.runtimeContext ?? runtimeContextForRecord(record); + const [inserted] = await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "receipt_reaction"); + return tx + .insert(chatActions) + .values({ + companyId: input.endpoint.companyId, + endpointId: input.endpoint.id, + deliveryId: input.deliveryId, + kind: "receipt_reaction", + providerActionId: `receipt_reaction:${input.deliveryId}`, + payload: { + version: 1, + operation: "add", + threadId: input.thread.id, + messageId: input.message.id, + reaction: "eyes", + runtimeGeneration: context.generation, + credentialFingerprint: context.credentialFingerprint, + } satisfies ReceiptReactionPayload, + status: "received", + }) + .onConflictDoNothing() + .returning({ id: chatActions.id }); + }); + const actionId = + inserted?.id ?? + (await db + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, input.endpoint.id), + eq( + chatActions.providerActionId, + `receipt_reaction:${input.deliveryId}`, + ), + ), + ) + .then((rows) => rows[0]?.id ?? null)); + if (!actionId) return; + await options.receiptReactionTransportBarrier?.(); + await processReceiptReaction(actionId); + } + + async function processReceiptReaction(actionId: string): Promise { + const initialAction = await db + .select({ endpointId: chatActions.endpointId }) + .from(chatActions) + .where( + and( + eq(chatActions.id, actionId), + eq(chatActions.kind, "receipt_reaction"), + ), + ) + .then((rows) => rows[0] ?? null); + if (!initialAction) return; + const initialEndpoint = await endpointRecord(initialAction.endpointId); + if (!initialEndpoint) return; + // Receipt reactions are GitHub repository mutations too. Serialize their + // final runtime-fence check and provider call with reconnect, secret + // rotation, pause, and removal so credentials cannot become obsolete + // between authorization and transport. + await withCredentialMutationLease(initialEndpoint.endpoint, (lease) => + processReceiptReactionWithCredentialLease(actionId, lease), + ); + } + + async function processReceiptReactionWithCredentialLease( + actionId: string, + credentialLease: CredentialMutationLeaseGuard, + ): Promise { + let action = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.id, actionId), + eq(chatActions.kind, "receipt_reaction"), + ), + ) + .then((rows) => rows[0] ?? null); + if (!action || ["processed", "cancelled"].includes(action.status)) return; + const payload = receiptReactionPayload(action.payload); + if (!payload) { + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "receipt_reaction"); + return tx + .update(chatActions) + .set({ + status: "failed", + result: { + code: "receipt_reaction_payload_invalid", + retryable: false, + }, + updatedAt: new Date(), + }) + .where(eq(chatActions.id, action.id)); + }); + return; + } + if (payload.operation === "add" && action.deliveryId) { + const terminalRemoval = await db + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, action.endpointId), + eq( + chatActions.providerActionId, + `receipt_reaction_remove:${action.deliveryId}`, + ), + ), + ) + .then((rows) => rows[0] ?? null); + if (terminalRemoval) { + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatActionWork(tx, "receipt_reaction"); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + ...(typeof action!.result?.attempts === "number" + ? { attempts: action!.result.attempts } + : {}), + code: "receipt_reaction_superseded_by_terminal_publication", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action!.id), + eq(chatActions.status, action!.status), + ), + ); + await credentialLease.assertOwned(tx); + }); + return; + } + } + if (action.status === "processing") { + if (action.updatedAt > new Date(Date.now() - PROVIDER_EFFECT_STALE_MS)) + return; + // Adding or removing the same reaction is idempotent. A worker + // interruption can be retried safely without an ambiguous + // duplicate-message boundary. + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "receipt_reaction"); + return tx + .update(chatActions) + .set({ + status: "failed", + result: { + ...(action.result ?? {}), + retryable: true, + retryAt: new Date().toISOString(), + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + eq(chatActions.updatedAt, action.updatedAt), + ), + ); + }); + action = await db + .select() + .from(chatActions) + .where(eq(chatActions.id, action.id)) + .then((rows) => rows[0] ?? null); + if (!action) return; + } + if (action.status === "failed") { + if (action.result?.retryable !== true) return; + const retryAt = + typeof action.result.retryAt === "string" + ? new Date(action.result.retryAt) + : null; + if (retryAt && retryAt > new Date()) return; + } else if (action.status !== "received") { + return; + } + const attempt = + (typeof action.result?.attempts === "number" ? action.result.attempts : 0) + + 1; + const retainedGitHubReceipt = parseGitHubReceiptIdentity( + action.result?.githubReceipt, + ); + const [claimed] = await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatActionWork(tx, "receipt_reaction"); + const rows = await tx + .update(chatActions) + .set({ + status: "processing", + result: { + attempts: attempt, + ...(retainedGitHubReceipt + ? { githubReceipt: retainedGitHubReceipt } + : {}), + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action!.id), + eq(chatActions.status, action!.status), + ), + ) + .returning(); + await credentialLease.assertOwned(tx); + return rows; + }); + if (!claimed) return; + + let reactionProvider: ChatProvider | null = null; + let githubReceipt: GitHubReceiptIdentity | null = retainedGitHubReceipt; + try { + const record = await endpointRecord(action.endpointId); + if ( + !record || + !["verifying", "active"].includes(record.endpoint.status) || + runtimeGeneration(record.endpoint.setup) !== payload.runtimeGeneration || + credentialFingerprint(record.credentialSecretRefs) !== + payload.credentialFingerprint + ) { + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatActionWork(tx, "receipt_reaction"); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + attempts: attempt, + code: "receipt_reaction_runtime_superseded", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action!.id), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, + ), + ); + await credentialLease.assertOwned(tx); + }); + return; + } + reactionProvider = record.endpoint.provider; + // Resolve the adapter only after acquiring the credential lease and + // proving the action's durable generation/fingerprint is still current. + // Never reuse the live callback's adapter: it may hold an installation + // token minted by a superseded private key. + const endpointRuntime = await runtimeFor(record.endpoint); + const adapter = endpointRuntime.thread(payload.threadId).adapter; + await credentialLease.assertOwned(); + if (record.endpoint.provider === "slack") { + await endpointRuntime.applySlackReceiptReaction(payload, fetchImpl); + } else if (record.endpoint.provider === "github") { + const assertCurrent = async () => { + await credentialLease.assertOwned(); + const [current] = await db + .select({ endpoint: chatEndpoints, connection: toolConnections }) + .from(chatEndpoints) + .innerJoin( + toolConnections, + and( + eq(toolConnections.id, chatEndpoints.connectionId), + eq(toolConnections.companyId, chatEndpoints.companyId), + ), + ) + .where( + and( + eq(chatEndpoints.id, action!.endpointId), + eq(chatEndpoints.companyId, action!.companyId), + ), + ) + .limit(1); + if ( + !current || + !["active", "verifying"].includes(current.endpoint.status) || + !current.connection.enabled || + current.connection.status !== "active" || + runtimeGeneration(current.endpoint.setup) !== + payload.runtimeGeneration || + credentialFingerprint(current.connection.credentialSecretRefs) !== + payload.credentialFingerprint || + runtime.get(action!.endpointId) !== endpointRuntime + ) + throw new Error("GitHub receipt authority is no longer current"); + }; + const applied = await endpointRuntime.applyGitHubReceiptReaction( + { ...payload, ...(githubReceipt ? { githubReceipt } : {}) }, + assertCurrent, + fetchImpl, + ); + githubReceipt = parseGitHubReceiptIdentity(applied); + if ( + !githubReceipt || + (payload.operation === "add" && !githubReceipt.reactionId) + ) + throw new Error("GitHub receipt identity was not confirmed"); + } else if (payload.operation === "remove") { + await adapter.removeReaction( + payload.threadId, + payload.messageId, + payload.reaction, + ); + } else { + await adapter.addReaction( + payload.threadId, + payload.messageId, + payload.reaction, + ); + } + // The provider may already have accepted the idempotent reaction. Fence + // the local success receipt so a reclaimed lease cannot report success + // for an obsolete runtime. + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatActionWork(tx, "receipt_reaction"); + const processed = await tx + .update(chatActions) + .set({ + status: "processed", + result: { + attempts: attempt, + ...(githubReceipt ? { githubReceipt } : {}), + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, + ), + ) + .returning({ id: chatActions.id }); + if (!processed.length) { + throw new Error( + "Receipt-reaction ownership changed before success settlement", + ); + } + if (action.deliveryId) { + await tx + .update(chatDeliveries) + .set({ redactedError: null, updatedAt: new Date() }) + .where( + and( + eq(chatDeliveries.id, action.deliveryId), + eq(chatDeliveries.state, "processed"), + like(chatDeliveries.redactedError, "Receipt reaction %"), + ), + ); + } + await credentialLease.assertOwned(tx); + }); + } catch (error) { + if ( + payload.operation === "add" && + reactionProvider === "slack" && + isSlackReceiptReactionAlreadyApplied(error) + ) { + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatActionWork(tx, "receipt_reaction"); + const processed = await tx + .update(chatActions) + .set({ + status: "processed", + result: { + attempts: attempt, + code: "receipt_reaction_already_applied", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action!.id), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, + ), + ) + .returning({ id: chatActions.id }); + if (!processed.length) { + throw new Error( + "Receipt-reaction ownership changed before idempotent settlement", + ); + } + if (action.deliveryId) { + await tx + .update(chatDeliveries) + .set({ redactedError: null, updatedAt: new Date() }) + .where( + and( + eq(chatDeliveries.id, action.deliveryId), + eq(chatDeliveries.state, "processed"), + like(chatDeliveries.redactedError, "Receipt reaction %"), + ), + ); + } + await credentialLease.assertOwned(tx); + }); + return; + } + const disposition = classifyChatPublicationError(error, attempt); + const retryable = + disposition.kind === "retry" || + (disposition.kind === "delivery_unknown" && attempt < 3); + const retryAfterMs = + disposition.kind === "retry" + ? disposition.retryAfterMs + : 100 * 2 ** Math.max(0, attempt - 1); + const operationLabel = + payload.operation === "remove" ? " reaction removal" : " reaction"; + const diagnostic = + `Receipt${operationLabel} ${retryable ? "deferred" : "failed"} after ${attempt} attempt${attempt === 1 ? "" : "s"} (${disposition.kind}): ${redactError(error)}`.slice( + 0, + MAX_ERROR_TEXT, + ); + const retryAt = retryable + ? new Date(Date.now() + retryAfterMs).toISOString() + : null; + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatActionWork(tx, "receipt_reaction"); + const [failed] = await tx + .update(chatActions) + .set({ + status: "failed", + result: { + attempts: attempt, + ...(githubReceipt ? { githubReceipt } : {}), + code: + payload.operation === "remove" + ? `receipt_reaction_removal_${disposition.kind}` + : `receipt_reaction_${disposition.kind}`, + redactedError: diagnostic, + retryable, + retryAt, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, + ), + ) + .returning({ id: chatActions.id }); + if (failed && action.deliveryId) { + await tx + .update(chatDeliveries) + .set({ redactedError: diagnostic, updatedAt: new Date() }) + .where( + and( + eq(chatDeliveries.id, action.deliveryId), + eq(chatDeliveries.state, "processed"), + ), + ); + } + await credentialLease.assertOwned(tx); + }); + logger.warn( + { + endpointId: action.endpointId, + deliveryId: action.deliveryId, + attempts: attempt, + disposition: disposition.kind, + operation: payload.operation, + retryAt, + }, + "external chat receipt reaction deferred", + ); + } + } + + async function processPendingReceiptReactions( + limit = 25, + onlyActionId?: string, + ) { + const actions = await db.select({ id: chatActions.id }).from(chatActions) + .where(and(dueChatAction("receipt_reaction"), onlyActionId ? eq(chatActions.id, onlyActionId) : undefined)) + .orderBy(asc(chatActions.createdAt)).limit(limit); + for (const action of actions) await processReceiptReaction(action.id); + return actions.length; + } + return { addReceiptReaction, processReceiptReaction, processPendingReceiptReactions }; +} diff --git a/server/src/services/chat-channels/runtime-types.ts b/server/src/services/chat-channels/runtime-types.ts index a764a0297eb..cd2f6881e9b 100644 --- a/server/src/services/chat-channels/runtime-types.ts +++ b/server/src/services/chat-channels/runtime-types.ts @@ -1,5 +1,5 @@ import type { Db, chatEndpoints } from "@paperclipai/db"; -import type { ToolCredentialSecretRef } from "@paperclipai/shared"; +import type { ToolCredentialSecretRef, ChatEndpointSetupState, ChatEndpointCallbackSurfaces } from "@paperclipai/shared"; import type { ChatEndpointRuntime } from "../voice/voice-runtime.js"; export type EndpointRow = typeof chatEndpoints.$inferSelect; @@ -36,3 +36,17 @@ export type EndpointRecord = { applicationId: string; credentialSecretRefs: ToolCredentialSecretRef[]; }; + +export type SlackCallbackSurface = keyof ChatEndpointCallbackSurfaces; + +export type SlackCallbackObservation = { url: string; observedAt: string }; + +export type InternalSetupState = ChatEndpointSetupState & { + photonIntakeAfter?: string; + runtimeGeneration?: number; + /** Binds pre-install URL verification to the signing secret later configured. */ + slackVerificationSigningFingerprint?: string; + slackCallbackSurfaces?: Partial< + Record + >; +}; diff --git a/server/src/services/chat-channels/setup-probes.ts b/server/src/services/chat-channels/setup-probes.ts new file mode 100644 index 00000000000..36d0f8f9f66 --- /dev/null +++ b/server/src/services/chat-channels/setup-probes.ts @@ -0,0 +1,353 @@ +import type { EndpointRow, DbOrTransaction } from "./runtime-types.js"; + +import { notifyChatEndpointWork } from "../chat-work-notifications.js"; + +import { chatCredentialMutationLease } from "../chat-credential-mutation-lease.js"; + +import { githubChatManagementService } from "../chat-github-management.js"; + +import { + and, + asc, + desc, + eq, + gte, + inArray, + isNotNull, + or, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatDeliveries, + chatEndpoints, + chatIdentityLinks, + chatMessageLinks, + chatPublications, + heartbeatRuns, + issueComments, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatEndpoint, + ChatEndpointSetupState, +} from "@paperclipai/shared"; + +import { + conflict, + forbidden, + notFound, +} from "../../errors.js"; + +import { + logActivity, +} from "../activity-log.js"; + +import { + githubRunReplyState, +} from "../chat-run-publications.js"; + +import type { EndpointRecord } from "./runtime-types.js"; + +type SetupProbesOptions = { + endpointRecord: (endpointId: string) => Promise; + withCredentialMutationLease: ReturnType; + fetchImpl: typeof fetch; + findAuthorizedIdentityLink: (endpoint: EndpointRow, userId: string) => Promise<{ id: string } | null>; + runtimeGeneration: (setup: ChatEndpointSetupState) => number; + lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean }>; + get: (endpointId: string) => Promise; + setupTestActivationBarrier?: () => Promise; +}; + +export function createSetupProbes(db: Db, options: SetupProbesOptions) { + const { endpointRecord, withCredentialMutationLease, fetchImpl, findAuthorizedIdentityLink, runtimeGeneration, lockCurrentPrincipalAuthorization, get } = options; + + + async function setupTestStatus(endpointId: string, userId: string) { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + const startedAt = record.endpoint.setup.testStartedAt; + if (!startedAt) return { messageReceivedAt: null }; + const links = await db.select({ principalId: chatIdentityLinks.principalId }).from(chatIdentityLinks).where(and( + eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.endpointId, endpointId), + eq(chatIdentityLinks.paperclipUserId, userId), eq(chatIdentityLinks.status, "linked"), + )); + if (!links.length) return { messageReceivedAt: null }; + const principalIds = links.map((link) => link.principalId); + const [delivery, command] = await Promise.all([ + db.select({ at: chatDeliveries.createdAt }).from(chatDeliveries).where(and( + eq(chatDeliveries.companyId, record.endpoint.companyId), eq(chatDeliveries.endpointId, endpointId), + inArray(chatDeliveries.principalId, principalIds), gte(chatDeliveries.createdAt, new Date(startedAt)), + inArray(chatDeliveries.eventKind, ["message", "mention"]), + )).orderBy(asc(chatDeliveries.createdAt)).limit(1).then((rows) => rows[0]), + db.select({ at: chatActions.createdAt }).from(chatActions).where(and( + eq(chatActions.companyId, record.endpoint.companyId), eq(chatActions.endpointId, endpointId), + inArray(chatActions.principalId, principalIds), eq(chatActions.kind, "slash_task_start"), gte(chatActions.createdAt, new Date(startedAt)), + )).orderBy(asc(chatActions.createdAt)).limit(1).then((rows) => rows[0]), + ]); + const at = [delivery?.at, command?.at].filter((value): value is Date => Boolean(value)).sort((a, b) => a.getTime() - b.getTime())[0]; + return { messageReceivedAt: at?.toISOString() ?? null }; + } + + async function test(endpointId: string, finishOptions?: { optionalSlackTestForUser: string }) { + const initial = await endpointRecord(endpointId); + if (!initial) throw notFound("Chat endpoint not found"); + return withCredentialMutationLease( + initial.endpoint, + async (credentialLease) => { + // Reload only after owning the same lane as reconnect, pause, removal, + // and credential rotation. Qualification and activation must describe + // one exact provider runtime, never a setup snapshot that a concurrent + // reconnect has already superseded. + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + const endpoint = record.endpoint; + if (endpoint.status !== "verifying" || endpoint.setup.step !== "test") { + throw conflict("This connection is not waiting for a setup test", { + code: "chat_endpoint_not_testing", + }); + } + if (endpoint.provider === "github" && endpoint.setup.github) { + const verified = await githubChatManagementService(db, fetchImpl).verification(endpoint.id); + if (!verified.ready && !(finishOptions?.optionalSlackTestForUser && verified.connectionReady)) throw conflict("Finish verifying the App, repositories, and assigned agent's tools before activating this bot", { checks: verified.checks }); + } + const testStartedAtValue = endpoint.setup.testStartedAt; + const testStartedAt = testStartedAtValue + ? new Date(testStartedAtValue) + : null; + const optionalSlackTest = Boolean(finishOptions?.optionalSlackTestForUser); + if (optionalSlackTest) { + if (!["slack", "github"].includes(endpoint.provider) || !endpoint.setup.webhookVerifiedAt || !endpoint.providerAccountId) { + throw conflict("Verify the connection before finishing setup"); + } + const linked = await findAuthorizedIdentityLink(endpoint, finishOptions!.optionalSlackTestForUser); + if (!linked) throw forbidden("Connect your account before finishing setup"); + } + if (!optionalSlackTest) { + if ( + !endpoint.lastEventAt || + !testStartedAtValue || + !testStartedAt || + Number.isNaN(testStartedAt.getTime()) || + endpoint.lastEventAt < testStartedAt + ) { + throw conflict( + "Send the test message in the provider before completing setup", + { + code: "chat_test_message_missing", + }, + ); + } + const requiredTrigger = + ["telegram", "imessage-photon"].includes(endpoint.provider) + ? "direct_message" + : "subscribed_message"; + const qualifyingDelivery = await db + .select({ + id: chatDeliveries.id, + conversationId: chatDeliveries.conversationId, + processedAt: chatDeliveries.processedAt, + }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.companyId, endpoint.companyId), + eq(chatDeliveries.endpointId, endpoint.id), + eq(chatDeliveries.state, "processed"), + gte(chatDeliveries.processedAt, testStartedAt), + sql`${chatDeliveries.normalizedEvent}->>'trigger' = ${requiredTrigger}`, + ), + ) + .orderBy(desc(chatDeliveries.processedAt)) + .limit(1) + .then((rows) => rows[0] ?? null); + if ( + !qualifyingDelivery?.conversationId || + !qualifyingDelivery.processedAt + ) { + throw conflict( + ["telegram", "imessage-photon"].includes(endpoint.provider) + ? "Send the test direct message before completing setup" + : "Reply once without mentioning the agent before completing setup", + { code: "chat_test_follow_up_missing" }, + ); + } + const finalPublication = await db + .select({ + commentId: chatPublications.commentId, + payload: chatPublications.payload, + }) + .from(chatPublications) + .innerJoin( + issueComments, + and( + eq(issueComments.id, chatPublications.commentId), + eq(issueComments.companyId, chatPublications.companyId), + eq(issueComments.issueId, chatPublications.issueId), + eq(issueComments.authorType, "agent"), + eq(issueComments.authorAgentId, endpoint.assignedAgentId), + ), + ) + .innerJoin( + chatMessageLinks, + and( + eq(chatMessageLinks.companyId, chatPublications.companyId), + eq(chatMessageLinks.endpointId, chatPublications.endpointId), + eq( + chatMessageLinks.conversationId, + chatPublications.conversationId, + ), + eq(chatMessageLinks.deliveryId, qualifyingDelivery.id), + eq(chatMessageLinks.direction, "inbound"), + isNotNull(chatMessageLinks.commentId), + ), + ) + .innerJoin( + heartbeatRuns, + and( + eq(heartbeatRuns.id, issueComments.createdByRunId), + eq(heartbeatRuns.companyId, chatPublications.companyId), + eq(heartbeatRuns.agentId, endpoint.assignedAgentId), + eq(heartbeatRuns.status, "succeeded"), + eq( + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, + sql`${chatPublications.issueId}::text`, + ), + or( + sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`, + sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'commentId'`, + sql`coalesce(${heartbeatRuns.contextSnapshot} -> 'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`, + ), + ), + ) + .where( + and( + eq(chatPublications.companyId, endpoint.companyId), + eq(chatPublications.endpointId, endpoint.id), + eq( + chatPublications.conversationId, + qualifyingDelivery.conversationId, + ), + eq(chatPublications.state, "published"), + gte(chatPublications.publishedAt, qualifyingDelivery.processedAt), + ), + ) + .orderBy(desc(chatPublications.publishedAt)) + .then((rows) => + rows.find( + (row) => + row.payload.interactionId === undefined && + row.payload.progressState === undefined && + row.commentId !== null, + ), + ); + let githubToolReply = false; + if (!finalPublication && endpoint.provider === "github") { + const setupRuns = await db.select({ id: heartbeatRuns.id, issueId: chatConversations.issueId }) + .from(heartbeatRuns) + .innerJoin(chatMessageLinks, and( + eq(chatMessageLinks.companyId, endpoint.companyId), + eq(chatMessageLinks.endpointId, endpoint.id), + eq(chatMessageLinks.deliveryId, qualifyingDelivery.id), + eq(chatMessageLinks.direction, "inbound"), + or( + sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot}->>'wakeCommentId'`, + sql`coalesce(${heartbeatRuns.contextSnapshot}->'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`, + ), + )) + .innerJoin(chatConversations, and( + eq(chatConversations.id, qualifyingDelivery.conversationId), + eq(chatConversations.id, chatMessageLinks.conversationId), + eq(chatConversations.companyId, endpoint.companyId), + sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${chatConversations.issueId}::text`, + )) + .where(and(eq(heartbeatRuns.companyId, endpoint.companyId), + eq(heartbeatRuns.agentId, endpoint.assignedAgentId), eq(heartbeatRuns.status, "succeeded"))) + .orderBy(desc(heartbeatRuns.createdAt)).limit(20); + for (const run of setupRuns) { + if (await githubRunReplyState(db, { companyId: endpoint.companyId, endpointId: endpoint.id, + issueId: run.issueId, runId: run.id }) === "confirmed") { + githubToolReply = true; + break; + } + } + } + if (!finalPublication && !githubToolReply) { + throw conflict( + "Wait for the Paperclip agent to reply to the setup turn before completing setup", + { + code: "chat_test_round_trip_incomplete", + }, + ); + } + } + await options.setupTestActivationBarrier?.(); + const expectedGeneration = runtimeGeneration(endpoint.setup); + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + if (optionalSlackTest) { + const linked = await tx.select({ principalId: chatIdentityLinks.principalId }).from(chatIdentityLinks).where(and( + eq(chatIdentityLinks.endpointId, endpoint.id), eq(chatIdentityLinks.paperclipUserId, finishOptions!.optionalSlackTestForUser), eq(chatIdentityLinks.status, "linked"), + )).limit(1).then((rows) => rows[0]); + if (!linked || !(await lockCurrentPrincipalAuthorization(tx, endpoint, linked.principalId)).allowed) throw forbidden("Your connected account is no longer authorized"); + } + await notifyChatEndpointWork(tx); + const [activated] = await tx + .update(chatEndpoints) + .set({ + status: "active", + setup: { + ...endpoint.setup, + step: "complete", + testStartedAt: null, + testSkipped: optionalSlackTest, + }, + healthMessage: optionalSlackTest ? "Connected; conversation test optional" : "Connected", + activatedAt: endpoint.activatedAt ?? new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(chatEndpoints.id, endpointId), + eq(chatEndpoints.status, "verifying"), + sql`${chatEndpoints.setup}->>'step' = 'test'`, + sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${expectedGeneration}`, + sql`${chatEndpoints.setup}->>'testStartedAt' = ${testStartedAtValue}`, + ), + ) + .returning({ id: chatEndpoints.id }); + if (!activated) { + throw conflict("This setup test was superseded by a reconnect", { + code: "chat_endpoint_test_superseded", + }); + } + await tx + .update(toolConnections) + .set({ + status: "active", + enabled: true, + healthStatus: "healthy", + healthMessage: optionalSlackTest ? "Connected; conversation test optional" : "Connected", + lastError: null, + healthCheckedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(toolConnections.id, endpoint.connectionId)); + if (optionalSlackTest) await logActivity(tx as unknown as Db, { + companyId: endpoint.companyId, actorType: "user", actorId: finishOptions!.optionalSlackTestForUser, + action: "chat.setup_completed", entityType: "chat_endpoint", entityId: endpointId, + details: { conversationTestOptional: true }, + }); + await credentialLease.assertOwned(tx); + }); + return get(endpointId); + }, + ); + } + return { setupTestStatus, test }; +} diff --git a/server/src/services/chat-channels/slack/callback-verification.ts b/server/src/services/chat-channels/slack/callback-verification.ts new file mode 100644 index 00000000000..ecddb2f665f --- /dev/null +++ b/server/src/services/chat-channels/slack/callback-verification.ts @@ -0,0 +1,300 @@ +import { isProvisionedSlackSetup } from "@paperclipai/shared"; +import type { EndpointRow, DbOrTransaction, SlackCallbackSurface, InternalSetupState } from "../runtime-types.js"; + +import { notifyChatVerificationWork } from "../../chat-work-notifications.js"; + +import { slackChatRegistrationService } from "../../chat-slack-registration.js"; +import { chatSlackRegistrations } from "@paperclipai/db"; + +import { runtimeCanonicalOrigin } from "../../cloud-runtime-identity.js"; + +import { + createHash, + createHmac, + timingSafeEqual, +} from "node:crypto"; +import { + and, + eq, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatEndpoints, + toolConnections, +} from "@paperclipai/db"; +import type { + ToolCredentialSecretRef, +} from "@paperclipai/shared"; + +import { + logActivity, +} from "../../activity-log.js"; + +import { secretService } from "../../secrets.js"; + +function slackRequestSignatureIsValid( + request: Request, + body: string, + signingSecret: string, +): boolean { + const timestamp = request.headers.get("x-slack-request-timestamp"); + const signature = request.headers.get("x-slack-signature"); + if (!timestamp || !signature) return false; + const timestampSeconds = Number(timestamp); + if ( + !Number.isFinite(timestampSeconds) || + Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds) > 300 + ) + return false; + const expected = `v0=${createHmac("sha256", signingSecret).update(`v0:${timestamp}:${body}`).digest("hex")}`; + try { + return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); + } catch { + return false; + } +} + +function slackRequestWorkspaceId( + body: string, + contentType: string, +): string | null { + let payload: unknown; + try { + if (contentType.includes("application/json")) { + payload = JSON.parse(body); + } else { + const form = new URLSearchParams(body); + const interactivePayload = form.get("payload"); + payload = interactivePayload + ? JSON.parse(interactivePayload) + : Object.fromEntries(form.entries()); + } + } catch { + return null; + } + if (!payload || typeof payload !== "object") return null; + const record = payload as Record; + const nestedId = (value: unknown): string | null => { + if (!value || typeof value !== "object") return null; + const id = (value as Record).id; + return typeof id === "string" && id.length > 0 ? id : null; + }; + const stringField = (key: string): string | null => { + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : null; + }; + const authorization = Array.isArray(record.authorizations) + ? record.authorizations.find( + (value): value is Record => + Boolean(value) && typeof value === "object", + ) + : null; + const authorizationField = (key: string): string | null => { + const value = authorization?.[key]; + return typeof value === "string" && value.length > 0 ? value : null; + }; + + // A Grid event may carry both an enterprise id and the concrete workspace + // id. The bot identity is verified against auth.test's team_id, so prefer a + // team id and use the enterprise id only when Slack omitted team context. + return ( + stringField("team_id") ?? + nestedId(record.team) ?? + authorizationField("team_id") ?? + stringField("enterprise_id") ?? + nestedId(record.enterprise) ?? + authorizationField("enterprise_id") + ); +} + +function canonicalCallbackUrl(value: string): string | null { + try { + const url = new URL(value); + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + url.hostname = url.hostname.replace(/\.$/, ""); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return null; + } +} + +// TLS terminates at a reverse proxy in many self-hosted deployments. The +// transport scheme is not evidence of URL drift; authority and path still are. +// This is diagnostic only: it does not trust forwarded headers or change auth. +function slackCallbackMatchesPublicUrl(observed: string, current: string): boolean { + const canonical = canonicalCallbackUrl(observed); + if (canonical === current) return true; + if (!canonical) return false; + const observedUrl = new URL(canonical); + const currentUrl = new URL(current); + return observedUrl.protocol === "http:" + && currentUrl.protocol === "https:" + && observedUrl.host === currentUrl.host + && observedUrl.pathname === currentUrl.pathname; +} + +async function inspectSlackCallback( + request: Request, +): Promise { + const url = slackCallbackObservationUrl(request); + if (!url) return null; + const contentType = request.headers.get("content-type")?.toLowerCase() ?? ""; + let body: string; + try { + body = await request.text(); + } catch { + return null; + } + if (contentType.includes("application/json")) { + try { + const payload = JSON.parse(body) as { + type?: unknown; + challenge?: unknown; + }; + const isUrlVerification = + payload.type === "url_verification" && + typeof payload.challenge === "string" && + payload.challenge.length > 0; + if (isUrlVerification || payload.type === "event_callback") { + return { + surface: "events", + url, + isUrlVerification, + }; + } + } catch { + return null; + } + return null; + } + + const form = new URLSearchParams(body); + if (form.get("command")) { + return { surface: "slashCommands", url, isUrlVerification: false }; + } + const interactivePayload = form.get("payload"); + if (!interactivePayload) return null; + try { + const payload = JSON.parse(interactivePayload) as { type?: unknown }; + if ( + typeof payload.type === "string" && + [ + "block_actions", + "block_suggestion", + "dialog_submission", + "message_action", + "shortcut", + "view_closed", + "view_submission", + "workflow_step_edit", + ].includes(payload.type) + ) { + return { surface: "interactivity", url, isUrlVerification: false }; + } + } catch { + return null; + } + return null; +} + +type SlackCallbackInspection = { + surface: SlackCallbackSurface; + url: string; + isUrlVerification: boolean; +}; + +// Cloud records the public request host in dedicated headers so provider edges +// cannot replace it with their upstream host. Only use callback-health evidence on a +// claimed Cloud instance, after provider authentication succeeded. It must not +// change the Request passed to the adapter, signature verification, routing, +// board identity, or the configured URL used to generate callbacks. +function slackCallbackObservationUrl(request: Request): string | null { + const directUrl = canonicalCallbackUrl(request.url); + if (!directUrl || !runtimeCanonicalOrigin()) return directUrl; + const hasCloudEvidence = request.headers.has("x-paperclip-cloud-forwarded-host"); + const host = request.headers.get(hasCloudEvidence ? "x-paperclip-cloud-forwarded-host" : "x-forwarded-host")?.trim(); + const protocol = request.headers.get(hasCloudEvidence ? "x-paperclip-cloud-forwarded-proto" : "x-forwarded-proto")?.trim(); + if (!host || /[\s/@\\?#,]/.test(host) || (protocol !== "https" && protocol !== "http")) return directUrl; + try { + const origin = new URL(`${protocol}://${host}`); + // Accept one authority only, never credentials, paths, queries, or lists. + if (!origin.host || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash) return directUrl; + const observed = new URL(directUrl); + observed.protocol = origin.protocol; + observed.host = origin.host; + observed.port = origin.port; + return canonicalCallbackUrl(observed.toString()); + } catch { + return directUrl; + } +} +export { slackRequestSignatureIsValid, slackRequestWorkspaceId, canonicalCallbackUrl, slackCallbackMatchesPublicUrl, inspectSlackCallback }; + +type SlackCallbackVerificationOptions = { + resolveCredentialRefs: (endpoint: EndpointRow, refs: ToolCredentialSecretRef[], database?: DbOrTransaction) => Promise>; + getWebhookPublicBaseUrl: () => string | null; + scheduleMessageProcessing: (task: () => Promise) => void; + slackRegistration: Pick, "notifyVerified">; +}; + +export function createSlackCallbackVerification(db: Db, options: SlackCallbackVerificationOptions) { + const { resolveCredentialRefs, getWebhookPublicBaseUrl, scheduleMessageProcessing, slackRegistration } = options; + + + async function verifySlackRequestUrl(endpoint: EndpointRow, request: Request): Promise { + const callback = await inspectSlackCallback(request.clone()); + if (!callback?.isUrlVerification) return null; + const body = await request.clone().text(); + let notifyAccount = false; + // URL verification needs only the signing secret. Do not initialize the + // SDK (which calls Slack auth.test), wait for OAuth, or acquire the creation + // lease: Slack can deliver this check while app setup still owns that lease. + const response = await db.transaction(async tx => { + const [current] = await tx.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpoint.id)).for("no key update"); + if (!current || ["archived", "paused", "revoked"].includes(current.status)) return new Response("ignored", { status: 200 }); + const [connection] = await tx.select({ status: toolConnections.status, refs: toolConnections.credentialSecretRefs }).from(toolConnections) + .where(and(eq(toolConnections.id, current.connectionId), eq(toolConnections.companyId, current.companyId))); + if (!connection || connection.status === "archived") return new Response("ignored", { status: 200 }); + let secret = (await resolveCredentialRefs(current, connection.refs.filter(ref => ref.configPath.replace(/^credentials\./, "") === "signingSecret"), tx)).signingSecret; + if (!secret && isProvisionedSlackSetup(current.setup.slackSetupMethod)) { + const [registration] = await tx.select().from(chatSlackRegistrations) + .where(and(eq(chatSlackRegistrations.endpointId, current.id), eq(chatSlackRegistrations.companyId, current.companyId))); + if (registration?.status !== "removed" && registration?.secretIds.signingSecret) { + secret = await secretService(tx as unknown as Db).resolveSecretValue(current.companyId, registration.secretIds.signingSecret, "latest", { + accessContext: { consumerType: "system", consumerId: `slack-registration:${current.id}`, configPath: "slack_registration.signingSecret", actorType: "system" }, + }); + } + } + if (!secret) return new Response("Slack signing secret is not ready yet", { status: 503 }); + if (!slackRequestSignatureIsValid(request, body, secret)) return new Response("Invalid signature", { status: 401 }); + const workspaceId = slackRequestWorkspaceId(body, request.headers.get("content-type") ?? ""); + if (workspaceId && current.providerAccountId && workspaceId !== current.providerAccountId) return new Response("ignored", { status: 200 }); + const observedAt = new Date(); + const setup = current.setup as InternalSetupState; + const matchesCurrentUrl = slackCallbackMatchesPublicUrl(callback.url, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${current.publicId}/slack`); + await notifyChatVerificationWork(tx); + await tx.update(chatEndpoints).set({ setup: { + ...setup, + ...(matchesCurrentUrl ? { webhookVerifiedAt: setup.webhookVerifiedAt ?? observedAt.toISOString(), + slackVerificationSigningFingerprint: createHash("sha256").update(secret).digest("hex") } : {}), + slackCallbackSurfaces: { ...setup.slackCallbackSurfaces, events: { url: callback.url, observedAt: observedAt.toISOString() } }, + ...(matchesCurrentUrl && setup.slackAccount?.status === "linked" && !setup.slackAccount.verificationStatus + ? { slackAccount: { ...setup.slackAccount, verificationStatus: "pending" as const } } : {}), + } as InternalSetupState, ...(matchesCurrentUrl ? { healthMessage: "Slack Events Request URL verified" } : {}), updatedAt: observedAt }).where(eq(chatEndpoints.id, current.id)); + if (matchesCurrentUrl && !setup.webhookVerifiedAt) await logActivity(tx as unknown as Db, { companyId: current.companyId, actorType: "system", actorId: "slack-webhook", + action: "chat_endpoint.webhook_verified", entityType: "tool_connection", entityId: current.connectionId, + details: { endpointId: current.id, provider: "slack" } }); + notifyAccount = matchesCurrentUrl && setup.slackAccount?.status === "linked" && !setup.slackAccount.verificationStatus; + return Response.json({ challenge: (JSON.parse(body) as { challenge: string }).challenge }); + }); + if (notifyAccount) scheduleMessageProcessing(() => slackRegistration.notifyVerified(endpoint.id)); + return response; + } + return { verifySlackRequestUrl }; +} diff --git a/server/src/services/chat-channels/slack/messages.ts b/server/src/services/chat-channels/slack/messages.ts index eba1e833f2f..dece4360827 100644 --- a/server/src/services/chat-channels/slack/messages.ts +++ b/server/src/services/chat-channels/slack/messages.ts @@ -41,3 +41,49 @@ export function slackMessageSentAt(raw: unknown, messageId: string): Date | null const date = new Date(Number(ts) * 1_000); return Number.isFinite(date.getTime()) ? date : null; } + +export function isSlackReceiptReactionAlreadyApplied(error: unknown): boolean { + const pending: Array<{ depth: number; value: unknown }> = [ + { depth: 0, value: error }, + ]; + const seen = new Set(); + while (pending.length > 0) { + const current = pending.shift(); + if ( + !current || + current.depth > 4 || + !current.value || + typeof current.value !== "object" || + seen.has(current.value) + ) { + continue; + } + seen.add(current.value); + const record = current.value as { + cause?: unknown; + code?: unknown; + data?: { error?: unknown }; + original?: unknown; + originalError?: unknown; + }; + if ( + record.code === "already_reacted" || + record.data?.error === "already_reacted" + ) { + return true; + } + for (const value of [record.cause, record.original, record.originalError]) { + pending.push({ depth: current.depth + 1, value }); + } + } + return false; +} + +export function slackThreadChannelId(threadId: string): string | null { + if (!threadId.startsWith("slack:")) return null; + const channelAndThread = threadId.slice("slack:".length); + const separator = channelAndThread.indexOf(":"); + const channelId = + separator === -1 ? channelAndThread : channelAndThread.slice(0, separator); + return channelId || null; +} diff --git a/server/src/services/chat-channels/slack/task-start.ts b/server/src/services/chat-channels/slack/task-start.ts index 0ccb3793616..309953a69f9 100644 --- a/server/src/services/chat-channels/slack/task-start.ts +++ b/server/src/services/chat-channels/slack/task-start.ts @@ -1073,3 +1073,21 @@ export function createSlackTaskStart(db: Db, options: SlackTaskStartOptions) { } return { latestSlackDmControlThreadId, finalizeSlackTaskStartFailure, admitConfirmedSlackTaskStart, processSlackTaskStart, processPendingSlackTaskStarts }; } + +export function slackTaskStartActivityStatus(action: { + status: string; + updatedAt: Date; +}): string { + const age = Date.now() - action.updatedAt.getTime(); + if (action.status === "validating" && age >= SLACK_COMMAND_POST_STALE_MS) { + return "queued"; + } + if ( + (action.status === "received" && age >= SLACK_COMMAND_POST_STALE_MS) || + (action.status === "resolving" && + age >= SLACK_COMMAND_EXPLICIT_RETRY_STALE_MS) + ) { + return "delivery_unknown"; + } + return action.status; +} diff --git a/server/src/services/chat-channels/telegram/callback-notices.ts b/server/src/services/chat-channels/telegram/callback-notices.ts new file mode 100644 index 00000000000..fe926e033b1 --- /dev/null +++ b/server/src/services/chat-channels/telegram/callback-notices.ts @@ -0,0 +1,128 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction } from "../runtime-types.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + eq, +} from "drizzle-orm"; + +import { + chatEndpointResources, + chatExternalPrincipals, + chatIdentityLinks, +} from "@paperclipai/db"; + +import { + type TelegramCallbackReceipt, +} from "../../chat-telegram-ephemeral.js"; + +type TelegramCallbackNoticeAuthorityOptions = { + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean; linkedDenied: boolean; userId: string | null; sponsorUserId?: string | null }>; +}; + +export function createTelegramCallbackNoticeAuthority(options: TelegramCallbackNoticeAuthorityOptions) { + const { nonDirectDestinationAllowed, lockCurrentPrincipalAuthorization } = options; + + + // This is permission to return one fixed, neutral callback denial, not + // permission to execute the denied action or publish to the conversation. + // Retain the initial identity-policy snapshot so queued notices cannot cross + // a subsequent actor/link/reach change. No provider I/O occurs under locks. + async function lockTelegramCallbackNoticeAuthority( + tx: DbTransaction, + endpoint: EndpointRow, + receipt: TelegramCallbackReceipt, + principalId: string | null, + ): Promise { + if ( + endpoint.provider !== "telegram" || + endpoint.status !== "active" || + endpoint.companyId !== receipt.companyId || + endpoint.id !== receipt.endpointId || + endpoint.botExternalId !== receipt.botUserId || + !endpoint.providerAccountId || + !principalId || + Date.now() < receipt.receivedAtMs || + Date.now() >= receipt.deadlineAtMs + ) + return null; + const resource = + receipt.chatType === "private" + ? null + : await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.providerResourceId, receipt.chatId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if ( + receipt.chatType === "private" + ? !endpoint.allowDirectMessages + : !resource || !nonDirectDestinationAllowed(endpoint, resource) + ) + return null; + const principal = await tx + .select() + .from(chatExternalPrincipals) + .where( + and( + eq(chatExternalPrincipals.companyId, endpoint.companyId), + eq(chatExternalPrincipals.provider, endpoint.provider), + eq( + chatExternalPrincipals.providerAccountId, + endpoint.providerAccountId, + ), + eq(chatExternalPrincipals.id, principalId), + eq(chatExternalPrincipals.externalId, receipt.receiverUserId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if (!principal || principal.kind !== "user" || principal.isBot) return null; + const authorization = await lockCurrentPrincipalAuthorization( + tx, + endpoint, + principal.id, + ); + const link = await tx + .select({ + status: chatIdentityLinks.status, + userId: chatIdentityLinks.paperclipUserId, + }) + .from(chatIdentityLinks) + .where( + and( + eq(chatIdentityLinks.companyId, endpoint.companyId), + eq(chatIdentityLinks.endpointId, endpoint.id), + eq(chatIdentityLinks.principalId, principal.id), + ), + ) + .then((rows) => rows[0] ?? null); + return createHash("sha256") + .update( + JSON.stringify([ + endpoint.companyId, + endpoint.id, + receipt.botUserId, + resource?.id ?? null, + principal.id, + principal.externalId, + link, + authorization, + endpoint.allowUnlinkedPeople, + endpoint.sponsorUserId, + ]), + ) + .digest("hex"); + } + return { lockTelegramCallbackNoticeAuthority }; +} diff --git a/server/src/services/chat-channels/telegram/messages.ts b/server/src/services/chat-channels/telegram/messages.ts index 152b6478533..ac75997b145 100644 --- a/server/src/services/chat-channels/telegram/messages.ts +++ b/server/src/services/chat-channels/telegram/messages.ts @@ -1,3 +1,4 @@ +import type { Attachment } from "chat"; import { createHash } from "node:crypto"; import type { chatDeliveries } from "@paperclipai/db"; import type { ChatProvider } from "@paperclipai/shared"; @@ -221,3 +222,14 @@ export function telegramMessageSentAt(raw: unknown): Date | null { const sentAt = new Date(value * 1_000); return Number.isFinite(sentAt.getTime()) ? sentAt : null; } + +export function telegramMarkdownAttachment(text: string): Attachment { + const data = Buffer.from(text, "utf8"); + return { + data, + mimeType: "text/markdown; charset=utf-8", + name: "paperclip-response.md", + size: data.byteLength, + type: "file", + }; + } From 4b958ecbd8f33a7782fe6f29ec825f80f636b833 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 12 Oct 2026 15:59:27 -0700 Subject: [PATCH 11/15] fix(server): keep classified provider quota runs out of Sentry (#16029) A run that stops because the user's provider account hit its usage limit is an expected wait, not a Paperclip defect. The adapter already classifies it (errorCode and resultJson.errorFamily are both provider_quota) and recovery schedules a retry at the reset time. Skip the Sentry report only when both adapter-owned fields agree, the run status is failed, no signal stopped it, and the failure did not happen during setup. Timeouts, cancellations, signals, other codes and missing or mismatched families still report. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Dotta --- .../__tests__/run-failure-report.test.ts | 33 +++++++++++++++++++ server/src/services/run-failure-report.ts | 11 +++++++ 2 files changed, 44 insertions(+) diff --git a/server/src/services/__tests__/run-failure-report.test.ts b/server/src/services/__tests__/run-failure-report.test.ts index 0c1d83de7b7..111be4faf60 100644 --- a/server/src/services/__tests__/run-failure-report.test.ts +++ b/server/src/services/__tests__/run-failure-report.test.ts @@ -301,6 +301,39 @@ describeEmbeddedPostgres("reportRunFailure", () => { expect(mockCaptureRunFailure).toHaveBeenCalledWith(expect.objectContaining({ agentAdapter: "unknown" })); }); + function providerQuotaRun(overrides: Partial = {}) { + return buildRun({ + error: "ACP agent reported a terminal limit failure.\nYou've hit your session limit · resets 2am (UTC)", + errorCode: "provider_quota", executionStage: "dispatching", exitCode: 1, signal: null, + resultJson: { errorFamily: "provider_quota", stopReason: "adapter_failed" }, ...overrides, + }); + } + + it.each(["claude_local", "codex_local"])("keeps a classified %s provider quota wait local", async (adapterType) => { + await seedCompanyAndAgent({ adapterType }); + const [run] = await db.insert(heartbeatRuns).values(providerQuotaRun()).returning(); + const before = structuredClone(run); + for (const phase of [undefined, "execute"] as const) await reportRunFailure(db, run!, { phase }); + expect(mockCaptureRunFailure).not.toHaveBeenCalled(); + expect(await db.select().from(heartbeatRuns)).toEqual([before]); + }); + + it.each([ + { status: "timed_out" }, { status: "cancelled", startedAt: new Date(0) }, { signal: "SIGTERM" }, + { errorCode: "adapter_failed" }, { errorCode: "claude_transient_upstream" }, + { resultJson: {} }, { resultJson: null }, { resultJson: { errorFamily: "transient_upstream" } }, + ])("reports a provider quota outcome without matching adapter evidence: %j", async (overrides) => { + await seedCompanyAndAgent({ adapterType: "claude_local" }); + await reportRunFailure(db, providerQuotaRun(overrides), { phase: "execute" }); + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + }); + + it("keeps a provider quota code reportable during setup", async () => { + await seedCompanyAndAgent({ adapterType: "claude_local" }); + await reportRunFailure(db, providerQuotaRun(), { phase: "setup" }); + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + }); + function aiSelectionRun(overrides: Partial = {}) { return buildRun({ errorCode: "configuration_incomplete", diff --git a/server/src/services/run-failure-report.ts b/server/src/services/run-failure-report.ts index a059bf523d0..60e6309e541 100644 --- a/server/src/services/run-failure-report.ts +++ b/server/src/services/run-failure-report.ts @@ -164,6 +164,16 @@ function isHermesConnectionFailure(run: HeartbeatRun, options: RunFailureReportO (connection.reason === "remote_unavailable" && code === "hermes_gateway_upstream_error"); } +/** + * The adapter proved the user's provider account is out of quota, and recovery + * waits for its reset. Both fields come from the adapter's own classifier. + */ +function isProviderQuotaFailure(run: HeartbeatRun, options: RunFailureReportOptions): boolean { + return run.status === "failed" && run.errorCode === "provider_quota" && + asRecord(run.resultJson)?.errorFamily === "provider_quota" && + options.phase !== "setup" && run.signal == null; +} + /** * Report a terminal run failure to Sentry. Returns at once for any status * other than failures and unexpected started cancellations. Never throws — a Sentry failure or a @@ -182,6 +192,7 @@ export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureR if (isAiConnectionSelectionBlocker(run, options)) return Promise.resolve(); if (isLocalPathWorkspaceConfigurationBlocker(run, options)) return Promise.resolve(); if (isGitConnectionFailure(run, options)) return Promise.resolve(); + if (isProviderQuotaFailure(run, options)) return Promise.resolve(); const runStatus = run.status; const report = captureTerminalRunFailure(db, run, runStatus, options); pendingRunFailureReports.add(report); From d69d94a0f828931a0bf9ff7e910216255c8a8f96 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 20:02:06 -0500 Subject: [PATCH 12/15] docs: record startup ownership and production readiness gates Keep feature and access maps synchronized with the scoped cancellation repair, and retain the exact source and unresolved installed/staging qualification. Co-Authored-By: Paperclip --- doc/plans/2026-10-06-default-agent-runner.md | 59 ++++++++++++++++++++ maps/agent-access-map/runs-adapters.md | 18 ++++++ maps/feature-map/runs-adapters.md | 9 +++ 3 files changed, 86 insertions(+) diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 99feb3ddb95..3a0b2ff9332 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -2,6 +2,65 @@ Updated 2026-10-12. +## Production readiness: owned startup correction + +The human requests production readiness; preparation and normal sequential +merges remain authorized. Production deployment and provisioning-default +promotion remain separate actions. Nothing in this checkpoint is merged or +deployed, and the stack is not production-ready. + +Runtime prerequisite behavior is `ce23716503a2c9158b5a7ab74ab61571ff4ee7a3` +on `codex/runner-native-platform-prerequisites`, in +[#16028](https://github.com/paperclipai/paperclip/pull/16028). Final qualification +reproduced close returning before credential preparation drained, followed by +registration and launch. Both direct close and real driver cancellation failed +on literal `5a9ce11`. The initial shared repair from Claude commit `463ebb` is +reused, with independently identified duplicate-admission, shutdown-readiness and +resume ordering corrections. All five source findings are closed in matching +source review. Absolute cleanup deadlines, primary cancellation errors, +unauthenticated-process protection and existing agents' runner choices remain. + +The existing suites pass 32 credential-free controls across three files; both +canonical runner TypeScript no-emit checks pass. No new suite, local Rust/Docker +build, provider call or credential-content read was used. Actual daemon +readiness/recovery controls remain **hosted-CI required**: local worktrees lack +the existing fake-Codex/fake-harness executables. The failed local native attempt +never reached the readiness assertion and is not qualification. Receipts are +`/private/tmp/pr2-463ebb-owned-startup-review-closure-receipt.json` and +`/private/tmp/ci-final-owned-startup-five-gap-closure-review.json`. + +Normal automatic merge is disabled and the prerequisite is draft while this +correction receives fresh full CI, package qualification and 5/5 review. +Earlier green checks at `5a9ce11` remain historical. Actual code-owner approval +is still required; no protection is bypassed. The updated runs/adapters map pair +records source-only scope, bringing the prerequisite review to 61 files. + +Codex remains `1a0ca66242121e39d88455aa2b52a07acc4a07d9` in +[#15683](https://github.com/paperclipai/paperclip/pull/15683), based on the previous +prerequisite. The Claude owner retains its branches, cached guided-setup fix, +verification and separate $50 allowance. Coordinate reconciliation of the common +repair before rewriting those branches; don't duplicate it in a leaf slice. + +The idle [local preview](http://127.0.0.1:3125/COD/agents/all) still serves literal +behavior `fe1ecf1c34`, recovery ready and zero active runs. Its four saved +legacy/native configurations and two historical native records remain preserved; +it has not been refreshed to this new runtime. Final local/managed acceptance +must use the tested source, independent file checks and follow-up continuity. + +The prepared private execution checklist reuses existing evals, installation, +Cloud composition and campaign paths. Required holds are final source/image/ +migrator, the actual verified-master private distribution, pinned QA onboarding, +warm/cold signup, production baseline and previous-image compatibility. Production +Fleet Admin sign-in is pending. Current paid holds remain $248.179254104 of $250; +unknown/cleanup holds stay reserved, and the unstarted managed envelope is $2.50. +No new image, paid resource, rollout or default promotion was dispatched. + +Next action: publish this reviewed prerequisite correction, require fresh hosted +gates, reconcile Codex and Claude with its actual source, then resume the existing +remote package/immutable preview and dedicated staging qualification. Merge ready +slices normally in the agreed order; actual production readiness requires the +private distribution and rollback/forward-recovery evidence afterward. + ## Final review checkpoint: shared fixes and sandbox qualification The user authorizes normal sequential merges after the applicable gates pass. diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index bd2f509c545..93c0adc6bd7 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -9,6 +9,11 @@ Each capability needs operation-level review before its entire scope can be qualified. No live agent execution is claimed. Follow the [map contract](../README.md) for format, permission checks, evidence and maintenance. +Scoped native cancellation source review: 2026-10-12 at +`ce23716503a2c9158b5a7ab74ab61571ff4ee7a3`, limited to owned startup and +shutdown/readiness ordering. The original baseline and review depth remain for +unrelated operations; no live provider or agent authorization is claimed. + ## `availability` Review: `baseline`. @@ -109,3 +114,16 @@ own policy and access review. Gaps: Execution context reset is not a permitted generic native API mutation or an ordinary agent management path. + +Scoped Evidence: `source-only` — the +[native transport](../../packages/paperclip-runner/src/live/runnerd-codex-transport.ts) +joins owned startup before certifying cleanup. Rejected duplicates cannot replace +that owner or mutate recovery state. Unconfirmed preparation or registration +retains recovery state; late registration may release only its own route and +cannot launch after close. Shutdown interrupts readiness without granting +authority over an unauthenticated adopted process. + +Scoped Verification: `not-run` — verify allowed/denied access using the actual +actor independently from the credential-free ownership controls. This correction +adds no account-setup, cleanup-override or session-reset authority. The real native +readiness/recovery fixtures and installed provider acceptance remain separate gates. diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index bec86d34f2c..89c1c6c7d96 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -48,3 +48,12 @@ Manual: Start a disposable task, open its running transcript, and follow it to a - The run log is instance database data; it is not the opt-out first-party Telemetry system or operator-configured OpenTelemetry tracing. - Authentication/model discovery does not establish tool, image, steering, or resumption capability parity. - Use [recovery](./recovery.md) for interrupted runs and [execution environments](./execution-environments.md) for local/SSH/sandbox differences. + +Native setup retirement joins owned startup before cleanup can be confirmed. +[Transport ownership controls](../../packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts) +hold preparation or registration without launching a provider, then check bounded +close, rejected duplicate requests, no later launch and retention of unconfirmed +state. Readiness observes shutdown; resumed startup cannot reactivate ingress +after close. These are lifecycle controls, separate from installed provider proof. +The real daemon readiness and recovery controls require their existing native +fixtures in hosted CI; passing credential-free controls does not qualify them. From c1b9933a242ffde3565d4960c0977a34bfb590ae Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 20:33:39 -0500 Subject: [PATCH 13/15] chore: follow source comment contribution rules Co-Authored-By: Paperclip --- .github/workflows/release-smoke.yml | 23 +++++++++---------- .github/workflows/release-verify.yml | 21 ++++++----------- .github/workflows/release.yml | 14 +++++------ .../src/ssh-command-managed.test.ts | 1 - packages/adapter-utils/src/ssh.ts | 2 -- .../scripts/stage-release-runner-binaries.mjs | 8 ------- .../scripts/stage-runner-binary.mjs | 2 -- .../scripts/stage-runner-binary.test.mjs | 2 -- .../drivers/codex/codex-startup-trust.test.ts | 2 -- .../src/drivers/codex/codex-startup-trust.ts | 2 -- .../src/live/runnerd-codex-transport.test.ts | 7 ------ .../src/live/runnerd-codex-transport.ts | 12 ---------- .../grok-public-install-sandbox.test.mjs | 3 --- .../release-verify-workflow.test.mjs | 4 ---- scripts/dev-runner-native-binary.mjs | 3 --- scripts/dev-runner.ts | 2 -- scripts/grok-public-install-sandbox.mjs | 6 ----- scripts/pi-public-install-probe.mjs | 2 -- scripts/prepare-linux-arm64-qualification.sh | 3 --- scripts/release.sh | 3 --- scripts/verify-grok-npm-install.mjs | 10 -------- .../dev-runner-native-binary.test.ts | 1 - .../src/__tests__/environment-service.test.ts | 2 -- server/src/__tests__/remove-test-home.test.ts | 1 - server/src/__tests__/remove-test-home.ts | 6 +---- .../environment-custom-image-runtime.ts | 1 - server/src/services/environment-probe.ts | 2 -- server/src/services/environments.ts | 2 -- .../services/local-ai-browser-login.test.ts | 2 -- server/src/services/local-ai-browser-login.ts | 2 -- .../native-runtime/native-codex-runner.ts | 3 --- .../native-session-executor.test.ts | 2 -- .../native-runtime/native-session-executor.ts | 14 ----------- tests/runner-e2e/installed-cli.ts | 8 ------- tests/runner-e2e/live-fixtures.ts | 4 ---- 35 files changed, 25 insertions(+), 157 deletions(-) diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 6301a4d1c1a..40fa1753466 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -227,17 +227,17 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ inputs.qualification_source_sha }} persist-credentials: false - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 with: version: 9.15.4 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: node-version: 24 - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: pattern: ${{ needs.qualification_daemons.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs @@ -275,7 +275,7 @@ jobs: fi - name: Retain completed package assembly even after a later Linux failure if: ${{ !cancelled() && steps.pack_identity.outputs.available == 'true' }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: ${{ inputs.artifact_name }}-public-packages path: ${{ runner.temp }}/public-runner-packs/ @@ -292,17 +292,17 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.qualification_daemons.outputs.native_consumer_matrix) }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ inputs.qualification_source_sha }} persist-credentials: false - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 with: version: 9.15.4 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: node-version: 24 - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: ${{ inputs.artifact_name }}-public-packages path: ${{ runner.temp }}/public-runner-packs @@ -310,9 +310,8 @@ jobs: run: pnpm install --ignore-scripts --no-frozen-lockfile - name: Checkout immutable ARM qualification tooling if: ${{ matrix.target == 'linux-arm64' }} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: - # job.workflow_* identifies this reusable workflow, not its caller. repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: .qualification-tooling @@ -363,7 +362,7 @@ jobs: if [[ "$TARGET" == linux-arm64 ]]; then mode=--linux-arm64-consumer; fi node scripts/verify-grok-npm-install.mjs "$mode" \ "$RUNNER_TEMP/public-runner-packs" "$SOURCE_SHA" "$RUNNER_TEMP/native-public-install.json" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() with: name: ${{ inputs.artifact_name }}-${{ matrix.target }} diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index b1c3d61694a..a4632dfb442 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -527,11 +527,11 @@ jobs: matrix: ${{ steps.targets.outputs.matrix }} native_consumers: ${{ steps.targets.outputs.native_consumers }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ inputs.ref }} persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: node-version: 24 - name: Preserve the immutable source's platform contract @@ -564,8 +564,6 @@ jobs: && Object.keys(contract).sort().join() === 'schema,targets', 'Unknown exact-source daemon platform contract'); declared = contract.targets; } else { - // Frozen pre-contract sources used a literal export. Read that one - // declaration as JSON data; never import or evaluate preview code. const legacy = readBlob('packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs'); const declarations = [...(legacy ?? '').matchAll(/^[ \t]*export const RELEASE_RUNNER_TARGETS = (?:Object\.freeze\()?(\[[^\r\n]*\])\)?;[ \t]*$/gm)]; assert.equal(declarations.length, 1, 'Unknown exact-source daemon platform contract'); @@ -596,14 +594,14 @@ jobs: outputs: artifact_prefix: ${{ steps.identity.outputs.artifact_prefix }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ inputs.ref }} persist-credentials: false - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 with: version: 9.15.4 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: node-version: 24 - name: Bind immutable producer source and target @@ -640,18 +638,13 @@ jobs: fi rustup toolchain list - name: Cache native Rust dependencies - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 with: workspaces: ${{ steps.native_rust_workspace.outputs.path }} -> target - # Compiler/environment and Cargo lock hashes are added by the action. - # Also bind generated contracts outside the Rust workspace, while - # always recompiling workspace code and recording actual new bytes. shared-key: release-native-v2-${{ matrix.host }}-${{ matrix.target }}-${{ hashFiles('packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/src/**', 'packages/paperclip-runner/*.json') }} cache-workspace-crates: false cache-bin: false cache-on-failure: false - # A master workflow can verify an unmerged preview ref. Those jobs - # restore only; they never write master's trusted dependency cache. save-if: ${{ github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }} - name: Install dependencies run: pnpm install --no-frozen-lockfile @@ -694,7 +687,7 @@ jobs: run: | node packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs record "$SOURCE_SHA" "$TARGET" \ packages/paperclip-runner/dist/bin/paperclip-runnerd "$RUNNER_TEMP/runner-release/$TARGET" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: ${{ steps.identity.outputs.artifact_prefix }}-${{ matrix.target }} path: ${{ runner.temp }}/runner-release/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a676cf55a28..19d2853b4f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -341,8 +341,6 @@ jobs: run: node scripts/cloud-source-verification.mjs "$SOURCE_SHA" release_runner_source: - # Old promotions retain their already-shipped packaging contract. New - # sources opt into source-declared native target assembly in their release.sh. needs: [verify_canary, select_nightly, select_beta, preflight_stable] if: >- !cancelled() && ( @@ -357,7 +355,7 @@ jobs: sha: ${{ steps.source.outputs.sha }} required: ${{ steps.source.outputs.required }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ needs.preflight_stable.outputs.sha || needs.select_beta.outputs.sha || needs.select_nightly.outputs.sha || github.sha }} persist-credentials: false @@ -427,7 +425,7 @@ jobs: - name: Download exact-source release daemon receipts if: needs.release_runner_source.outputs.required == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs @@ -724,7 +722,7 @@ jobs: - name: Download exact-source release daemon receipts if: needs.release_runner_source.outputs.required == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs @@ -1003,7 +1001,7 @@ jobs: - name: Download exact-source release daemon receipts if: needs.release_runner_source.outputs.required == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs @@ -1384,7 +1382,7 @@ jobs: - name: Download exact-source release daemon receipts if: needs.release_runner_source.outputs.required == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs @@ -1442,7 +1440,7 @@ jobs: - name: Download exact-source release daemon receipts if: needs.release_runner_source.outputs.required == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs diff --git a/packages/adapter-utils/src/ssh-command-managed.test.ts b/packages/adapter-utils/src/ssh-command-managed.test.ts index 12d7c7f7788..57ed8b5d618 100644 --- a/packages/adapter-utils/src/ssh-command-managed.test.ts +++ b/packages/adapter-utils/src/ssh-command-managed.test.ts @@ -5,7 +5,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); - // Execute the authored remote command locally, through both subprocess paths. return { ...actual, execFile: vi.fn((file: string, args: string[], options: object, callback: Parameters[3]) => { diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index 342ca553bd1..783b5d37a77 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -68,8 +68,6 @@ export function createSshCommandManagedRuntimeRunner(input: { const envPrefix = envEntries.length > 0 ? `env ${envEntries.map(([key, value]) => shellQuote(`${key}=${value}`)).join(" ")} ` : ""; - // Keep sh -c's command name and positional arguments intact. Flattening - // its script loses $0/$1/... and breaks native runner launch/identity. const commandScript = `exec ${envPrefix}${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`; const remoteCommand = `cd ${shellQuote(cwd)} && ${commandScript}`; diff --git a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs index 61bdd2c2ac4..5f82bd937f1 100644 --- a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs +++ b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs @@ -1,4 +1,3 @@ -// Assemble the four independently built release daemons before npm packing. // Usage: node packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs MANIFEST.json // Manifest: {sourceRevision, platforms: {"darwin-arm64": {path, sha256}, ...}, remoteProviderPack: {path, sha256}}. import { createHash } from "node:crypto"; @@ -31,8 +30,6 @@ function regularFile(path, maximumSize) { return readFileSync(path); } -// Production TypeScript contracts are built by the existing producer step. -// Foreign artifacts are data: the assembler never executes another target. async function metadataContract() { const { PAPERCLIP_RUNNER_BUILD_METADATA } = await import("../dist/evals/build-metadata.js"); const { parsePaperclipRunnerdBuildMetadata } = await import("../dist/evals/runnerd-artifact.js"); @@ -112,16 +109,12 @@ async function main() { if (packBytes.length > 512 * 1024 || "sha256:" + createHash("sha256").update(packBytes).digest("hex") !== entry.sha256) throw Error("Remote provider-pack release manifest digest mismatch"); packIdentity = verifyReleaseProviderPack(JSON.parse(packBytes), manifest.sourceRevision); } else if (daemonOnly) { - // Preserve a qualified same-source Linux pack already materialized by the - // normal producer. It never becomes a Mac provider-pack qualification. packIdentity = existingReleaseProviderPack(root, manifest.sourceRevision); } const platforms = {}; for (const artifact of verified) { const relative = `${artifact.target}/paperclip-runnerd`, destination = join(root, "dist", "bin", relative); mkdirSync(dirname(destination), { recursive: true }); copyFileSync(artifact.source, destination); chmodSync(destination, 0o755); - // Copied Mach-O inodes need a fresh ad-hoc signature on macOS. Record the - // resulting packaged bytes, as well as the verified build artifact identity. if (process.platform === "darwin" && artifact.target.startsWith("darwin-")) { execFileSync("codesign", ["--force", "--sign", "-", destination], { stdio: "pipe", timeout: 30_000 }); } @@ -131,7 +124,6 @@ async function main() { if (packBytes) { const destination = join(root, "dist", "remote-provider-packs", "linux-x64", "provider-pack.json"); mkdirSync(dirname(destination), { recursive: true }); - // Replace the inode so repeat assembly can preserve a read-only manifest. const staged = `${destination}.staging-${process.pid}`; try { writeFileSync(staged, packBytes, { mode: 0o444, flag: "wx" }); diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.mjs index 581ee2867e8..6b415d58db5 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.mjs @@ -32,8 +32,6 @@ export async function stageBuiltRunnerBinary(packageRoot) { const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; const source = path.join(packageRoot, "runner", "target", "release", executable); const binaryRoot = path.join(packageRoot, "dist", "bin"); - // Source builds are separate from the source-bound release targets and their - // manifest. The dev supervisor selects this fresh output for its server. await stageRunnerBinary(source, path.join(binaryRoot, executable)); } diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs index 9c0c5656379..ddffc53e4c8 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs @@ -140,8 +140,6 @@ test("a source rebuild selects fresh dev bytes while preserving release binaries const copyHostFixture = async (input, output) => { await copyFile(input, output); if (process.platform === "darwin") { - // System fixtures can be universal; Cargo release daemons are thin. - // Extract only this host slice before exercising the native header gate. const slices = execFileSync("/usr/bin/lipo", ["-archs", input], { encoding: "utf8" }).trim().split(/\s+/); const architecture = slices.find(value => process.arch === "arm64" ? value.startsWith("arm64") : value === "x86_64"); assert.ok(architecture); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts index 02d377d0451..e8ef76d4d72 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts @@ -31,8 +31,6 @@ vi.mock("smol-toml", async (importOriginal) => { ...actual, parse: (...args: Parameters) => { const result = actual.parse(...args); - // Exercise both table representations independently of the installed - // parser version while retaining the real TOML values. setTablePrototypes(result); return result; }, diff --git a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts index 488b025148d..fb748ae1697 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts @@ -92,8 +92,6 @@ export function trustCodexStartupRoot(codexHome: string, cwd: string): void { const path = join(codexHome, "config.toml"); const source = existsSync(path) ? readFileSync(path, "utf8") : ""; const config = parse(source); - // Retain the parser's table prototypes so strict candidate comparison also - // works with parsers that return null-prototype TOML tables. const projects = config.projects ?? parse(""); if ( typeof projects !== "object" || diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index f9cd2051838..65b75bea7d2 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -396,7 +396,6 @@ it("publishes spawned runner ownership before waiting for provider startup", asy }); const opening = bundle.transport.request("thread/start", { cwd: tmpdir(), dynamicTools: codexSemanticToolSpecs() }); try { - // Same-tick recovery must not mutate an already admitted fresh startup. await expect(bundle.transport.request("thread/read", { threadId: "rejected-duplicate" })) .rejects.toThrow("PRP provider thread is already started"); await vi.waitFor(() => expect(onSpawn).toHaveBeenCalledOnce()); @@ -2223,7 +2222,6 @@ it("owns blocked Codex home preparation through driver abort without later regis markPreparationStarted(); await preparationReleased; preparationContinuedAfterClose = closeSettled; - // A synthetic, credential-free write makes the owned preparation visible. await mkdir(input.codexHome, { mode: 0o700 }); await writeFile(join(input.codexHome, "preparation-marker"), "fixture preparation drained\n", { mode: 0o600 }); }); @@ -2264,7 +2262,6 @@ it("owns blocked Codex home preparation through driver abort without later regis .then(value => { openingSettled = true; return { value }; }, error => { openingSettled = true; return { error }; }); try { await preparationStarted; - // A rejected duplicate must not replace the first admitted startup owner. await expect(transport.request("thread/start", { cwd: root, dynamicTools: [] })) .rejects.toThrow("PRP provider thread is already started"); controller.abort(cancelled); @@ -2863,8 +2860,6 @@ it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooper runtimeDirectory, provider: "codex", model: "gpt-test", timeoutMs: 1_000, environment: { PATH: "", OPENAI_API_KEY: "inert-test-key" }, transportOptions: { - // Registration and launch are external owners; hash real inert bytes - // without executing a daemon, Codex, or an ambient developer binary. runnerBinary: resolve(import.meta.dirname, "../../package.json"), codexCommand: resolve(import.meta.dirname, "../../package.json"), sourceCodexHome: "", runnerProcessLauncher: launch, @@ -2895,8 +2890,6 @@ it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooper expect(cleanup).not.toHaveBeenCalled(); expect((await stat(runtimeDirectory)).isDirectory()).toBe(true); if (mode === "late-release-error") expect((failure as AggregateError).errors).toContain(releaseError); - // A registration that finishes after the bounded return still releases - // its authority, without retroactively certifying or deleting state. returnRegistration(registered); finishRelease(); await vi.advanceTimersByTimeAsync(0); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index f099283b738..75a7543ff7e 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1335,7 +1335,6 @@ export interface CapabilityRunnerdCodexTransportOptions { controlPlaneRegistration?: ( authority: DurablePrpControlPlane, identity?: DurableRecoveryIdentity, - /** Setup owners abort preparation before draining or releasing its result. */ signal?: AbortSignal, ) => Promise<{ connectUrl?: string; @@ -4462,8 +4461,6 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } #trackStartup(start: () => Promise): Promise { - // Admit one owner before invoking startup. A duplicate request's rejection - // must never replace the preparation that close is responsible for joining. if (this.#startupOperation !== null) { return Promise.reject(new Error("PRP provider thread is already started")); } @@ -7101,8 +7098,6 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } await Promise.race([ ready(), this.#failureSignal, - // Readiness is a notification wait, not launch-material preparation. - // Its route owner is released by close; joining it first would cycle. this.#closedSignal.then(() => { this.#assertStartupOpen(); }), ]); this.#assertStartupOpen(); @@ -7153,7 +7148,6 @@ export function createCapabilityRunnerdCodexTransport( export const createRunnerdCodexTransport = createCapabilityRunnerdCodexTransport; -/** A setup turn uses the same daemon/provider boundary as ordinary native runs. */ export async function probeNativeRunnerEnvironment(options: { runtimeDirectory: string; provider: "codex"; @@ -7161,7 +7155,6 @@ export async function probeNativeRunnerEnvironment(options: { reasoningEffort?: string; environment: NodeJS.ProcessEnv; timeoutMs?: number; - /** The selected remote filesystem supplies this cwd; never inspect it on the controller. */ workingDirectory?: string; transportOptions?: CapabilityRunnerdCodexTransportOptions; onCodexCredentialRefresh?: (filename: string) => Promise; @@ -7182,9 +7175,6 @@ export async function probeNativeRunnerEnvironment(options: { let registrationOperation: Promise | undefined; let registrationCleanupConfirmed = true; const sourceCodexHome = options.transportOptions?.sourceCodexHome ?? resolveSourceCodexHome(environment); - // Never let an unbound home or an external-sandbox flag admit host state or - // weaken this read-only probe. The normal materializer copies bound auth and - // the managed provider projection into a new daemon-owned Codex home. delete environment.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX; environment.HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "probe-home"); environment.CODEX_HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "codex-home"); @@ -7303,8 +7293,6 @@ export async function probeNativeRunnerEnvironment(options: { } finally { clearTimeout(timer); controller.abort(failure ?? new Error("Native provider hello probe complete.")); - // Preserve private recovery state if the production close cannot confirm - // teardown; callers remove it only after an authoritative receipt. const teardownErrors: unknown[] = []; const teardownDeadline = Date.now() + 10_000; const awaitTeardown = async (pending: Promise, detail: string): Promise => { diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index 5b731c17a3f..e49cc4e372b 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -133,7 +133,6 @@ test('verification never elevates PR-controlled provisioning or cleanup on the h test('the image and Grok downloads start before staging and join without weakening the npm checks', () => { const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); - // The optional Mac consumer owns separate scratch and no Linux prefetches. const linuxSource = source.slice(source.indexOf("assert.equal(process.platform, 'linux'")); const position = fragment => { const index = linuxSource.indexOf(fragment); assert.notEqual(index, -1, `verifier must contain ${fragment}`); return index; }; // Exactly the pinned consumer image is pulled, for the platform the probes run on. @@ -332,8 +331,6 @@ test('the installed Codex probe exercises the public export and rejects incomple writeFileSync(legacyCommand, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); writeFileSync(index, `export const resolvePinnedCodexCommand = () => ${JSON.stringify(legacyCommand)};`); result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /Selected Codex platform version must match the qualified pin/); - // Native Codex uses protocol compatibility, not the ACPX version allowlist. - // Keep exact official-npm host provenance and the selected resource boundary. mkdirSync(join(legacy, 'vendor')); const legacyNative = join(legacy, 'vendor', triple, 'codex', process.platform === 'win32' ? 'codex.exe' : 'codex'); mkdirSync(join(legacy, 'vendor', triple, 'codex'), { recursive: true }); writeFileSync(legacyNative, nativeBytes, { mode: 0o755 }); diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index 152cf00edcd..c6db590248a 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -838,9 +838,6 @@ const waitForFixture = async (predicate, label) => { assert.fail(label); }; -// Execute the workflow's actual coordinator with two tiny local Node commands. -// Both must cross the start barrier before either can finish; no pnpm, Cargo, -// provider, registry, or Docker build runs in these fixtures. async function withNativePreparationFixture(mode, check) { const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'native-preparation-'))); let controller; @@ -1007,7 +1004,6 @@ test("ARM qualification binds separate product and reusable-workflow revisions e TOOLING_WORKFLOW_REF: "paperclipai/paperclip/.github/workflows/release-smoke.yml@refs/heads/qualification", TOOLING_WORKFLOW_PATH: ".github/workflows/release-smoke.yml", FIXTURE_PRODUCT_HEAD: productHead, FIXTURE_TOOLING_HEAD: toolingHead, FIXTURE_PREPARED: prepared, - // Reusable calls retain the caller context; it must not select tooling. GITHUB_REPOSITORY: "different/caller", GITHUB_SHA: "d".repeat(40), } }); assert.equal(result.status, expectedStatus, result.stderr); diff --git a/scripts/dev-runner-native-binary.mjs b/scripts/dev-runner-native-binary.mjs index d49eb01aaed..1df5eab04b3 100644 --- a/scripts/dev-runner-native-binary.mjs +++ b/scripts/dev-runner-native-binary.mjs @@ -50,9 +50,6 @@ export function resolveNativeRunnerDevBinary({ const flat = path.join(packageRoot, "dist", "bin", executable); const targetStat = statSync(target, { throwIfNoEntry: false }); const flatStat = statSync(flat, { throwIfNoEntry: false }); - // Release targets remain authoritative unless a newer source build matches - // this host. In particular, a build-host flat artifact cannot mask a remotely - // staged Mac target or force a local Cargo build. if (flatStat?.isFile() && (!targetStat?.isFile() || flatStat.mtimeMs > targetStat.mtimeMs) && runnerBinaryTarget(readFileSync(flat)) === `${platform}-${architecture}`) { return { binary: flat, sourceBuild: true }; diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 748df95ab72..407a5a34470 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -172,8 +172,6 @@ const env: NodeJS.ProcessEnv = { ...process.env, PAPERCLIP_UI_DEV_MIDDLEWARE: explicitUiDevMiddleware ?? (serveBuiltUiForManagedRuntime ? "false" : "true"), }; -// An automatic source-build override must not hide subsequent Rust edits from -// the watcher. Keep the operator's original choice separate from server env. const configuredRunnerBinary = env.PAPERCLIP_RUNNER_BINARY; if (mode === "dev") { diff --git a/scripts/grok-public-install-sandbox.mjs b/scripts/grok-public-install-sandbox.mjs index 3e674c1017b..52f188bae89 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -11,8 +11,6 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [ 'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', ]; -// setup-node provides its matching SDK alongside the executing Node binary. -// Deferred lifecycle scripts may read that SDK, but may never download headers. export function macNodeHeadersDirectory(nodeExecutable, nodeVersion) { assert.match(nodeVersion, /^\d+\.\d+\.\d+$/, 'Executing Node must have an exact release version'); const executable = realpathSync(nodeExecutable); @@ -200,7 +198,6 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi `; } -/** Installed bytes and consumer resolution must survive host protocol startup. */ export function assertInstalledNativeConsumerContinuity(before, after) { for (const field of ['lockSha256', 'runnerSha256', 'codexCommandSha256', 'codexNativeExecutableSha256']) { if (!/^[a-f0-9]{64}$/.test(before[field]) || before[field] !== after[field]) { @@ -209,7 +206,6 @@ export function assertInstalledNativeConsumerContinuity(before, after) { } } -/** The installed consumer must select its packaged host daemon, never PATH. */ export function installedDaemonProbeSource(outputRoot, consumerRoot, sourceRevision) { return ` import assert from 'node:assert/strict'; @@ -254,8 +250,6 @@ export function installedDaemonProbeSource(outputRoot, consumerRoot, sourceRevis `; } -// Hosted, credential-free Mac consumers defer only npm lifecycle execution. -// Offline npm flags are not an OS network boundary; sandbox-exec supplies it. export function macPublicInstallLifecycleProfile(ownedRoot) { if (!ownedRoot.startsWith('/') || ownedRoot.includes('\0')) throw new Error('Mac lifecycle requires an absolute owned root'); return `(version 1)\n(allow default)\n(deny network*)\n(deny file-write* (require-all (require-not (subpath ${JSON.stringify(ownedRoot)})) (require-not (literal "/dev/null"))))\n`; diff --git a/scripts/pi-public-install-probe.mjs b/scripts/pi-public-install-probe.mjs index 5827f9ad008..22f547d64a4 100644 --- a/scripts/pi-public-install-probe.mjs +++ b/scripts/pi-public-install-probe.mjs @@ -32,8 +32,6 @@ export async function verifyPiPackagedDaemon(server, daemon, target, runnerBinar assert.equal(`sha256:${createHash('sha256').update(bytes).digest('hex')}`, artifact.sha256); assert.equal(runnerBinaryTarget(bytes), target); } else { - // Existing host-only source packages have no multi-target release manifest. - // They must still use their exact normal flat daemon, never an override. assert.equal(await realpath(daemon), expectedPath); assert.equal(runnerBinaryTarget(await readFile(expectedPath)), target); } diff --git a/scripts/prepare-linux-arm64-qualification.sh b/scripts/prepare-linux-arm64-qualification.sh index 162e17db4e4..f1c29f501f6 100755 --- a/scripts/prepare-linux-arm64-qualification.sh +++ b/scripts/prepare-linux-arm64-qualification.sh @@ -1,6 +1,4 @@ #!/usr/bin/env bash -# Prepare the disposable GitHub ARM qualification host using stock distro files. -# Extraction skips package maintainer scripts and their global policy changes. set -euo pipefail if [[ "${GITHUB_ACTIONS:-}" != true || "${RUNNER_OS:-}" != Linux ]]; then @@ -87,7 +85,6 @@ test "$(od -An -tx1 -N4 "$stock_binary" | tr -d ' \n')" = 7f454c46 readelf -h "$stock_binary" | tee "$prerequisite_report_dir/bwrap-elf-header.txt" grep -E 'Machine:.*AArch64' "$prerequisite_report_dir/bwrap-elf-header.txt" -# Reject every target symlink, including dangling links, before privileged copy. test ! -L /usr/bin/bwrap if test -e /usr/bin/bwrap; then test -f /usr/bin/bwrap diff --git a/scripts/release.sh b/scripts/release.sh index a4a0073550f..dc9815610b1 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -284,9 +284,6 @@ release_info "" release_info "==> Step 2/7: Building workspace artifacts..." cd "$REPO_ROOT" pnpm build -# Hosted producers supply data-only, hash/source/architecture-bound daemon -# receipts. Assemble after the build (which stages its host binary), before -# public packages are packed. The server vendors this same compiled runner. if [ -n "${PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR:-}" ]; then node "$REPO_ROOT/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs" \ assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR" "$(git rev-parse HEAD)" diff --git a/scripts/verify-grok-npm-install.mjs b/scripts/verify-grok-npm-install.mjs index 6bd8754baf4..153c6632010 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -36,8 +36,6 @@ async function verifyMacConsumer(packageDirectory, sourceRevision, receiptPath) for (const directory of [consumer, home, tools, join(root, 'tmp'), join(root, 'cache')]) mkdirSync(directory); const npmCli = realpathSync(execFileSync('/usr/bin/which', ['npm'], { encoding: 'utf8' }).trim()); symlinkSync(process.execPath, join(tools, 'node')); - // npm subprocesses get a Node-only owned tool directory; no ambient harness - // command can hide missing installed dependencies. const env = { PATH: `${tools}:/usr/bin:/bin:/usr/sbin:/sbin`, HOME: home, TMPDIR: join(root, 'tmp'), npm_config_cache: join(root, 'cache'), npm_config_audit: 'false', npm_config_fund: 'false' }; const run = (command, args, cwd = consumer, runEnv = env) => execFileSync(command, args, { cwd, env: runEnv, @@ -147,13 +145,8 @@ async function verifyLinuxArmConsumer(packageDirectory, sourceRevision, receiptP const daemon = probe('daemon-probe.mjs', installedDaemonProbeSource(installed, '/consumer', sourceRevision)); assert.equal(daemon.target, 'linux-arm64'); const codex = probe('codex-probe.mjs', installedCodexProbeSource(installed + '/index.js', '/consumer', undefined, { nativeCodex: true })); - // Reuse the existing zero-turn hook as read-only verifier source. All - // production imports still resolve from the installed consumer graph. cpSync(join(repo, 'tests/runner-e2e/installed-cli.ts'), join(assets, 'installed-cli.ts')); writeFileSync(join(assets, 'types.js'), stripTypeScriptTypes(readFileSync(join(repo, 'tests/runner-e2e/types.ts'), 'utf8'))); - // The restricted install container deliberately forbids nested namespaces. - // Keep its lifecycle/no-network proof, then test the exact same bind-mounted - // graph on the native host using Codex's normal read-only namespace policy. const home = join(root, 'host-home'), hostTmp = join(root, 'host-tmp'); for (const directory of [home, hostTmp]) mkdirSync(directory, { mode: 0o700 }); const hostEnv = { PATH: `${dirname(process.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin`, HOME: home, TMPDIR: hostTmp, LC_ALL: 'C', TZ: 'UTC' }; @@ -330,9 +323,6 @@ try { timing.stage_pack_ms = Math.round(performance.now() - packStarted); Object.assign(timing, packTimings(packs, concurrency)); - // Preserve the exact Linux-produced public tarballs for independent Mac - // consumers even if a later Linux runtime probe fails. This is a packaging - // receipt, never an installation or provider-admission pass. if (process.env.PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR) { const output = resolve(process.env.PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR); assert.equal(JSON.parse(readFileSync(join(repo, 'server/dist/build-info.json'))).commit, sourceRevision); diff --git a/server/src/__tests__/dev-runner-native-binary.test.ts b/server/src/__tests__/dev-runner-native-binary.test.ts index 0e1ac24a070..cb7640ba43a 100644 --- a/server/src/__tests__/dev-runner-native-binary.test.ts +++ b/server/src/__tests__/dev-runner-native-binary.test.ts @@ -140,7 +140,6 @@ describe("paperclip runner native dev prerequisite", () => { fs.utimesSync(target, current, current); const input = { repoRoot: checkout.root, nativeRunnerRequired: true, platform: "darwin", architecture }; expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false); - // A newer Linux build-host output cannot mask the remote Mac artifact. fs.writeFileSync(checkout.binary, nativeHeader("linux", "x64")); const wrongHostNewer = new Date(Date.now() + 4000); fs.utimesSync(checkout.binary, wrongHostNewer, wrongHostNewer); diff --git a/server/src/__tests__/environment-service.test.ts b/server/src/__tests__/environment-service.test.ts index 4f953121e34..50d10441fae 100644 --- a/server/src/__tests__/environment-service.test.ts +++ b/server/src/__tests__/environment-service.test.ts @@ -154,8 +154,6 @@ describeEmbeddedPostgres("environmentService leases", () => { config: { config: { ...config, image: `registry.example.test/runtime@sha256:${"b".repeat(64)}` } }, driver: { driver: "ssh", config: { host: "fixture.example.test", username: "fixture" } }, }; - // Interleave the real probe UPDATE after the save is prepared but before its - // real UPDATE executes. This reproduces the stale route-read/write window. const delayedWriteDb = { update(table: typeof environments) { const prepared = db.update(table); diff --git a/server/src/__tests__/remove-test-home.test.ts b/server/src/__tests__/remove-test-home.test.ts index 0b4a5baf82b..4436a7711d1 100644 --- a/server/src/__tests__/remove-test-home.test.ts +++ b/server/src/__tests__/remove-test-home.test.ts @@ -63,7 +63,6 @@ describe("removeTestHome", () => { const home = makeHome(); fs.mkdirSync(path.join(home, "instances", "default"), { recursive: true }); fs.writeFileSync(path.join(home, "instances", "default", "paperclip.json"), "{}\n"); - // An unrelated ENOTEMPTY must not cause permission changes or be swallowed. const error = Object.assign(new Error("unrelated nonempty directory"), { code: "ENOTEMPTY" }); const removal = vi.spyOn(fs, "rmSync").mockImplementation(() => { throw error; }); const permissionChange = vi.spyOn(fs, "chmodSync"); diff --git a/server/src/__tests__/remove-test-home.ts b/server/src/__tests__/remove-test-home.ts index 1f2260f9bb2..39cbad6087b 100644 --- a/server/src/__tests__/remove-test-home.ts +++ b/server/src/__tests__/remove-test-home.ts @@ -9,8 +9,7 @@ import path from "node:path"; * in them read-only (`makeDirectoriesReadOnly` in * services/native-runtime/runtime-context.ts), and a run that is still * finishing when the worker exits can leave such a bundle under the worker's - * home. Removing a file inside a read-only directory fails with `EACCES` - * or, on macOS, the recursive removal can report `ENOTEMPTY` instead. + * home. Removing a file inside a read-only directory fails with `EACCES`, * Vitest reports it as an unhandled error, and the shard fails after every * test passed. So on a permission error every directory in the tree is made * writable first and the removal is tried once more. @@ -23,8 +22,6 @@ export function removeTestHome(home: string): void { const permissionError = isPermissionError(error); if (!permissionError && (error as NodeJS.ErrnoException | null)?.code !== "ENOTEMPTY") throw error; const restored = makeDirectoriesWritable(home); - // ENOTEMPTY also has unrelated causes; retry only after restoring an - // actual read-only directory, and still surface a failed second removal. if (!permissionError && !restored) throw error; fs.rmSync(home, options); } @@ -51,7 +48,6 @@ function makeDirectoriesWritable(directory: string): boolean { fs.chmodSync(directory, stat.mode | 0o700); restored = true; } catch { - // A directory that cannot be changed still fails the removal below, loudly. } } let entries: fs.Dirent[]; diff --git a/server/src/services/environment-custom-image-runtime.ts b/server/src/services/environment-custom-image-runtime.ts index 9babca70c11..20f596e01ee 100644 --- a/server/src/services/environment-custom-image-runtime.ts +++ b/server/src/services/environment-custom-image-runtime.ts @@ -13,7 +13,6 @@ import { readConfigValueAtPath, writeConfigValueAtPath } from "./json-schema-sec type TemplateRow = typeof environmentCustomImageTemplates.$inferSelect; export const ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY = "runtimeConfigBinding"; -/** Measured platform evidence is reserved to explicit server-side environment probes. */ export const SANDBOX_PLATFORM_PROBE_METADATA_KEY = "paperclipSandboxPlatformProbes"; export const ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS = [ "timeoutMs", diff --git a/server/src/services/environment-probe.ts b/server/src/services/environment-probe.ts index 8ecad38bc6f..1d6c8ccedc5 100644 --- a/server/src/services/environment-probe.ts +++ b/server/src/services/environment-probe.ts @@ -41,14 +41,12 @@ async function sandboxBinding(db: Db, environment: Environment, companyId: strin }); if (parsed.driver !== "sandbox") return null; const image = parsed.config.image; - // Names, mutable tags, provider defaults and snapshot names are not immutable identities. const template = selectedTemplate.value; if (!isImmutableImage(image) || ["snapshot", "template", "templateRef"].some(key => record(parsed.config)[key]) || (template && (template.templateKind !== "image" || template.bindingField !== "image"))) return null; return { baseConfigFingerprint: fingerprint(environment.config), runtimeConfigFingerprint: fingerprint(parsed.config as unknown as Record), image, template }; } -/** Read measured, company-scoped image evidence without leasing or contacting a provider. */ export async function readSandboxPlatformQualification(db: Db, environment: Environment, companyId: string): Promise { const proof = record(record(environment.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY])[companyId]); if (proof.v !== 1 || proof.companyId !== companyId || proof.baseConfigFingerprint !== fingerprint(environment.config)) return null; diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index 7bf26c9d419..b8922aa61d7 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -1144,8 +1144,6 @@ export function environmentService(db: Db) { values.metadata = userMetadata; } else { const replacement = userMetadata === null ? sql`null::jsonb` : sql`${JSON.stringify(userMetadata)}::jsonb`; - // Preserve the current private subtree in the same statement. A route's - // earlier read must never replace a newer concurrent probe receipt. metadataUpdate = sql`case when ${environments.metadata} ? ${SANDBOX_PLATFORM_PROBE_METADATA_KEY} then coalesce(${replacement}, '{}'::jsonb) || jsonb_build_object( ${SANDBOX_PLATFORM_PROBE_METADATA_KEY}::text, ${environments.metadata}->${SANDBOX_PLATFORM_PROBE_METADATA_KEY}) diff --git a/server/src/services/local-ai-browser-login.test.ts b/server/src/services/local-ai-browser-login.test.ts index 505b0bee18c..5f021570189 100644 --- a/server/src/services/local-ai-browser-login.test.ts +++ b/server/src/services/local-ai-browser-login.test.ts @@ -45,8 +45,6 @@ function targetPlatform(platform: string, architecture: string, glibc = true, gl } function resolveFixtureCodexCommand(home: string): string { - // Vitest's global dependency paths can supply a foreign bridge before the - // fixture wrapper. Reuse the existing Codex security test's closed Node seam. const source = ` import { resolveCodexCommand } from ${JSON.stringify(new URL("../../../packages/paperclip-runner/src/drivers/codex/codex-command.ts", import.meta.url).href)}; try { console.log(JSON.stringify({ command: resolveCodexCommand(${JSON.stringify(path.join(root!, "issuer.js"))}, { PATH: "/missing-codex" }, ${JSON.stringify(home)}) })); } diff --git a/server/src/services/local-ai-browser-login.ts b/server/src/services/local-ai-browser-login.ts index 755a1e41573..4ae7e26ab16 100644 --- a/server/src/services/local-ai-browser-login.ts +++ b/server/src/services/local-ai-browser-login.ts @@ -56,8 +56,6 @@ export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: s async start(_command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }> { let executable = "claude"; if (provider === "openai") { - // The installed Codex CLI is independent of the runner daemon's glibc floor. - // Supported CLI hosts use owned resolution; other platforms keep PATH lookup. const legacyPlatform = !((process.platform === "linux" || process.platform === "darwin") && (process.arch === "x64" || process.arch === "arm64")); try { diff --git a/server/src/services/native-runtime/native-codex-runner.ts b/server/src/services/native-runtime/native-codex-runner.ts index c5cda8a9942..f8e22050f29 100644 --- a/server/src/services/native-runtime/native-codex-runner.ts +++ b/server/src/services/native-runtime/native-codex-runner.ts @@ -208,7 +208,6 @@ export function resolvePaperclipRunnerBinary( ); } -/** Select controller-owned bytes for the actual remote host, never its controller. */ export async function resolveRemotePaperclipRunnerBinary(input: { runner: CommandManagedRuntimeRunner; remoteCwd: string; @@ -224,8 +223,6 @@ export async function resolveRemotePaperclipRunnerBinary(input: { } const configured = input.configuredPath?.trim(); if (configured && !isAbsolute(configured)) throw new Error("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH must be an absolute path"); - // A same-host sandbox can use the supervisor's fresh development artifact. - // Cross-platform targets must never inherit the controller's override. const controllerOverride = platform === process.platform && architecture === process.arch ? process.env.PAPERCLIP_RUNNER_BINARY : undefined; const binary = configured || (controllerOverride diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 6357d6ec8c8..4d42bc0785c 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -12354,8 +12354,6 @@ describe("runnerd provider runtime wiring", () => { { version: "0.156.0", model: "gpt-6.1-sol", floor: null, recover: true }, { version: "0.158.0", model: "gpt-6.1-sol", floor: null, recover: true }, ])("lets sandbox Codex $version reach actual model verification ($model, recover=$recover)", async ({ version, model, recover }) => { - // Version metadata is diagnostic. A working app-server reaches the model - // turn instead of being rejected or replaced by a static CLI version floor. const preparedModel = recover ? resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: { provider: "codex", model }, codexCliVersion: version, }).model : model; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index ed860b6eb73..3adcfefddf1 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -13385,7 +13385,6 @@ async function createRunnerdBackendWithinSessionClaim( } satisfies NativeSessionBackend & { bindManagedSession(session: NativeSession): NativeSession }; } -/** Shared task/setup preparation; setup exercises the same remote artifact contract. */ export function createRemoteNativeArtifactPreparation(input: { target: Extract; runner: CommandManagedRuntimeRunner; @@ -13398,7 +13397,6 @@ export function createRemoteNativeArtifactPreparation(input: { runnerRemoteCodexNpmSpec?: string | null; model?: string | null; reuseRetainedRunner?: boolean; - /** The selected Pi companion or bundled provider image verifies its target-platform artifact. */ targetPlatformArtifactVerified?: boolean; trace?: NativeRunTrace; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; @@ -13451,9 +13449,6 @@ export function createRemoteNativeArtifactPreparation(input: { } const versionOutput = `${versionResult.stdout}\n${versionResult.stderr}`; const version = parseCodexCliVersion(versionOutput); - // The install pin describes our tested image, not a CLI admission rule. - // Ordinary installations reach the real app-server/model probe; a version - // difference alone must neither fail setup nor replace a working CLI. if (version !== REMOTE_PROVIDER_PACK_PINS.codex && !reportedCodexVersions.has(version)) { reportedCodexVersions.add(version); await input.onLog?.( @@ -13463,9 +13458,6 @@ export function createRemoteNativeArtifactPreparation(input: { } }; - // Image policy: keep one latest stable CLI installation shared by native and - // local adapters. Preferred bin entries must point to that same installation; - // never bake an older global CLI alongside a private runner-only version. const discoverPreinstalledExecutable = async ( name: "paperclip-runnerd" | "codex", ) => { @@ -13500,8 +13492,6 @@ export function createRemoteNativeArtifactPreparation(input: { }; const prepare = async (requiredMode: "dial_wss" | "listen_ws") => { - // Compatibility metadata does not prove artifact identity. Reuse only the - // exact controller-owned bytes when the controller artifact is available. const matchesControllerRunnerArtifact = async (executable: string): Promise => { const expected = createHash("sha256") .update(readFileSync(controllerRunnerBinary)) @@ -13523,7 +13513,6 @@ export function createRemoteNativeArtifactPreparation(input: { /^[a-f0-9]{64}\s/.test(probe.stdout) && probe.stdout.trim().split(/\s+/)[0] === expected; } catch { - // An unavailable checksum uses the verified staging path. return false; } }; @@ -13572,9 +13561,6 @@ export function createRemoteNativeArtifactPreparation(input: { } } if (!runnerArtifactPrepared) { - // Upload the same artifact used for the controller identity. The server - // vendors the runner under vendor/paperclip-runner/bin, so the package - // development fallback cannot locate it in a deployed server. const sourceBinary = controllerRunnerBinary; if (!existsSync(sourceBinary)) { throw new Error("runner_remote_artifact_unavailable"); diff --git a/tests/runner-e2e/installed-cli.ts b/tests/runner-e2e/installed-cli.ts index 6bcfc6f005d..a7b84db3b0c 100644 --- a/tests/runner-e2e/installed-cli.ts +++ b/tests/runner-e2e/installed-cli.ts @@ -12,8 +12,6 @@ import { CREDENTIAL_NAMES, type MatrixExecution } from "./types.js"; export const installedCliKeys = ["PAPERCLIP_RUNNER_E2E_INSTALLED_CLI", "PAPERCLIP_RUNNER_E2E_INSTALLED_CLI_SHA256", "PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_ROOT", "PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_SHA256"] as const; const overrideKeys = ["PAPERCLIP_RUNNER_BINARY", "PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", "PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", "PAPERCLIP_RUNNER_ACPX_QUALIFICATION", "PAPERCLIP_ACPX_BUILTIN_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST"] as const; const suites = new Set(["extended-harnesses", "pi-native", "pi-controls", "rich-acp-warm-continuity"]); -// Reuse the production-onboarding, independent file/revision, and restart -// journeys. Installed proof does not add cases or relax their existing graders. const installedCodexCells = new Set([ "first-task.runner-codex.local.task-card-accept", "everyday-workflows.runner-codex.local.build-revise", @@ -63,7 +61,6 @@ export async function verifyInstalledCli(env: NodeJS.ProcessEnv, executions: rea return { schema: "paperclip.e2e.installed-cli-admission/v1", entry, cliRoot, cliSha256, serverRoot, serverEntry, serverSha256, version: cli.version, defaultRuntimeResolution: true, qualificationOverride: false }; } -/** Clean-install protocol proof only: no credentials, task, or provider turn. */ export async function probeInstalledCodexStartup(input: { consumerRoot: string; serverRoot: string; @@ -110,8 +107,6 @@ export async function probeInstalledCodexStartup(input: { const codexSha256 = digest(await readOwned(codexCommand, 512 * 1024 * 1024)); const codexVersion = execFileSync(codexCommand, ["--version"], { cwd: workspace, env: environment, encoding: "utf8", timeout: 10000, maxBuffer: 16384 }).trim(); if (!codexVersion || codexVersion.length > 200) throw new Error("Installed Codex startup returned no bounded version identity"); - // Keep the production security arguments; pin file-only auth for this - // credential-free fixture so macOS keychain accounts cannot be consulted. const args = runtime.createIsolatedCodexAppServerArgs(environment); args.splice(args.indexOf("app-server"), 0, "-c", 'cli_auth_credentials_store="file"'); bundle = runtime.createRunnerdCodexTransport({ provider: "codex", stateDirectory: directory, environment, @@ -156,9 +151,6 @@ export async function probeInstalledCodexStartup(input: { } catch (error) { cleanupFailures.push(error); } if (startupFailed) { const evidence = bundle?.evidence(); - // This zero-turn fixture has empty homes and no provider credentials. - // Report only bounded failure text and lifecycle state, never identities - // or raw durable state. A close failure cannot hide the startup cause. const bounded = (value: unknown) => String(value).replaceAll(directory, "[private-startup]").slice(0, 2000); const primaryMessage = bounded(startupFailure instanceof Error ? startupFailure.message : startupFailure); const diagnostic = { schema: "paperclip.e2e.installed-codex-startup-failure/v1", platform: `${process.platform}-${process.arch}`, diff --git a/tests/runner-e2e/live-fixtures.ts b/tests/runner-e2e/live-fixtures.ts index 0faf1a9d58a..e236bf8ac83 100644 --- a/tests/runner-e2e/live-fixtures.ts +++ b/tests/runner-e2e/live-fixtures.ts @@ -64,7 +64,6 @@ export interface LiveFixtureValues { agent: AgentRecord; project?: ProjectRecord; aiConnection?: ManagedAccountFixture; - /** Private setup receipt; its lease is included in existing runtime cost accounting. */ setupProbe?: { leaseId: string; durationMs: number; defaultRunner?: string }; onboardingRuntime?: { @@ -82,7 +81,6 @@ function value(resolved: ReadonlyMap, id: string): T { return result as T; } -/** Preserve task/run attribution and include only this fixture's explicit setup probe. */ export function selectFixtureRuntimeLeases(input: { leases: T[]; runIds: string[]; issueId?: string; setupProbeLeaseId?: string }): T[] { @@ -320,8 +318,6 @@ export async function setupLiveFixtures(input: { throw new Error("Native Codex qualification requires this fixture's immutable image-backed environment."); } const startedAt = Date.now(); - // One existing explicit connection probe: a fresh lease, bounded platform read, - // verified release, and server-owned image evidence before agent creation. const probe = await api.post<{ ok: boolean; details?: { platformQualificationRecorded?: boolean; leaseId?: string } }>( `/api/environments/${environment.id}/probe?companyId=${encodeURIComponent(company.id)}`, {}, { timeout: 330_000 }, From 64c074fb8da1b31f8d6042c4fef48dc4164e759b Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 20:33:39 -0500 Subject: [PATCH 14/15] docs: record runtime qualification and catalog review Co-Authored-By: Paperclip --- doc/plans/2026-10-06-default-agent-runner.md | 23 ++++++++++- maps/storybook-map/REVIEW.md | 27 +++++++++++++ maps/storybook-map/catalog.json | 42 ++++++++++---------- 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 3a0b2ff9332..7a35d1b4d39 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -33,7 +33,8 @@ Normal automatic merge is disabled and the prerequisite is draft while this correction receives fresh full CI, package qualification and 5/5 review. Earlier green checks at `5a9ce11` remain historical. Actual code-owner approval is still required; no protection is bypassed. The updated runs/adapters map pair -records source-only scope, bringing the prerequisite review to 61 files. +records source-only scope, bringing the prerequisite review to 63 files after the required Storybook +catalog review below. Codex remains `1a0ca66242121e39d88455aa2b52a07acc4a07d9` in [#15683](https://github.com/paperclipai/paperclip/pull/15683), based on the previous @@ -55,6 +56,26 @@ Fleet Admin sign-in is pending. Current paid holds remain $248.179254104 of $250 unknown/cleanup holds stay reserved, and the unstarted managed envelope is $2.50. No new image, paid resource, rollout or default promotion was dispatched. +Master reconciliation uses `4b958ecbd8f33a7782fe6f29ec825f80f636b833`. +The new contribution rules are satisfied: 157 added comment annotations were +removed from 35 files, with identical executable JavaScript/TypeScript emission, +parsed workflow values, 77 action pins and shell commands. No code file crosses +from below 1,000 lines to above that limit. Final focused checks pass 248 tests +(107 workflow/package, 54 runner, 42 server, 45 E2E support). Both runner no-emit +checks and the canonical server/E2E no-emit checks pass with isolated matching +workspace dependencies. Only JavaScript/TypeScript preparation ran locally; +root preview links and data stayed unchanged. The earlier linked-source failures +remain recorded and are superseded only by the new canonical check receipt. + +The existing Storybook catalog has 21 source-review fingerprint updates because +its declared runs/adapters map input changed. All affected stories, subjects, +wrappers and fixtures remain byte-identical to master. Review classifications +stay unchanged. The catalog check passes 223 files/2,210 stories; this is +structural/source evidence, not visual or live proof. Source receipts are +`/private/tmp/pr2-9f59-comments-cleanup-receipt.json`, +`/private/tmp/pr2-storybook-owned-final-receipt.json` and +`/private/tmp/pr2-frozen-canonical-noemit-receipt.json`. + Next action: publish this reviewed prerequisite correction, require fresh hosted gates, reconcile Codex and Claude with its actual source, then resume the existing remote package/immutable preview and dedicated staging qualification. Merge ready diff --git a/maps/storybook-map/REVIEW.md b/maps/storybook-map/REVIEW.md index a914397c46f..9ef9f47346c 100644 --- a/maps/storybook-map/REVIEW.md +++ b/maps/storybook-map/REVIEW.md @@ -158,3 +158,30 @@ reasons below. The owned static preview was left running for the requested test- Use `maintain-storybook` to correct drift and `review-storybook` to record separate source, render, interaction, visual-comparison, and production evidence. Both skills consume the feature map; neither changes its upstream contract. + +## Native runtime prerequisite: scoped source follow-up — 2026-10-12 + +Source comparison: `9f59ff36cf2da963709ca19c06ab005510c835a6` against actual +master `4b958ecbd8f33a7782fe6f29ec825f80f636b833`. The reviewed +`maps/feature-map/runs-adapters.md` bytes have SHA-256 +`ca5aff86608b7d7987dc0ee814475a7b93e3faff1f99ce090429d2e932b7e16b`. + +Twenty-one entries in Runs, harnesses, and model accounts and Task creation and +lifecycle had review-fingerprint drift. Their 117 unique declared sources have +one changed input: the runs-adapters feature recipe. All 21 story files and the +other 116 declared production subjects, wrappers and fixture sources are +unchanged from master. The recipe adds bounded native startup and registration +retirement controls. It separates those provider-free controls from real daemon +and installed-provider proof; it changes no represented UI or fixture contract. + +Refreshed only those 21 reviewed fingerprints. No story, fixture, export, stable +ID, title, capability reference or product/implementation/data classification +changed. Existing mixed and unreviewed parity limitations remain. No fixture +correction is needed for this scoped prerequisite; this does not establish full +state parity for the affected suites. + +This follow-up is source inspection only. Rendering, play assertions, visual +comparisons and production provider acceptance were not run. Native lifecycle +behavior remains with the linked transport controls and their existing hosted +CI fixtures. Catalog validation proves structure and reviewed dependency hashes, +not that these live journeys passed. diff --git a/maps/storybook-map/catalog.json b/maps/storybook-map/catalog.json index 3bdb0a34160..d9a6e3ee3e3 100644 --- a/maps/storybook-map/catalog.json +++ b/maps/storybook-map/catalog.json @@ -363,7 +363,7 @@ "ui/storybook/fixtures/onboardingEnvironment.ts", "ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx" ], - "fingerprint": "4dfc350a48d8f378eabf1b7785d1b867541121fa7715da5fd9812b28a0288d9b", + "fingerprint": "7b2571f03ffa3a559a9c228f78c87302d346183599b667c1102d131b4f33593a", "stableId": "onboarding-saved-connections", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/Onboarding/Saved connections" }, @@ -728,7 +728,7 @@ "ui/storybook/prototypes/AiConnectionUsageReview.tsx", "ui/storybook/stories/runs-adapters/ai-connection-usage.stories.tsx" ], - "fingerprint": "0b03669a6abba327c01222793a41d898958d50a483ed1a2e842c195484cb33ec", + "fingerprint": "293a8407e94d525533e5beb8920d78e4b47b2326568460bfe02bb3bdd9f81eaf", "stableId": "ai-connections-usage-limits", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Usage limits" }, @@ -765,7 +765,7 @@ "ui/storybook/prototypes/AiTaskConnectionReview.tsx", "ui/storybook/stories/runs-adapters/ai-connections.stories.tsx" ], - "fingerprint": "2f32110235d513dbfb6086126180d03107bc2167bc472da6280dfeb64629343a", + "fingerprint": "9a5d741ffbff64ab6e14e03130a1b7a53403d6e69ee48108ba1358d0093facf2", "stableId": "ai-connections-review", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Review" }, @@ -1374,7 +1374,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/tasks/chat-comments.stories.tsx" ], - "fingerprint": "aae493759f1217f0805fc6a577c8c1444d95ddbc3abc08cafe7288a543020657", + "fingerprint": "78ef704288c2c9ab63641e16b0e5fbe9d70bd81ec6dadacb1a31658388249c6d", "stableId": "product-chat-comments", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Chat & Comments" }, @@ -1496,7 +1496,7 @@ "ui/storybook/prototypes/completed-activity/CompletedActivityPreview.tsx", "ui/storybook/stories/tasks/completed-activity.stories.tsx" ], - "fingerprint": "76af14f51a89a98ed64685cee95edb29133f31bf6f3e8356c31a3b7c7b1d00b2", + "fingerprint": "7c91ff66b892ee3b50680e65cab8c83ec56851a75504ff5e3e6db4270f7b730e", "stableId": "tasks-completed-activity-preview", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Tasks/Completed activity preview" }, @@ -1546,7 +1546,7 @@ "ui/storybook/prototypes/composer-model-picker/fixtures.ts", "ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx" ], - "fingerprint": "1288567427591ad546f49303ae7922bfd2b02c01635528cc4ae5470b926b723d", + "fingerprint": "b4d5689a5fd6a891e06d98d698f2556832969ac67c56f2e2a8d9da26b0510616", "stableId": "composer-mobile-assignee-picker", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker" }, @@ -1574,7 +1574,7 @@ "ui/storybook/prototypes/composer-model-picker/fixtures.ts", "ui/storybook/stories/tasks/composer-model-picker.stories.tsx" ], - "fingerprint": "ecd36c7bcc1b331cc83aa50fc15bc18842e6fdb813f21889b30966c803204fcd", + "fingerprint": "f1ddb769d225b27ea4275da372027f7bc6f11ed079a8e7b4a3b7409744707b1f", "stableId": "composer-model-and-effort-picker", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker" }, @@ -1675,7 +1675,7 @@ "ui/storybook/prototypes/ConnectionPoolConnectorHost.tsx", "ui/storybook/stories/runs-adapters/connection-pool-connector.stories.tsx" ], - "fingerprint": "045ed080d3c5ebecf92131e090085065ca2753ac7eadd66e3386b88542ff543c", + "fingerprint": "f6208d2a6b919cbd511baefdb5e45f79eb2c2d851e6035ea9778a13fc827544c", "stableId": "connectors-pool-host", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/Connectors/Pool host" }, @@ -1701,7 +1701,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/runs-adapters/connection-pools.stories.tsx" ], - "fingerprint": "dd72a70a6dc0173d82c92dc9aedaba6b5e0414d2befb8a8d4ee38c5e3cce8494", + "fingerprint": "b614f0bcfb914ae817cbfba36d8dd80091068ad6e6a630ec239a7d9f7b3e05c1", "stableId": "ai-connections-connection-pools", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Connection pools" }, @@ -2117,7 +2117,7 @@ "ui/src/components/DotRunnerConnection.tsx", "ui/storybook/stories/runs-adapters/dot-runner-connection.stories.tsx" ], - "fingerprint": "a5fbe31bc2b35136830bfd8fb1cf10e1b35cd7cc6995f108ceca5a6773a4db73", + "fingerprint": "65219fd330a54350d2c91a485039d2a8dc6886cce3dbde64bce2b514d2bdcc95", "stableId": "assistant-connections-dot-runner", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/Assistant connections/Dot Runner" }, @@ -2930,7 +2930,7 @@ "ui/src/components/AdapterLoginChrome.tsx", "ui/storybook/stories/runs-adapters/local-ai-subscription.stories.tsx" ], - "fingerprint": "79d32a9424c87080ebaa86216c23e3c36fcb1d3c8f4051a109a201e4e3adf1c6", + "fingerprint": "7ec5fe9129699ed14dd55c4e3849c602100a1a934bad44b6fd6d31af4e8a0458", "stableId": "ai-connections-provider-routing-07-subscription-sign-in", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/07 Subscription sign-in" }, @@ -4019,7 +4019,7 @@ "ui/storybook/prototypes/provider-routing/story-support.ts", "ui/storybook/stories/runs-adapters/provider-routing-00-overview.stories.tsx" ], - "fingerprint": "9f1c436cf8ac4a409ca597d95abf4a975ab5a58be08c37edbcd44547bea04d55", + "fingerprint": "58ea208bbfd6bbc068ff89a49aa2c71d55f244710d84f8d8574511bcc3593257", "stableId": "ai-connections-provider-routing-00-overview", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/00 Overview" }, @@ -4069,7 +4069,7 @@ "ui/storybook/prototypes/provider-routing/story-support.ts", "ui/storybook/stories/runs-adapters/provider-routing-01-onboarding.stories.tsx" ], - "fingerprint": "17ed706f27cb9282f6918dd855160aeb6a63c003d7891c6fa6a336cd7cec1f8b", + "fingerprint": "f39e0ecde64690f67d56519efc0b7b45f790f21b7836057f6735fffaff3d3ef5", "stableId": "ai-connections-provider-routing-01-onboarding", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/01 Onboarding" }, @@ -4100,7 +4100,7 @@ "ui/storybook/prototypes/provider-routing/story-support.ts", "ui/storybook/stories/runs-adapters/provider-routing-02-connect.stories.tsx" ], - "fingerprint": "25d750d1d9ab3e1adf71bc1273d577e7b59939f6db1b96a55f59e6da3bea8b8e", + "fingerprint": "728d7d6f61446c644f7d1402b683ade30dbef0c893e409a5fa9526e247e1c179", "stableId": "ai-connections-provider-routing-02-connect", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/02 Connect" }, @@ -4150,7 +4150,7 @@ "ui/storybook/prototypes/provider-routing/story-support.ts", "ui/storybook/stories/runs-adapters/provider-routing-03-agent.stories.tsx" ], - "fingerprint": "dd04df0958753c6b4e3fbf97b118e1d69543c8e213970f2cfeffebdc5a7ecc91", + "fingerprint": "5d0c5dcd46b3814727322016a2bb4b58695f1435a9bd015c67898b549aac40fb", "stableId": "ai-connections-provider-routing-03-agent", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/03 Agent" }, @@ -4183,7 +4183,7 @@ "ui/storybook/prototypes/provider-routing/story-support.ts", "ui/storybook/stories/runs-adapters/provider-routing-04-manage.stories.tsx" ], - "fingerprint": "f549ac215f9307cb77deefce243283d673b497901c996f5be67eeff177420024", + "fingerprint": "34331e78cd92bccd47f5e2c7b83609e137846117e137615b4bd29bda065524e4", "stableId": "ai-connections-provider-routing-04-manage", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/04 Manage" }, @@ -4208,7 +4208,7 @@ "ui/storybook/prototypes/provider-routing/story-support.ts", "ui/storybook/stories/runs-adapters/provider-routing-05-recovery.stories.tsx" ], - "fingerprint": "5cfce358ed547b3eba402b1cdd5c58bac7ea71359c4edc7674c0c7e4de72e51e", + "fingerprint": "fe9596ebe4de1f2e315d6e024c636f56e0b253b3af5d3a9c4cd6a3015309e7e7", "stableId": "ai-connections-provider-routing-05-recovery", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/05 Recovery" }, @@ -4239,7 +4239,7 @@ "ui/storybook/prototypes/provider-routing/shared.tsx", "ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx" ], - "fingerprint": "405597333685a02824aa162927f2e0c544647ec7079adbc74c61031e8258c8e6", + "fingerprint": "ab0fe3f233b6f8dafdb6a946447535366ba687b9ccf8fafd2b877aa4e7cac230", "stableId": "ai-connections-provider-routing-06-production-components", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/06 Production components" }, @@ -4604,7 +4604,7 @@ "ui/storybook/prototypes/runner-activity/RunnerActivityPreview.tsx", "ui/storybook/stories/tasks/runner-activity.stories.tsx" ], - "fingerprint": "fc9539ac75fd89906fcd403ca1e5792b21440b898d0662317fbe28d7192e725d", + "fingerprint": "f39f36b1276c741530f6da95724d0e29fbc43c3bdd87b75640047bbc9f753810", "stableId": "tasks-runner-activity-preview", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Tasks/Runner activity preview" }, @@ -4717,7 +4717,7 @@ "ui/src/components/task-chat/RunnerGoalWidget.tsx", "ui/storybook/stories/runs-adapters/session-goals.stories.tsx" ], - "fingerprint": "1d6e7f30007a296443b0670142cb13694e0e955e6f97687f306f69155e766593", + "fingerprint": "91775a33b452fed0055f7e607384f3f95801a8bafbdf369cf149f8ebcc3943a8", "stableId": "composer-session-goals", "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/Composer/Session goals" }, @@ -5108,7 +5108,7 @@ "ui/src/components/task-chat/task-chat-model.ts", "ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx" ], - "fingerprint": "c9641a4c8ac8ab68a6d1ad7b9a730f5c3b4f894d5a60262ba2ef196d1c1da7d2", + "fingerprint": "2c926951a7911a3ca79ceae50578e581f389e7cffe68bce15e862ae95a429644", "stableId": "tasks-task-chat-review-fixes", "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Tasks/Task chat review fixes" }, From bb765e90098e09113fdb3d86912740986a4a42fe Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 20:34:26 -0500 Subject: [PATCH 15/15] chore(runner): apply contribution rules with immutable source attestations Retain Pi profile 24 and bind the updated sandbox source in profile 25. Provider closures and qualification boundaries are unchanged. Co-Authored-By: Paperclip --- .dockerignore | 1 + doc/architecture/runner-pi-capabilities.md | 2 +- .../runner-rich-acp-capabilities.md | 2 +- packages/paperclip-runner/acpx-profiles.json | 4 +- .../runner-core/src/acpx_provider_backend.rs | 3 +- .../src/generated_acpx_profiles.rs | 2 +- .../tests/native_provider_backend.rs | 2 +- .../src/drivers/acpx/claude-effort.ts | 2 - .../drivers/acpx/codex-credentials.test.ts | 3 -- .../src/drivers/acpx/config-admission.ts | 8 --- .../src/drivers/acpx/generated-profiles.ts | 4 +- .../src/drivers/acpx/pi-installation.test.ts | 40 +++++++++------ .../src/drivers/acpx/runtime-host.ts | 2 - .../src/drivers/acpx/runtime-sandbox.ts | 4 -- .../src/live/live-session.test.ts | 3 -- .../pi-acp/profile-v25-identity.json | 50 +++++++++++++++++++ 16 files changed, 85 insertions(+), 47 deletions(-) create mode 100644 packages/paperclip-runner/test-fixtures/pi-acp/profile-v25-identity.json diff --git a/.dockerignore b/.dockerignore index 8ae5f323da0..f0436b4e6e7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -56,6 +56,7 @@ packages/paperclip-runner/test-fixtures/pi-acp/** !packages/paperclip-runner/test-fixtures/pi-acp/profile-v22-identity.json !packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json !packages/paperclip-runner/test-fixtures/pi-acp/profile-v24-identity.json +!packages/paperclip-runner/test-fixtures/pi-acp/profile-v25-identity.json !packages/paperclip-runner/docs/capability-contract.md # check:runner-workflow-traceability access()es every regression test the # stress-traceability spec names — those are src/**/*.test.ts files, so diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md index 8b57d05c844..253fa9296ff 100644 --- a/doc/architecture/runner-pi-capabilities.md +++ b/doc/architecture/runner-pi-capabilities.md @@ -2,7 +2,7 @@ ## Current source integration — 2026-10-12 -Pi profile 24 (`sha256:d25d674cda9a0853bdbf0521025099f1424c573d2f533630cb67d90e1bfcda3b`) +Pi profile 25 (`sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a`) advances the shared sandbox source attestation after Claude settings initialization moved behind provider lifetime ownership. Profile 23 remains an immutable historical identity. Pi's provider executable closures, model/effort options and diff --git a/doc/architecture/runner-rich-acp-capabilities.md b/doc/architecture/runner-rich-acp-capabilities.md index 8ad361096f3..90137ed4c97 100644 --- a/doc/architecture/runner-rich-acp-capabilities.md +++ b/doc/architecture/runner-rich-acp-capabilities.md @@ -2,7 +2,7 @@ ## Current source integration — 2026-10-12 -Pi profile 24 (`sha256:d25d674cda9a0853bdbf0521025099f1424c573d2f533630cb67d90e1bfcda3b`) +Pi profile 25 (`sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a`) advances the shared sandbox source attestation after Claude settings initialization moved behind provider lifetime ownership. Profile 23 remains an immutable historical identity. Pi's provider executable closures, model/effort options and diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 289c782947e..a26968b621e 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -14,12 +14,12 @@ "requiresProviderPolicy": false }, "pi": { - "agentProfileVersion": 24, + "agentProfileVersion": 25, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", "agentRuntimeVersion": "1.0.0", - "commandDigest": "sha256:d25d674cda9a0853bdbf0521025099f1424c573d2f533630cb67d90e1bfcda3b", + "commandDigest": "sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a", "requiresProviderPolicy": true }, "cursor": { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 6c28264f235..cfc5f9d8a1c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -3200,7 +3200,7 @@ mod tests { #[test] fn pi_profile_matches_published_identity_and_rejects_prior_profiles() { let published: Value = serde_json::from_str(include_str!( - "../../../../test-fixtures/pi-acp/profile-v24-identity.json" + "../../../../test-fixtures/pi-acp/profile-v25-identity.json" )) .unwrap(); let mut value = descriptor("codex"); @@ -3217,6 +3217,7 @@ mod tests { current.validate(&context()).unwrap(); for prior in [ + include_str!("../../../../test-fixtures/pi-acp/profile-v24-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v23-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v15-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v16-identity.json"), diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index 99f4d02739b..5498dbfe2d9 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -29,7 +29,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: Some("@earendil-works/pi-coding-agent"), agent_runtime_version: Some("1.0.0"), command_digest: - "sha256:d25d674cda9a0853bdbf0521025099f1424c573d2f533630cb67d90e1bfcda3b", + "sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a", requires_provider_policy: true, }, "cursor" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index 22630885bfc..39143816c96 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -17,7 +17,7 @@ use sha2::{Digest, Sha256}; const CODEX_ACPX_DIGEST: &str = "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3"; const PI_ACPX_DIGEST: &str = - "sha256:d25d674cda9a0853bdbf0521025099f1424c573d2f533630cb67d90e1bfcda3b"; + "sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a"; fn temporary_directory(label: &str) -> PathBuf { let nonce = SystemTime::now() diff --git a/packages/paperclip-runner/src/drivers/acpx/claude-effort.ts b/packages/paperclip-runner/src/drivers/acpx/claude-effort.ts index 504ddd08afe..e8b048a53b5 100644 --- a/packages/paperclip-runner/src/drivers/acpx/claude-effort.ts +++ b/packages/paperclip-runner/src/drivers/acpx/claude-effort.ts @@ -8,8 +8,6 @@ export function resolveClaudeEffort(agent: string, value: unknown): ClaudeEffort return value; } export function createClaudeEffortAdmission(expected: ClaudeEffort) { - // Every connection, including session/load, must select and acknowledge the - // configured effort. A restored preference alone never admits a prompt. return createAcpxConfigAdmission(expected, { label: "Claude effort", configId: "effort", supportedValues: ["default", "low", "medium", "high", "xhigh", "max"] }); } diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts index c13ffcb1596..f1ac121755d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts @@ -1699,9 +1699,6 @@ async function silentPrimaryQuorumFixture( if (failure?.status === "rejected") throw failure.reason; }, }; - // Only fixture preparation may retry. Release the unselected candidates - // immediately before the caller stages once. A foreign bind racing this - // handoff remains a visible production-call failure, never a hidden retry. const released = await Promise.allSettled( owned .filter((_, index) => !occupiedIndices.includes(index)) diff --git a/packages/paperclip-runner/src/drivers/acpx/config-admission.ts b/packages/paperclip-runner/src/drivers/acpx/config-admission.ts index be597f10e16..73e5faf73bc 100644 --- a/packages/paperclip-runner/src/drivers/acpx/config-admission.ts +++ b/packages/paperclip-runner/src/drivers/acpx/config-admission.ts @@ -2,8 +2,6 @@ function object(value: unknown): Record { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } -/** Native mode is independent of permissions. Every connection must prove its - * current mode before a prompt; a persisted ACPX preference is not proof. */ export function createAcpxConfigAdmission(expected: string, options: { label: string; configId: string; supportedValues: readonly string[]; restoring?: boolean; sessionMode?: boolean }) { type State = { sessionId: string | null; mode: string | null; error: Error | null }; let current: State | null = null; @@ -17,8 +15,6 @@ export function createAcpxConfigAdmission(expected: string, options: { label: st const configs = Array.isArray(result.configOptions) ? result.configOptions.filter(value => object(value).id === options.configId) : []; if (configs.length !== 1) throw failure("native mode configuration is missing or ambiguous"); const mode = object(configs[0]).currentValue; - // Before exact model selection, Pi can legitimately report a different - // native model's level. Observation alone never admits the first prompt. if (typeof mode !== "string" || !options.supportedValues.includes(mode)) throw failure("native mode is unsupported"); if (session && options.sessionMode && object(result.modes).currentModeId !== mode) throw failure("native mode acknowledgements conflict"); return mode; @@ -56,16 +52,12 @@ export function createAcpxConfigAdmission(expected: string, options: { label: st pending.set(message.id, { method, sessionId: params.sessionId, selectsMode: params.configId === options.configId }); } else if (options.sessionMode && method === "session/set_mode") { if (!state.sessionId || params.sessionId !== state.sessionId || params.modeId !== expected) throw failure("mode control changes the selected mode"); - // This method only returns {}; require a later config/session - // acknowledgement rather than treating that empty result as proof. state.mode = null; } return; } if (message.method === "session/update" && object(params.update).sessionUpdate === "config_option_update") { const update = object(params.update); - // Config updates may contain only model options. A mode entry is - // authoritative observation, but never an admission acknowledgement. if (Array.isArray(update.configOptions) && update.configOptions.some(value => object(value).id === options.configId)) { if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session"); const observed = readMode(update, false); diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 20b368c2ab4..ecc8eea459e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -22,12 +22,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "pi", - "agentProfileVersion": 24, + "agentProfileVersion": 25, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", "agentRuntimeVersion": "1.0.0", - "commandDigest": "sha256:d25d674cda9a0853bdbf0521025099f1424c573d2f533630cb67d90e1bfcda3b", + "commandDigest": "sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a", "permissionPolicy": "interactive" }, "cursor": { diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts index b3a848ec8c0..640bfb7b954 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -104,11 +104,19 @@ describe("Pi installation factory", () => { expect(current.commandDigest).not.toBe(prior.commandDigest); }); + it("retains profile 24 while binding the comment-free sandbox source in profile 25", async () => { + const read = async (version: number) => JSON.parse(await readFile(new URL(`../../../test-fixtures/pi-acp/profile-v${version}-identity.json`, import.meta.url), "utf8")); + const prior = await read(24), current = await read(25); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()) + .toEqual(["agentProfileVersion", "runtimeSandboxSourceSha256"]); + expect(current.commandDigest).not.toBe(prior.commandDigest); + }); + it("binds the profile declaration to the reviewed patch and platform closure pins", async () => { const hash = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex"); const canonical = (value: any): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]` : value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value); - const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v24-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v25-identity.json", import.meta.url), "utf8")); const declaration = identity.declaration; expect(declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.commandDigest).toBe(`sha256:${hash(canonical(declaration))}`); @@ -139,21 +147,21 @@ describe("Pi installation factory", () => { it("rejects legacy profiles and tampered executable identities", () => { expect(() => assertPiInstallationProfile(candidate())).not.toThrow(); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 24"); - for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22, 23]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 25"); + for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22, 23, 24]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 25"); for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { agentRuntimePackage: "ambient-pi" }]) { expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 9bd4402b9be..fe353e6a6f7 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -483,8 +483,6 @@ export class AcpxRuntimeHost { }); } options.signal?.throwIfAborted(); - // Await this write under ownership even if cancellation arrives. Releasing - // the lease before it settles could let a late write affect the next owner. await sandbox.initializeOwnedConfiguration?.(); options.signal?.throwIfAborted(); assertAcpxProfileEnvironment(options.agent, sandbox.launchEnvironment); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 6b315ca4470..aebebe86c5c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -53,7 +53,6 @@ export interface AcpxRuntimeSandbox { cacheDirectory: string; agentHomeDirectory: string; workspaceRecordPath: string; - /** Mutate provider settings only while the host holds its lifetime lease. */ initializeOwnedConfiguration?: () => Promise; launchEnvironment: Readonly; persistedEnvironment: Readonly; @@ -405,9 +404,6 @@ export async function prepareAcpxRuntimeSandbox(input: { `${JSON.stringify({ model: input.binding.requestedModel, availableModels: [input.binding.requestedModel], - // Claude reloads effort from settings on a new ACP connection. Keep - // the bound choice available during model verification and resume; - // the protocol guard still requires a provider acknowledgement. ...(input.binding.claudeEffort ? { effortLevel: input.binding.claudeEffort } : {}), ...((input.binding.permissionMode === "approve-reads" || input.binding.permissionMode === "approve-paperclip") ? { permissions: { allow: claudePaperclipPermissionRules(input.tools ?? [], input.binding.permissionMode) } } diff --git a/packages/paperclip-runner/src/live/live-session.test.ts b/packages/paperclip-runner/src/live/live-session.test.ts index 6e050568d29..9292aa316ab 100644 --- a/packages/paperclip-runner/src/live/live-session.test.ts +++ b/packages/paperclip-runner/src/live/live-session.test.ts @@ -1766,9 +1766,6 @@ describe("Capability live runnerd and Codex session", () => { expect((await store.load(replacement.id))?.status).toBe("suspended"); }); - // This case includes a deliberate 500 ms killed turn, a resumed turn with - // its own 5-second bound, and durable writes. The enclosing test must not - // expire at the resumed turn's deadline before those assertions can finish. it("durably resumes a distinct attempt, preserves partial usage, and deduplicates the semantic effect", async () => { const state = providerState(); const directory = await mkdtemp(join(tmpdir(), "capability-live-durable-")); diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v25-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v25-identity.json new file mode 100644 index 00000000000..8c3e9a4f4cd --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v25-identity.json @@ -0,0 +1,50 @@ +{ + "commandDigest": "sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 25, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "282022db10150c6632b3444df421342e7d534bdf5d5fb1097a2e79d0625a2bcf", + "darwin-x64": "64e251e19009f755c0b04f73ce2138246faab71a961b0f13d75ebfcc34bef12e", + "linux-x64": "713b1fdff42fb56a1518bdc084f181d70bee8ebadc3e4b1d76321ed9108c8410" + }, + "wrapperSha256": "9d129b3d38772e93e97080aa6c4e574ac5df4e47ce5bc331a484a9fbf8188b36", + "helperSha256": "41e0490b617da0d60e0c8ec58ef311236f945129d99f816cff6897f78da7f91d", + "extensionSha256": "f5818573a355702388072b28926897db546a5cf346b5171f646582ce5f9102a4", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v4-root-field-types", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts", + "nativeNotices": "session-extension-v2-bounded-runtime-failure", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1", + "thinkingLevel": { + "supported": [ + "off", + "low", + "high", + "max" + ], + "newConfigurationDefault": "low", + "admission": "explicit-native-effective-before-prompt-v1", + "recovery": "mode-bound-v1" + }, + "modelSelection": "caller-selected-native-verified-v1", + "providerConfigurationSourceSha256": "d5b4c1a426afc85f040c9b55358d1d645299586dc8cec72d1be2509187ca3865", + "credentialEnvironmentSourceSha256": "f8dc40ea1aa894bd44754a35a8f65fbcaa2f4c7ffc88cc5fdaa965e0d378d9bc", + "runtimeSandboxSourceSha256": "144d62b3415cfedf272b5b052e14af1e41fe50a644af84247fc9c20c7ef7edde", + "recoveryIdentitySourceSha256": "8479e04da1ae2f7742e34f045b0270b99e9c3ae39a5b8fb9f164e5776d83224c", + "nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1", + "dependencySecurityPatchSha256": "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814" + } +}