From 76b443ae4fe2567c5c5e51465a82db5faa1f3e62 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:58:12 -0700 Subject: [PATCH 1/2] feat: edit a deployed agent, and show what Slack actually granted (CMP-77) (#109) --- .agents/skills/diffs/SKILL.md | 51 ++ .agents/skills/diffs/references/api-editor.md | 95 ++ .agents/skills/diffs/references/api-react.md | 63 ++ .../diffs/references/recipe-code-view.md | 213 +++++ .../diffs/references/recipe-edit-react.md | 129 +++ .../skills/diffs/references/recipe-react.md | 37 + .env.example | 5 + AGENTS.md | 202 +++++ apps/agent/agent/channels/crm.ts | 180 +++- apps/agent/agent/hooks/audit.ts | 16 +- apps/agent/agent/instructions/task.ts | 2 +- apps/agent/agent/lib/agent-actions.ts | 42 + apps/agent/agent/lib/agent-manifest.ts | 111 +++ apps/agent/agent/lib/builder-input.ts | 101 +++ apps/agent/agent/lib/builder-runtime.ts | 253 +++++- apps/agent/agent/lib/custom-agent-dispatch.ts | 338 +++++++- apps/agent/agent/lib/deadline.ts | 18 + apps/agent/agent/lib/dispatch-config.ts | 43 + apps/agent/agent/lib/dispatch.ts | 350 +++++++- apps/agent/agent/lib/enrichment.ts | 61 +- apps/agent/agent/lib/event-persistence.ts | 5 + apps/agent/agent/lib/pool.ts | 6 +- apps/agent/agent/lib/run-preflight.ts | 55 ++ apps/agent/agent/lib/run-runtime.ts | 808 ++++++++++++++---- apps/agent/agent/lib/run-state.ts | 16 +- apps/agent/agent/lib/slack-config.ts | 16 + apps/agent/agent/lib/slack-connection.ts | 28 + apps/agent/agent/lib/slack-join-task.ts | 19 + apps/agent/agent/lib/slack-membership.ts | 282 ++++++ apps/agent/agent/lib/slack-people.ts | 296 +++++++ apps/agent/agent/lib/tasks.ts | 42 +- apps/agent/agent/schedules/dispatch.ts | 14 +- .../subagents/agent_builder/instructions.md | 53 +- .../agent_builder/lib/draft-input.ts | 60 +- .../agent_builder/tools/inspect_context.ts | 2 +- .../subagents/agent_runner/instructions.md | 11 +- .../agent_runner/tools/finish_run.ts | 7 +- .../agent_runner/tools/post_slack_message.ts | 20 + apps/agent/package.json | 1 + .../test/builder-runtime.integration.spec.ts | 314 ++++++- apps/agent/test/custom-agent-runtime.spec.ts | 453 +++++++++- apps/agent/test/dispatch-health.spec.ts | 258 ++++++ apps/agent/test/drain.spec.ts | 2 + .../durable-agent-runtime.integration.spec.ts | 225 ++++- apps/agent/test/e2e/dispatch.e2e.ts | 226 +++++ apps/agent/test/e2e/e2e-agents.ts | 54 ++ apps/agent/test/e2e/e2e-config.ts | 43 + apps/agent/test/e2e/live-run.e2e.ts | 189 ++++ apps/agent/test/e2e/load.e2e.ts | 219 +++++ apps/agent/test/e2e/retry.e2e.ts | 147 ++++ apps/agent/test/e2e/slack-delivery.e2e.ts | 110 +++ apps/agent/test/e2e/slack-join.e2e.ts | 138 +++ .../agent/test/enrichment.integration.spec.ts | 124 +++ apps/agent/test/event-persistence.spec.ts | 28 + .../test/keyless-brand.integration.spec.ts | 65 +- apps/agent/test/pool.spec.ts | 17 + .../test/slack-membership.integration.spec.ts | 178 ++++ .../test/slack-people.integration.spec.ts | 248 ++++++ apps/api/.scratch/existing.ts | 21 - apps/api/.scratch/shape.ts | 34 - apps/api/.scratch/verify.ts | 19 - apps/api/package.json | 4 +- apps/api/src/agent/agent-access.service.ts | 24 +- .../src/agent/agent-definitions.service.ts | 425 ++++++++- apps/api/src/agent/agent-dispatch.config.ts | 13 + apps/api/src/agent/agent-runs.service.ts | 236 ++++- apps/api/src/agent/agent-trigger.service.ts | 337 ++++++-- apps/api/src/agent/agent.module.ts | 9 +- apps/api/src/agent/agents.contracts.ts | 47 + apps/api/src/agent/agents.router.ts | 41 + .../src/agent/dispatch-heartbeat.service.ts | 40 + apps/api/src/app.module.ts | 2 + apps/api/src/companies/companies.service.ts | 27 +- .../companies/company-directory.service.ts | 58 +- apps/api/src/config/env.validation.ts | 8 + apps/api/src/contacts/contacts.service.ts | 31 +- .../conversation-sharing.service.ts | 21 +- .../conversations/conversations.service.ts | 162 +++- apps/api/src/currency/currency.service.ts | 24 +- apps/api/src/deals/deals.module.ts | 3 +- apps/api/src/deals/deals.service.ts | 150 +++- apps/api/src/generated/server.ts | 40 +- apps/api/src/mailbox/mailbox-match.service.ts | 67 +- apps/api/src/slack/slack-channels.service.ts | 89 ++ apps/api/src/slack/slack-config.ts | 17 + .../api/src/slack/slack-connection.service.ts | 278 ++++++ apps/api/src/slack/slack.contracts.ts | 40 + apps/api/src/slack/slack.module.ts | 13 + apps/api/src/slack/slack.router.ts | 71 ++ apps/api/src/sso/sso.service.ts | 20 +- apps/api/src/workspace/workspace.service.ts | 18 +- apps/api/test/agent-delete.spec.ts | 7 +- apps/api/test/agent-events.spec.ts | 257 ++++++ apps/api/test/agent-lifecycle.spec.ts | 276 +++++- apps/api/test/agent-runs.spec.ts | 311 ++++++- apps/api/test/agent-trigger.stub.ts | 11 + apps/api/test/bulk.spec.ts | 6 +- apps/api/test/conversations.spec.ts | 146 +++- .../test/currency-totals.integration.spec.ts | 7 + apps/api/test/deal-contacts.spec.ts | 7 + apps/api/test/fields.spec.ts | 6 +- apps/api/test/mailbox-thread-writer.spec.ts | 4 +- apps/api/test/record-delete.spec.ts | 4 +- apps/api/test/slack-channels.spec.ts | 69 ++ apps/api/test/slack-connection.spec.ts | 245 ++++++ .../test/tracking-filing.integration.spec.ts | 4 +- .../(agent-builder)/agents/[agentId]/page.tsx | 42 +- .../(agent-builder)/chat/[chatId]/page.tsx | 38 +- .../[slug]/(agent-builder)/missing-record.ts | 9 + .../connections/add-connection-dialog.tsx | 130 +++ .../settings/connections/connection-page.tsx | 34 + .../connections/google-connection.tsx | 24 +- .../settings/connections/google/page.tsx | 21 + .../settings/connections/intake/page.tsx | 41 + .../connections/microsoft-connection.tsx | 20 +- .../settings/connections/microsoft/page.tsx | 21 + .../connections/oauth-connection-page.tsx | 42 + .../[slug]/settings/connections/page.tsx | 267 ++++-- .../settings/connections/slack/page.tsx | 353 ++++++++ .../connections/slack/people/page.tsx | 54 ++ .../slack/people/slack-people-matches.tsx | 114 +++ .../connections/slack/slack-channels.tsx | 216 +++++ .../slack/slack-connect-button.tsx | 86 ++ .../slack/slack-disconnect-button.tsx | 100 +++ .../connections/slack/slack-scope-groups.tsx | 108 +++ .../agent-builder/agent-builder-chat.tsx | 187 ++-- .../agent-builder/agent-capabilities.tsx | 419 +++++++++ .../components/agent-builder/agent-code.tsx | 250 ++++++ .../agent-builder/agent-history.tsx | 458 ++++++++++ .../agent-builder/agent-runs-drawer.tsx | 98 +++ .../agent-builder/create-channel-dialog.tsx | 111 +++ .../agent-builder/new-agent-dialog.tsx | 214 +++++ .../agent-builder/team-agent-detail.tsx | 586 +++---------- .../agent-clarification-composer.tsx | 6 +- apps/app/components/page-shell.tsx | 11 +- apps/app/components/slack/channel-picker.tsx | 135 +++ .../components/slack/use-slack-channels.ts | 60 ++ apps/app/lib/agent-builder-state.test.ts | 52 ++ apps/app/lib/agent-builder-state.ts | 62 ++ apps/app/lib/agent-handoff.ts | 46 + apps/app/lib/agent-run-failure.ts | 35 + apps/app/lib/trpc/cache.ts | 8 + apps/app/package.json | 1 + bun.lock | 20 + docs/agent.md | 53 +- docs/connections.md | 232 +++++ docs/environment.md | 17 + packages/auth/package.json | 4 +- packages/auth/src/auth.ts | 142 ++- packages/auth/src/client.ts | 3 +- packages/auth/src/env.ts | 9 + packages/auth/src/index.ts | 23 +- packages/auth/src/organization.ts | 24 +- packages/auth/src/scopes.ts | 1 + packages/auth/src/slack-config.ts | 21 + packages/auth/src/slack-connect.ts | 74 ++ packages/auth/src/slack-grant.ts | 79 ++ packages/auth/src/slack-scopes.ts | 200 +++++ packages/auth/src/slack-sync.ts | 6 + .../test/slack-connect.integration.spec.ts | 339 ++++++++ packages/db/package.json | 3 + .../migration.sql | 30 + .../migration.sql | 2 + .../migration.sql | 5 + .../migration.sql | 15 + .../migration.sql | 5 + .../migration.sql | 13 + .../migration.sql | 2 + .../migration.sql | 20 + .../migration.sql | 27 + packages/db/prisma/schema.prisma | 89 +- packages/db/src/agent-tasks.ts | 14 +- packages/db/src/crm-events.ts | 51 ++ packages/db/src/slack-inventory.ts | 44 + packages/ui/src/components/accordion.tsx | 2 + packages/ui/src/components/alert.tsx | 2 + .../src/components/brand-logos/docusign.tsx | 18 + packages/ui/src/components/save-bar.tsx | 73 ++ .../ui/src/components/thinking-indicator.tsx | 18 + packages/ui/src/styles/globals.css | 15 + packages/validation/package.json | 23 + packages/validation/src/agents.ts | 105 +++ packages/validation/src/index.ts | 41 + packages/validation/src/slack.ts | 53 ++ packages/validation/test/parse.spec.ts | 66 ++ packages/validation/tsconfig.json | 9 + turbo.json | 2 + 187 files changed, 15808 insertions(+), 1483 deletions(-) create mode 100644 .agents/skills/diffs/SKILL.md create mode 100644 .agents/skills/diffs/references/api-editor.md create mode 100644 .agents/skills/diffs/references/api-react.md create mode 100644 .agents/skills/diffs/references/recipe-code-view.md create mode 100644 .agents/skills/diffs/references/recipe-edit-react.md create mode 100644 .agents/skills/diffs/references/recipe-react.md create mode 100644 apps/agent/agent/lib/agent-actions.ts create mode 100644 apps/agent/agent/lib/agent-manifest.ts create mode 100644 apps/agent/agent/lib/builder-input.ts create mode 100644 apps/agent/agent/lib/deadline.ts create mode 100644 apps/agent/agent/lib/dispatch-config.ts create mode 100644 apps/agent/agent/lib/event-persistence.ts create mode 100644 apps/agent/agent/lib/run-preflight.ts create mode 100644 apps/agent/agent/lib/slack-config.ts create mode 100644 apps/agent/agent/lib/slack-connection.ts create mode 100644 apps/agent/agent/lib/slack-join-task.ts create mode 100644 apps/agent/agent/lib/slack-membership.ts create mode 100644 apps/agent/agent/lib/slack-people.ts create mode 100644 apps/agent/agent/subagents/agent_runner/tools/post_slack_message.ts create mode 100644 apps/agent/test/dispatch-health.spec.ts create mode 100644 apps/agent/test/e2e/dispatch.e2e.ts create mode 100644 apps/agent/test/e2e/e2e-agents.ts create mode 100644 apps/agent/test/e2e/e2e-config.ts create mode 100644 apps/agent/test/e2e/live-run.e2e.ts create mode 100644 apps/agent/test/e2e/load.e2e.ts create mode 100644 apps/agent/test/e2e/retry.e2e.ts create mode 100644 apps/agent/test/e2e/slack-delivery.e2e.ts create mode 100644 apps/agent/test/e2e/slack-join.e2e.ts create mode 100644 apps/agent/test/event-persistence.spec.ts create mode 100644 apps/agent/test/slack-membership.integration.spec.ts create mode 100644 apps/agent/test/slack-people.integration.spec.ts delete mode 100644 apps/api/.scratch/existing.ts delete mode 100644 apps/api/.scratch/shape.ts delete mode 100644 apps/api/.scratch/verify.ts create mode 100644 apps/api/src/agent/agent-dispatch.config.ts create mode 100644 apps/api/src/agent/dispatch-heartbeat.service.ts create mode 100644 apps/api/src/slack/slack-channels.service.ts create mode 100644 apps/api/src/slack/slack-config.ts create mode 100644 apps/api/src/slack/slack-connection.service.ts create mode 100644 apps/api/src/slack/slack.contracts.ts create mode 100644 apps/api/src/slack/slack.module.ts create mode 100644 apps/api/src/slack/slack.router.ts create mode 100644 apps/api/test/agent-events.spec.ts create mode 100644 apps/api/test/agent-trigger.stub.ts create mode 100644 apps/api/test/slack-channels.spec.ts create mode 100644 apps/api/test/slack-connection.spec.ts create mode 100644 apps/app/app/(app)/[slug]/(agent-builder)/missing-record.ts create mode 100644 apps/app/app/(app)/[slug]/settings/connections/add-connection-dialog.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/connections/connection-page.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/connections/google/page.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/connections/intake/page.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/connections/microsoft/page.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/connections/oauth-connection-page.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/connections/slack/page.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/connections/slack/people/page.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/connections/slack/people/slack-people-matches.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/connections/slack/slack-channels.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/connections/slack/slack-disconnect-button.tsx create mode 100644 apps/app/app/(app)/[slug]/settings/connections/slack/slack-scope-groups.tsx create mode 100644 apps/app/components/agent-builder/agent-capabilities.tsx create mode 100644 apps/app/components/agent-builder/agent-code.tsx create mode 100644 apps/app/components/agent-builder/agent-history.tsx create mode 100644 apps/app/components/agent-builder/agent-runs-drawer.tsx create mode 100644 apps/app/components/agent-builder/create-channel-dialog.tsx create mode 100644 apps/app/components/agent-builder/new-agent-dialog.tsx create mode 100644 apps/app/components/slack/channel-picker.tsx create mode 100644 apps/app/components/slack/use-slack-channels.ts create mode 100644 apps/app/lib/agent-handoff.ts create mode 100644 apps/app/lib/agent-run-failure.ts create mode 100644 docs/connections.md create mode 100644 packages/auth/src/slack-config.ts create mode 100644 packages/auth/src/slack-connect.ts create mode 100644 packages/auth/src/slack-grant.ts create mode 100644 packages/auth/src/slack-scopes.ts create mode 100644 packages/auth/src/slack-sync.ts create mode 100644 packages/auth/test/slack-connect.integration.spec.ts create mode 100644 packages/db/prisma/migrations/20260809120000_slack_member_matches/migration.sql create mode 100644 packages/db/prisma/migrations/20260810090000_agent_conversation_pending_input/migration.sql create mode 100644 packages/db/prisma/migrations/20260810100000_agent_task_deal_events/migration.sql create mode 100644 packages/db/prisma/migrations/20260810110000_agent_event_conversation/migration.sql create mode 100644 packages/db/prisma/migrations/20260810111000_canonical_builder_tokens/migration.sql create mode 100644 packages/db/prisma/migrations/20260810120000_plural_agent_triggers/migration.sql create mode 100644 packages/db/prisma/migrations/20260810130000_drop_unused_slack_unmatched/migration.sql create mode 100644 packages/db/prisma/migrations/20260811050330_slack_workspace_grant/migration.sql create mode 100644 packages/db/prisma/migrations/20260811212311_slack_install_cancel_delivery_task_subject/migration.sql create mode 100644 packages/db/src/crm-events.ts create mode 100644 packages/db/src/slack-inventory.ts create mode 100644 packages/ui/src/components/brand-logos/docusign.tsx create mode 100644 packages/ui/src/components/save-bar.tsx create mode 100644 packages/ui/src/components/thinking-indicator.tsx create mode 100644 packages/validation/package.json create mode 100644 packages/validation/src/agents.ts create mode 100644 packages/validation/src/index.ts create mode 100644 packages/validation/src/slack.ts create mode 100644 packages/validation/test/parse.spec.ts create mode 100644 packages/validation/tsconfig.json diff --git a/.agents/skills/diffs/SKILL.md b/.agents/skills/diffs/SKILL.md new file mode 100644 index 000000000..0fd48369a --- /dev/null +++ b/.agents/skills/diffs/SKILL.md @@ -0,0 +1,51 @@ +--- +name: diffs +description: + Use when an app uses @pierre/diffs to render or edit code files, diffs, + patches, merge conflicts, or CodeView review surfaces, including React, + vanilla JavaScript, SSR, workers, annotations, selection, and custom Shiki + languages or themes. +--- + +# `@pierre/diffs` + +Use `@pierre/diffs` to render syntax-highlighted files and diffs. Use its +optional editor, SSR, and worker entries for those capabilities. + +## Install + +```bash +pnpm add @pierre/diffs +``` + +Install `react` and `react-dom` when the app uses the React entry. + +## Select an API reference + +| Surface | Reference | +| --------------------- | -------------------------------------- | +| `@pierre/diffs/react` | [React API](references/api-react.md) | +| `@pierre/diffs/edit` | [Editor API](references/api-editor.md) | + +## Select a recipe + +| Task | Recipe | +| ---------------------------------- | -------------------------------------------------- | +| Render a file or diff in React | [Render with React](references/recipe-react.md) | +| Build a virtualized review surface | [Use CodeView](references/recipe-code-view.md) | +| Edit a React surface or CodeView | [Edit with React](references/recipe-edit-react.md) | + +## Not vendored here + +These references were not copied into this skill. There is no local file for +them. Read the package types and the upstream documentation instead. + +- Core API: root components, parsing, and file extension APIs. +- Highlighting API: languages, themes, highlighter state, and streams. +- Low-level rendering API: renderers, managers, DOM helpers, and constants. +- Shared types: data, option, render, selection, and editor types. +- SSR API for `@pierre/diffs/ssr`, and the recipe for preloading server markup. +- Worker API for `@pierre/diffs/worker`, and the recipe for a worker pool. +- Recipes for vanilla JavaScript rendering and vanilla editing. +- Recipes for line annotations and selection. +- Recipe for registering a custom Shiki language or theme. diff --git a/.agents/skills/diffs/references/api-editor.md b/.agents/skills/diffs/references/api-editor.md new file mode 100644 index 000000000..671d1195d --- /dev/null +++ b/.agents/skills/diffs/references/api-editor.md @@ -0,0 +1,95 @@ +# Editor API + +This reference lists every export from `@pierre/diffs/edit` and every public +member of its classes. + +## Exports + +| Export | Kind | Purpose | +| --------------------- | ----- | --------------------------------------------------------- | +| `Editor` | Class | Adds text editing to a `File` or `FileDiff` instance. | +| `EditorChange` | Type | Describes one normalized editor change. | +| `EditorChangeEvent` | Type | Provides normalized edits and current document state. | +| `EditorOptions` | Type | Configures history, state, selections, and callbacks. | +| `TextDocument` | Class | Stores text, positions, edits, search, and undo history. | +| `TextDocumentChange` | Type | Describes the lines and characters changed by an edit. | +| `IStateStorage` | Type | Defines asynchronous or synchronous editor state storage. | +| `PersistStateStorage` | Type | Selects memory, IndexedDB, or custom state storage. | +| `Position` | Type | Identifies a zero-based line and character. | +| `Range` | Type | Identifies a start and end position. | +| `TextEdit` | Type | Replaces one range with new text. | + +## `EditorOptions` fields + +| Field | Purpose | +| ------------------------ | -------------------------------------------------------- | +| `historyMaxEntries` | Limits the undo stack. | +| `persistState` | Keeps editor state for each file cache key. | +| `persistStateStorage` | Selects the state store. | +| `roundedSelection` | Controls rounded selection corners. | +| `matchBrackets` | Controls matching-bracket highlights. | +| `autoSurround` | Controls quote and bracket insertion around a selection. | +| `languageCommentConfig` | Overrides comment tokens by language. | +| `enabledSelectionAction` | Enables the selection action surface. | +| `clipboard` | Supplies a text clipboard reader. | +| `renderSelectionAction` | Produces the selection action element. | +| `onAttach` | Receives the editor and attached surface. | +| `onChange` | Receives file state, annotations, and a change event. | +| `onFocus` | Runs after the editor gains focus. | +| `onBlur` | Runs after the editor loses focus. | + +## `Editor` members + +| Member | Purpose | +| ----------------------------------- | --------------------------------------------------------- | +| `new Editor(options?)` | Creates one editor. | +| `edit(instance)` | Attaches to a file or diff and returns a detach function. | +| `setOptions(options)` | Replaces editor options. | +| `applyEdits(edits, updateHistory?)` | Applies programmatic text edits. | +| `canUndo` | Reports whether undo has an entry. | +| `canRedo` | Reports whether redo has an entry. | +| `undo()` | Reverts the latest edit. | +| `redo()` | Reapplies the latest reverted edit. | +| `getFile()` | Gets the current file contents. | +| `getText()` | Gets the current text. | +| `getState()` | Gets selections and view state. | +| `setState(state)` | Sets selections and view state. | +| `setSelections(selections)` | Sets directed selection ranges. | +| `setMarkers(markers)` | Sets diagnostic markers. | +| `focus(options?)` | Focuses the editor. | +| `blur()` | Removes editor focus. | +| `cleanUp(recycle?)` | Releases editor resources. | + +## `TextDocument` members + +| Member | Purpose | +| ---------------------------------------------------- | ----------------------------------------------------- | +| `new TextDocument(uri, text, languageId?, version?)` | Creates a text document. | +| `uri` | Gets the document identifier. | +| `languageId` | Gets the language identifier. | +| `version` | Gets the document version. | +| `lineCount` | Gets the line count. | +| `eol` | Gets the line-ending sequence. | +| `canUndo` | Reports whether undo has an entry. | +| `canRedo` | Reports whether redo has an entry. | +| `positionAt(offset)` | Converts an offset to a position. | +| `positionsAt(offsets)` | Converts several offsets to positions. | +| `offsetAt(position)` | Converts a position to an offset. | +| `getText(range?)` | Gets all text or one range. | +| `getLineText(line, includeLineBreak?)` | Gets one line. | +| `normalizeEol(text)` | Converts text to the document line ending. | +| `getLineLength(line, includeLineBreak?)` | Gets one line length. | +| `charAt(offsetOrPosition)` | Gets one character. | +| `getTextSlice(start, end)` | Gets text between two offsets. | +| `findNextNonOverlappingSubstring(needle, occupied)` | Finds an unused substring range. | +| `search(params)` | Finds text ranges. | +| `applyEdits(edits, ...)` | Resolves and applies position-based edits. | +| `resolveEdits(edits)` | Converts position-based edits to offset edits. | +| `applyResolvedEdits(edits, ...)` | Applies offset-based edits. | +| `setLastUndoSelectionsAfter(selections)` | Associates selections with the latest history entry. | +| `setLastUndoLineAnnotations(before, after)` | Associates annotations with the latest history entry. | +| `undo()` | Reverts one document history entry. | +| `redo()` | Reapplies one document history entry. | +| `normalizePosition(position)` | Clamps a position to the document. | + +`IStateStorage` has `get(cacheKey)` and `set(cacheKey, state)` methods. diff --git a/.agents/skills/diffs/references/api-react.md b/.agents/skills/diffs/references/api-react.md new file mode 100644 index 000000000..96e6bf76e --- /dev/null +++ b/.agents/skills/diffs/references/api-react.md @@ -0,0 +1,63 @@ +# React API + +This reference lists the React-specific exports from `@pierre/diffs/react`. The +entry also re-exports every type in [Shared types](api-types.md). + +## Components and hooks + +| Export | Kind | Purpose | +| --------------------------- | --------- | --------------------------------------------------------- | +| `File` | Component | Renders one code file. | +| `FileDiff` | Component | Renders pre-parsed diff metadata. | +| `MultiFileDiff` | Component | Parses and renders an old and new file pair. | +| `PatchDiff` | Component | Parses and renders one unified patch string. | +| `UnresolvedFile` | Component | Renders and resolves merge conflicts in one file. | +| `CodeView` | Component | Renders a virtualized list of files and diffs. | +| `Virtualizer` | Component | Provides simple viewport virtualization. | +| `useVirtualizer` | Hook | Gets the nearest simple `Virtualizer` instance. | +| `EditProvider` | Component | Supplies an editor factory. | +| `useCreateEditor` | Hook | Gets the nearest editor factory. | +| `WorkerPoolContextProvider` | Component | Creates and supplies a worker pool. | +| `useWorkerPool` | Hook | Gets the nearest worker pool. | +| `useFileInstance` | Hook | Creates and manages a vanilla `File` instance. | +| `useFileDiffInstance` | Hook | Creates and manages a vanilla `FileDiff` instance. | +| `useStableCallback` | Hook | Returns a stable callback that reads the latest function. | + +## Component and provider types + +| Export | Purpose | +| ----------------------------------- | ----------------------------------------------------------------- | +| `FileProps` | Defines props for `File`. | +| `FileOptions` | Defines vanilla file options and the React `options` prop. | +| `FileDiffProps` | Defines props for `FileDiff`. | +| `MultiFileDiffProps` | Defines props for `MultiFileDiff`. | +| `PatchDiffProps` | Defines props for `PatchDiff`. | +| `UnresolvedFileProps` | Defines props for `UnresolvedFile`. | +| `UnresolvedFileReactOptions` | Defines merge-conflict options for React. | +| `DiffBasePropsReact` | Defines props shared by React diff components. | +| `CodeViewProps` | Defines controlled or uncontrolled `CodeView` props. | +| `ControlledCodeViewProps` | Defines `CodeView` props with `items`. | +| `UncontrolledCodeViewProps` | Defines `CodeView` props with `initialItems`. | +| `CodeViewReactOptions` | Defines the React-safe `CodeView` option set. | +| `CodeViewHandle` | Defines imperative list, selection, scroll, and editor controls. | +| `CreateEditor` | Defines the editor factory. | +| `EditProviderProps` | Defines the `EditProvider` factory prop. | +| `MergeConflictActionsTypeOption` | Selects no actions, default actions, or a custom action renderer. | +| `RenderMergeConflictActionContext` | Supplies conflict resolution to a custom action renderer. | +| `RenderMergeConflictActions` | Defines a custom conflict action renderer. | +| `WorkerInitializationRenderOptions` | Defines initial worker languages and render options. | +| `WorkerPoolOptions` | Defines the worker factory, pool size, and cache size. | + +## Contexts and render helpers + +| Export | Kind | Purpose | +| ------------------------- | -------- | ------------------------------------------------------ | +| `EditContext` | Context | Holds the editor factory. | +| `WorkerPoolContext` | Context | Holds the worker pool. | +| `VirtualizerContext` | Context | Holds the simple virtualizer. | +| `GutterUtilitySlotStyles` | Value | Supplies style keys for gutter utility slots. | +| `MergeConflictSlotStyles` | Value | Supplies style keys for merge conflict slots. | +| `noopRender` | Function | Returns no React output for an optional render slot. | +| `renderDiffChildren` | Function | Builds React portals for diff slots. | +| `renderFileChildren` | Function | Builds React portals for file slots. | +| `templateRender` | Function | Renders React content through a managed template slot. | diff --git a/.agents/skills/diffs/references/recipe-code-view.md b/.agents/skills/diffs/references/recipe-code-view.md new file mode 100644 index 000000000..36d818c0a --- /dev/null +++ b/.agents/skills/diffs/references/recipe-code-view.md @@ -0,0 +1,213 @@ +# Recipe: build a `CodeView` + +Use `CodeView` when one scroll region contains many files, diffs, or both. It +manages item virtualization, sticky headers, list-wide selection, and item or +line scroll targets. + +## Contents + +- [Select item ownership](#select-item-ownership) +- [Define items](#define-items) +- [Use controlled React state](#use-controlled-react-state) +- [Use imperative ownership](#use-imperative-ownership) +- [Enable item edit mode](#enable-item-edit-mode) + +## Select item ownership + +| Host and data flow | Input | Update API | +| ------------------------------------------- | ----------------- | ------------------------------------ | +| React owns the complete list | `items` | Publish a new `items` array. | +| React hosts a large or append-only list | `initialItems` | Use the `CodeViewHandle` methods. | +| Vanilla JavaScript owns the viewer instance | `setItems(items)` | Use the `CodeView` instance methods. | + +Keep one ownership mode for the life of a mounted React viewer. Use controlled +state when item data already belongs to React. Use imperative ownership for a +large or streamed list. + +## Define items + +Give each item a stable and unique `id`. Use a `file` item for `FileContents`. +Use a `diff` item for `FileDiffMetadata`. + +Increment `version` when an existing item changes its contents, annotations, +collapsed state, or edit state. `CodeView` uses the ID and version to select the +item that it must update. + +## Use controlled React state + +```tsx +import { + parseDiffFromFile, + type CodeViewItem, + type CodeViewLineSelection, +} from '@pierre/diffs'; +import { CodeView, type CodeViewHandle } from '@pierre/diffs/react'; +import { useRef, useState } from 'react'; + +const oldFile = { + name: 'src/value.ts', + contents: 'export const value = 1;', +}; +const newFile = { + name: 'src/value.ts', + contents: 'export const value = 2;', +}; +const codeViewStyle = { height: 600, overflow: 'auto' } as const; +const codeViewOptions = { + theme: { light: 'pierre-light', dark: 'pierre-dark' }, + stickyHeaders: true, + enableLineSelection: true, + layout: { paddingTop: 16, paddingBottom: 16, gap: 12 }, +} as const; + +export function ReviewSurface() { + const viewerRef = useRef | null>(null); + const [selection, setSelection] = useState( + null + ); + const [items, setItems] = useState(() => [ + { + id: 'diff:src/value.ts', + type: 'diff', + fileDiff: parseDiffFromFile(oldFile, newFile), + version: 0, + }, + { + id: 'file:README.md', + type: 'file', + file: { name: 'README.md', contents: '# Review notes' }, + version: 0, + }, + ]); + + function toggleDiff() { + setItems((current) => + current.map((item) => + item.id === 'diff:src/value.ts' + ? { + ...item, + collapsed: !item.collapsed, + version: (item.version ?? 0) + 1, + } + : item + ) + ); + } + + return ( + <> + + + + + ); +} +``` + +## Use imperative ownership + +In React, pass `initialItems` and keep `items` unset. Use the component ref to +call `addItems`, `getItem`, `updateItem`, `updateItemId`, or `scrollTo`. + +In vanilla JavaScript, configure and populate the instance directly: + +```ts +import { CodeView, parseDiffFromFile } from '@pierre/diffs'; + +const root = document.querySelector('#review'); +if (root == null) throw new Error('Missing review host'); + +const oldFile = { + name: 'src/value.ts', + contents: 'export const value = 1;', +}; +const newFile = { + name: 'src/value.ts', + contents: 'export const value = 2;', +}; + +const viewer = new CodeView({ + theme: { light: 'pierre-light', dark: 'pierre-dark' }, + stickyHeaders: true, + enableLineSelection: true, + onSelectedLinesChange(selection) { + console.log('selected lines', selection); + }, +}); + +root.style.height = '600px'; +root.style.overflow = 'auto'; +viewer.setup(root); +viewer.setItems([ + { + id: 'diff:src/value.ts', + type: 'diff', + fileDiff: parseDiffFromFile(oldFile, newFile), + version: 0, + }, +]); + +viewer.addItems([ + { + id: 'file:README.md', + type: 'file', + file: { name: 'README.md', contents: '# Review notes' }, + version: 0, + }, +]); +viewer.scrollTo({ + type: 'item', + id: 'diff:src/value.ts', + align: 'start', +}); + +const item = viewer.getItem('diff:src/value.ts'); +if (item != null) { + viewer.updateItem({ + ...item, + collapsed: true, + version: (item.version ?? 0) + 1, + }); +} + +export function removeReviewSurface() { + viewer.cleanUp(); +} +``` + +## Enable item edit mode + +In React, wrap `CodeView` in `EditProvider`. In vanilla JavaScript, pass +`createEditor` in `CodeViewOptions`. Set `edit: true` on each editable item and +increment its version. + +Use `onItemEditChange` for live contents and annotation changes. Use +`onItemEditComplete` to write the final contents into the item, disable edit +mode, assign a fresh `cacheKey`, and increment `version`. Use `getEditor(id)` +for editor commands such as undo, redo, markers, or programmatic edits. + +Read [Edit with React](recipe-edit-react.md) or +[Edit with vanilla JavaScript](recipe-edit-vanilla.md) for the complete editor +lifecycle. diff --git a/.agents/skills/diffs/references/recipe-edit-react.md b/.agents/skills/diffs/references/recipe-edit-react.md new file mode 100644 index 000000000..5f20ff842 --- /dev/null +++ b/.agents/skills/diffs/references/recipe-edit-react.md @@ -0,0 +1,129 @@ +# Recipe: edit with React + +Mount one stable `EditProvider` above the editable surfaces. The provider +supplies an editor factory. Each active surface or `CodeView` item owns a +separate editor instance, cached by `editorOptions` object identity — an edit +session restarting with the same options object reuses its editor, and +simultaneously editable surfaces need distinct options objects. + +To share one editor across surfaces, pass the same `editorOptions` object to +each of them: the cache then hands every surface the same instance. Instance +state — such as `persistState` records and their default `inMemory` storage — +survives surface remounts, so per-file selections and scroll positions restore +across file switches. Share an options object only where one surface is editable +at a time; simultaneously editable surfaces need distinct options objects. + +## Contents + +- [Edit a standalone file or diff](#edit-a-standalone-file-or-diff) +- [Keep annotations synchronized](#keep-annotations-synchronized) +- [Edit CodeView items](#edit-codeview-items) + +## Edit a standalone file or diff + +Set `edit` on `File`, `FileDiff`, `MultiFileDiff`, or `PatchDiff`. Pass editor +behavior through `editOptions`. + +```tsx +import type { FileContents, FileDiffOptions } from '@pierre/diffs'; +import { Editor, type EditorOptions } from '@pierre/diffs/edit'; +import { EditProvider, MultiFileDiff, Virtualizer } from '@pierre/diffs/react'; +import { useMemo, useRef, useState } from 'react'; + +const oldFile: FileContents = { + name: 'src/value.ts', + contents: 'export const value = 1;', +}; +const initialNewFile: FileContents = { + name: 'src/value.ts', + contents: 'export const value = 2;', +}; +const diffOptions: FileDiffOptions = { + theme: { light: 'pierre-light', dark: 'pierre-dark' }, + diffStyle: 'split', +}; + +function createEditor(options: EditorOptions) { + return new Editor(options); +} + +export function EditableDiff() { + const [edit, setEdit] = useState(false); + const [newFile, setNewFile] = useState(initialNewFile); + const draftRef = useRef(newFile); + const editorRef = useRef | null>(null); + const editOptions = useMemo>( + () => ({ + onAttach(editor) { + editorRef.current = editor; + }, + onChange(file) { + draftRef.current = file; + }, + }), + [] + ); + + function toggleEdit() { + if (edit) setNewFile(draftRef.current); + setEdit((value) => !value); + } + + return ( + + + + + + + + ); +} +``` + +Mount the provider near the application root when many surfaces use edit mode. +Keep `createEditor` and `editOptions` stable. Use `onAttach` when controls need +`undo`, `redo`, `applyEdits`, selections, markers, focus, or other editor APIs. + +## Keep annotations synchronized + +The `onChange` callback can supply the complete current annotation collection. +Replace the application collection when the callback supplies a different array. +Use `isFileAnnotationCollection` or `isDiffAnnotationCollection` to narrow its +type. + +Publish a changed React annotation array inside `flushSync`. This keeps its +coordinates aligned with the edited contents before paint. Store annotation UI +state by a stable metadata ID instead of a line number. + +## Edit `CodeView` items + +Wrap `CodeView` in the same `EditProvider`. Set `edit: true` on an item and +increment its `version`. Pass shared creation options through the `CodeView` +`editOptions` prop. + +Use `onItemEditChange` for live contents and annotation changes. Use +`onItemEditComplete` to commit the final `file` or rebuild the `fileDiff`. In +the same item update, set `edit: false`, assign a fresh `cacheKey`, and +increment `version`. + +Use the `CodeViewHandle.getEditor(id)` method for imperative editor commands. +The item editor keeps its document and history when virtualization removes the +item from the rendered window. + +When a worker pool highlights an editable surface, set +`useTokenTransformer: true` in the worker `highlighterOptions`. diff --git a/.agents/skills/diffs/references/recipe-react.md b/.agents/skills/diffs/references/recipe-react.md new file mode 100644 index 000000000..f1048230f --- /dev/null +++ b/.agents/skills/diffs/references/recipe-react.md @@ -0,0 +1,37 @@ +# Recipe: render with React + +## Select a surface + +| Input or layout | Component | +| ------------------------------------------- | ---------------- | +| One `FileContents` object | `File` | +| Old and new `FileContents` objects | `MultiFileDiff` | +| Existing `FileDiffMetadata` | `FileDiff` | +| One unified patch string | `PatchDiff` | +| One file with merge conflicts | `UnresolvedFile` | +| One scroll region with many files and diffs | `CodeView` | + +Use `MultiFileDiff` when the app has old and new file contents: + +```tsx +import { MultiFileDiff } from '@pierre/diffs/react'; + +; +``` + +Pass source data, annotations, and slot renderers as component props. Pass +display, theme, interaction, and highlighting settings through `options`. + +Keep file objects and option objects stable when their values do not change. +Wrap a large standalone surface in `Virtualizer`. Use `CodeView` when one scroll +region contains a list of files or diffs. + +Use the matching preload function from `@pierre/diffs/ssr` when the server must +render the initial highlighted markup. diff --git a/.env.example b/.env.example index c4206e5b6..3be3f9fb2 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,11 @@ GOOGLE_CLIENT_SECRET="" # MICROSOFT_CLIENT_ID="" # MICROSOFT_CLIENT_SECRET="" +# Optional. Enables Slack account linking on Settings > Connections. +# Add APP_URL + /api/auth/oauth2/callback/slack as the Slack OAuth redirect URL. +# SLACK_CLIENT_ID="" +# SLACK_CLIENT_SECRET="" + # Which Entra tenant may sign in. "common" (the default) accepts any work, # school or personal Microsoft account and leans on ALLOWED_SIGN_IN to decide # who actually gets in; your own tenant's GUID refuses everyone else at diff --git a/AGENTS.md b/AGENTS.md index 7083f64cc..a5c78b963 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ context until you read them, and the rules in them are not optional. | UI in `apps/app` or `packages/ui` | `docs/design.md` (below) | | Deal amounts, totals, charts, exchange rates | `docs/currency.md` | | The record sheet's Agent tab | `docs/agent-panel.md` | +| `/settings/connections`, integrations, the intake endpoint | `docs/connections.md` | | The tracking script, the collector, form submissions | `docs/tracking.md` | | Running it locally, Google Cloud, DB commands, secrets | `docs/setup.md` | | Anything that sends a telemetry event, or a new property on one | `docs/telemetry.md` | @@ -42,6 +43,207 @@ rules and skills you read. installed version. Read the relevant guide before writing eve code rather than working from memory — guessing typechecks, builds, and then behaves differently. +## Report every issue. Use ASD-STE100 + +Do not bury a known problem inside a paragraph. A problem inside prose is a +problem nobody reads. Report **every** issue, including ones you caused, in a +list at the end of your reply. + +Write every message, every report and every issue in **ASD-STE100** +(Simplified Technical English): + +- One idea per sentence. Maximum 20 words. +- Active voice. Present tense. No conditionals. +- One word for one meaning. Do not use synonyms for variety. +- Say the effect, not only the cause. +- No hedging: never "may", "might", "possibly", "somewhat". + +Use exactly this shape: + +``` +## Issues + +1. BROKEN — Slack is not connected. Agents that post to Slack fail. + Fix: connect Slack in Settings → Connections. +2. RISK — A run longer than 5 minutes is cancelled. Work is lost. + Fix: not done. Needs a separate execution lease. +3. NOT DONE — The manual run button shows on event-only agents. +``` + +Rules for the list: + +- One line for the problem. One line for the fix. +- Start each with **BROKEN**, **RISK**, **NOT DONE**, or **UNKNOWN**. +- **BROKEN** is failing now. **RISK** fails later. **NOT DONE** is unbuilt. + **UNKNOWN** is not investigated. +- If you introduced it, write **I caused this** on the fix line. +- Zero issues? Write `## Issues` then `None.` + +**Don't** — bury it in prose: + +> The fix works well. One honest limit: abandoning a sweep unblocks the queue but +> doesn't cancel the underlying hung promise, so it leaks until restart. + +**Do** — put it in the list: + +> 1. RISK — An abandoned sweep leaks its promise. Memory grows until restart. +> Fix: not done. Needs cancellation in `receive()`. I caused this. + +## A server page computes. A client component renders. + +A client component must never import a server package. `@crm/auth` and `@crm/db` +are server packages: their barrels reach Prisma, which reaches `pg`, which +reaches `dns`. The bundler follows that chain into the browser and the build +fails with `Module not found: Can't resolve 'dns'`. + +The import trace is the whole error. Read it from the bottom: the last line is +the page, the line above is the client component that leaked, and the top is the +Node module that cannot exist in a browser. + +**Don't** — a client component reaching for a server package: + +```tsx +"use client"; +import { describeSlackScopes, SLACK_SCOPE_GROUPS } from "@crm/auth"; + +export function SlackScopeGroups({ scopes }: { scopes: string[] }) { + const groups = SLACK_SCOPE_GROUPS.map(...) +} +``` + +**Do** — the page does the work and hands over plain data: + +```tsx +// page.tsx — server +import { describeSlackScopes, SLACK_SCOPE_GROUPS } from "@crm/auth"; + +const groups = groupScopes(status.scopes); +return ; +``` + +```tsx +// slack-scope-groups.tsx — client +"use client"; + +export type ScopeGroup = { id: string; label: string; scopes: ScopeLine[] }; + +export function SlackScopeGroups({ groups }: { groups: ScopeGroup[] }) { … } +``` + +Rules that follow: + +- The client component owns its own prop types. It does not re-export a server + type to get them. +- Anything interactive — an accordion, a dialog, a search field — is a client + component that receives finished data. It never derives it. +- A `"use client"` file may import from `@crm/ui`, the tRPC client, and React. + Anything else needs checking. +- The server page is where `await` and secrets live. The client file has neither. + +## Constants belong in one file per area, not beside their first use + +A number that someone will want to tune goes in a named config module for its +area. It does not go at the top of whichever file happened to need it first. +Somebody changing a timeout must not have to know which file to open. + +**Don't** — one constant per file, found only by grep: + +```ts +// dispatch.ts +const DRAIN_TIMEOUT_MS = 4 * 60_000; +// crm.ts +const STALE_QUEUE_MS = 5 * 60_000; +// tasks.ts +const LEASE_MS = 10 * 60_000; +``` + +**Do** — one object, grouped by concern, imported where used: + +```ts +// dispatch-config.ts +export const DISPATCH = { + sweep: { timeoutMs: 4 * MINUTE_MS, staleQueueMs: 5 * MINUTE_MS }, + task: { leaseMs: 10 * MINUTE_MS }, +} as const; +``` + +`apps/agent/agent/lib/dispatch-config.ts` is the pattern. Rules: + +- Group by concern, not by file that uses it. +- Derive units from one base (`MINUTE_MS`). Never write `4 * 60_000` twice. +- `as const`, so the values are literal types. +- No magic numbers inline. If it is tunable, it belongs in the config. +- One convention across the codebase. Do not invent a local style for one file. + +## Parse at the boundary, never pass `Record` around + +Untyped data — a Prisma `Json` column, a webhook body, an API response — is +parsed into a **domain type at the moment it enters the process**, with Zod, in a +module that owns that shape. Every consumer downstream receives the parsed type +and nothing else. `Record`, `unknown` casts and one-off +`recordOf()` helpers are how a shape becomes unknowable and a typo becomes a +runtime bug two files away. + +**Don't** — reach into raw JSON, re-deriving the shape at each call site: + +```ts +function manifestActions(value: unknown) { + const actions = recordOf(value).actions; + return Array.isArray(actions) ? actions.map(recordOf) : []; +} + +const slack = manifestActions(version.manifest).find( + (action) => action.type === "slack.message.post", +); +const id = (slack?.destination as Record)?.id; +``` + +Nothing here is checked. `destination` may be missing, `id` may be a number, and +the compiler cannot help. Rename a field and every one of these silently returns +`undefined`. + +**Do** — one schema, parsed once, at the read: + +```ts +export const agentManifestAction = z.discriminatedUnion("type", [ + z.object({ + type: z.literal(AGENT_ACTION_TYPES.SLACK_MESSAGE_POST), + provider: z.literal("slack"), + summary: z.string(), + destination: z.object({ + kind: z.enum(["channel", "user"]), + resolution: z.literal("chosen"), + id: z.string().trim().min(1).max(120), + label: z.string().trim().min(1).max(120), + }), + }), +]); + +export type AgentManifest = z.infer; + +export function parseAgentManifest(value: unknown): AgentManifest { … } +``` + +```ts +const manifest = parseAgentManifest(version.manifest); +const slack = manifest.actions.find( + (action) => action.type === "slack.message.post", +); +const id = slack?.destination.id; +``` + +`apps/agent/agent/lib/agent-manifest.ts` is the pattern. Rules that follow from +it: + +- The schema describes what is **actually stored**, not the loosest thing that + parses. If a test fixture fails the schema, fix the fixture — a fixture that + omits required fields is testing data that cannot exist. +- Parse failure is a real error with a real message. Do not swallow it into an + empty array, because "unreadable manifest" and "no actions" are different + problems and only one of them is the user's fault. +- Derive types with `z.infer`. Never hand-write an interface beside a schema; + they drift. + ## Design @docs/design.md diff --git a/apps/agent/agent/channels/crm.ts b/apps/agent/agent/channels/crm.ts index 43faef02d..0128065cf 100644 --- a/apps/agent/agent/channels/crm.ts +++ b/apps/agent/agent/channels/crm.ts @@ -1,22 +1,38 @@ import { timingSafeEqual } from "node:crypto"; -import { EnrichmentStatus } from "@crm/db"; -import { defineChannel, POST } from "eve/channels"; +import { EnrichmentStatus, Prisma } from "@crm/db"; +import { MAX_ATTEMPTS } from "@crm/db/agent-tasks"; +import { schemas } from "@crm/validation"; +import { defineChannel, GET, POST } from "eve/channels"; +import { persistBuilderInputRequest } from "../lib/builder-input"; import { verifyKey } from "../lib/context-dev"; import { builderIdFromToken, + builderToken, + cancelRun, dispatchAgentRun, dispatchBuilderSubmission, drainAgentRuns, drainBuilder, failRun, runIdFromToken, + runToken, } from "../lib/custom-agent-dispatch"; -import { brief, drainAll, taskAuth } from "../lib/dispatch"; +import { + brief, + DRAIN_TIMEOUT_MS, + dispatchHealth, + drainAll, + taskAuth, +} from "../lib/dispatch"; +import { DISPATCH } from "../lib/dispatch-config"; import { settle } from "../lib/enrichment"; import { finishRun } from "../lib/run-runtime"; +import { attribute } from "../lib/session-purpose"; +import { createSlackChannel } from "../lib/slack-membership"; import { completeTask, taskSubject } from "../lib/tasks"; const TASK_MARKER = "task:"; +const STALE_QUEUE_MS = DISPATCH.sweep.staleQueueMs; function authorised(request: Request): boolean { const secret = process.env.AGENT_BRIDGE_SECRET?.trim(); @@ -46,18 +62,50 @@ export function taskFromToken(token: string | undefined): string | null { export default defineChannel({ routes: [ + GET("/internal/crm/dispatch-health", async (request) => { + if (!authorised(request)) { + return new Response("Unauthorized", { status: 401 }); + } + + const health = dispatchHealth(); + const { db } = await import("@crm/db"); + const now = new Date(); + const overdue = await db.agentTask.count({ + where: { + finishedAt: null, + dueAt: { lte: new Date(now.getTime() - STALE_QUEUE_MS) }, + attempts: { lt: MAX_ATTEMPTS }, + OR: [{ leasedUntil: null }, { leasedUntil: { lt: now } }], + }, + }); + + const wedged = health.stalledMs > DRAIN_TIMEOUT_MS; + return Response.json( + { + ok: !wedged && overdue === 0, + wedged, + overdueTasks: overdue, + ...health, + }, + { status: wedged || overdue > 0 ? 503 : 200 }, + ); + }), + POST("/internal/crm/dispatch", async (request, { send, waitUntil }) => { if (!authorised(request)) { return new Response("Unauthorized", { status: 401 }); } waitUntil( - drainAll((task) => - send(brief(task), { - auth: taskAuth(task), - continuationToken: taskToken(task.id), - }), - ), + (async () => { + await drainAll((task) => + send(brief(task), { + auth: taskAuth(task), + continuationToken: taskToken(task.id), + }), + ); + await drainAgentRuns(send); + })(), ); return new Response(null, { status: 202 }); @@ -87,6 +135,50 @@ export default defineChannel({ }, ), + POST("/internal/crm/cancel-run", async (request, { cancel }) => { + if (!authorised(request)) { + return new Response("Unauthorized", { status: 401 }); + } + + const body = (await request.json().catch(() => null)) as { + runId?: unknown; + } | null; + const runId = typeof body?.runId === "string" ? body.runId.trim() : null; + if (!runId) { + return Response.json({ error: "No run id was sent." }, { status: 400 }); + } + + return Response.json( + await cancel({ continuationToken: runToken(runId) }), + ); + }), + + POST("/internal/crm/slack/create-channel", async (request) => { + if (!authorised(request)) { + return new Response("Unauthorized", { status: 401 }); + } + + const parsed = schemas.slack.createPayload.safeParse( + await request.json().catch(() => null), + ); + + if (!parsed.success) { + return Response.json( + { error: "That channel name is not usable." }, + { status: 400 }, + ); + } + + const outcome = await createSlackChannel( + parsed.data.channelName, + parsed.data.isPrivate, + ); + + return "error" in outcome + ? Response.json({ error: outcome.error }, { status: 422 }) + : Response.json({ channel: outcome }); + }), + POST("/internal/crm/verify-key", async (request) => { if (!authorised(request)) { return new Response("Unauthorized", { status: 401 }); @@ -111,6 +203,14 @@ export default defineChannel({ ], events: { + async "input.requested"(data, channel, ctx) { + await persistBuilderInputRequest( + data, + channel.continuationToken, + attribute(ctx, "conversationId"), + ); + }, + async "message.completed"(data, channel) { const conversationId = builderIdFromToken(channel.continuationToken); if (!conversationId || !data.message?.trim()) return; @@ -141,7 +241,7 @@ export default defineChannel({ await import("@crm/db").then(({ db }) => db.agentConversation.updateMany({ where: { id: conversationId, kind: "BUILDER" }, - data: { continuationToken: channel.continuationToken }, + data: { continuationToken: builderToken(conversationId) }, }), ); }, @@ -159,11 +259,34 @@ export default defineChannel({ return; } + const conversationId = builderIdFromToken(channel.continuationToken); + if (conversationId) { + const { db } = await import("@crm/db"); + await db.agentConversation.updateMany({ + where: { id: conversationId, kind: "BUILDER" }, + data: { + continuationToken: builderToken(conversationId), + pendingInputRequest: Prisma.DbNull, + }, + }); + return; + } + const runId = runIdFromToken(channel.continuationToken); if (runId) await failRun(runId, "TURN_FAILED", reason); }, async "session.completed"(_data, channel) { + const conversationId = builderIdFromToken(channel.continuationToken); + if (conversationId) { + const { db } = await import("@crm/db"); + await db.agentConversation.updateMany({ + where: { id: conversationId, kind: "BUILDER" }, + data: { pendingInputRequest: Prisma.DbNull }, + }); + return; + } + const runId = runIdFromToken(channel.continuationToken); if (!runId) return; @@ -172,11 +295,43 @@ export default defineChannel({ where: { id: runId }, select: { status: true, summary: true, result: true }, }); - if (run?.status === "RUNNING") { + if (run?.status !== "RUNNING") return; + + try { await finishRun(runId, { summary: run.summary ?? "The agent run completed.", result: recordOf(run.result), }); + } catch (error) { + await failRun( + runId, + "NEVER_SETTLED", + error instanceof Error ? error.message : String(error), + ).catch(() => {}); + } + }, + + async "turn.cancelled"(_data, channel) { + const conversationId = builderIdFromToken(channel.continuationToken); + if (conversationId) { + const { db } = await import("@crm/db"); + await db.agentConversation.updateMany({ + where: { id: conversationId, kind: "BUILDER" }, + data: { + continuationToken: builderToken(conversationId), + pendingInputRequest: Prisma.DbNull, + }, + }); + return; + } + + const runId = runIdFromToken(channel.continuationToken); + if (runId) { + await cancelRun( + runId, + "CANCELLED", + "The run was stopped before it finished.", + ); } }, @@ -187,7 +342,8 @@ export default defineChannel({ await db.agentConversation.updateMany({ where: { id: conversationId, kind: "BUILDER" }, data: { - continuationToken: channel.continuationToken, + continuationToken: builderToken(conversationId), + pendingInputRequest: Prisma.DbNull, lastAssistantAt: new Date(), lastMessageAt: new Date(), }, diff --git a/apps/agent/agent/hooks/audit.ts b/apps/agent/agent/hooks/audit.ts index f096d34cc..b553cc188 100644 --- a/apps/agent/agent/hooks/audit.ts +++ b/apps/agent/agent/hooks/audit.ts @@ -1,21 +1,23 @@ -import { db, type Prisma } from "@crm/db"; +import { db, Prisma } from "@crm/db"; import { defineHook } from "eve/hooks"; +import { isTransportOnlyEvent } from "../lib/event-persistence"; import { currentFocus } from "../lib/focus"; import { lockAgentRun } from "../lib/run-state"; import { attribute, purposeOf } from "../lib/session-purpose"; -const CUMULATIVE_DELTAS = new Set(["reasoning.appended"]); - export default defineHook({ events: { async "*"(event, ctx) { const id = event.meta?.id; - if (!id || CUMULATIVE_DELTAS.has(event.type)) return; + if (!id || isTransportOnlyEvent(event.type)) return; try { const data = ("data" in event ? (event.data ?? {}) : {}) as object; const emittedAt = event.meta?.at ? new Date(event.meta.at) : new Date(); + const purpose = purposeOf(ctx); + const conversationId = + purpose === "builder" ? attribute(ctx, "conversationId") : null; await db.$transaction(async (tx) => { await tx.agentEvent.createMany({ data: [ @@ -23,6 +25,7 @@ export default defineHook({ id, sessionId: ctx.session.id, contactId: currentFocus().contactId, + conversationId, type: event.type, data, emittedAt, @@ -31,7 +34,6 @@ export default defineHook({ skipDuplicates: true, }); - const purpose = purposeOf(ctx); if (purpose === "builder") { await persistBuilderLifecycle(tx, event, ctx.session.id, ctx); } @@ -72,6 +74,10 @@ async function persistBuilderLifecycle( where: { id: submissionId, conversationId }, data: { status: "ACCEPTED", acceptedAt: new Date() }, }); + await tx.agentConversation.updateMany({ + where: { id: conversationId, kind: "BUILDER" }, + data: { pendingInputRequest: Prisma.DbNull }, + }); } } } diff --git a/apps/agent/agent/instructions/task.ts b/apps/agent/agent/instructions/task.ts index 3cf99d131..e617aab09 100644 --- a/apps/agent/agent/instructions/task.ts +++ b/apps/agent/agent/instructions/task.ts @@ -64,7 +64,7 @@ export function builderTaskMarkdown( ): string { const task = commandType === "CREATE_AGENT" - ? `This private CRM chat turn is authorized to create or revise an agent. Call agent_builder exactly once. Pass the complete request, the conversation's relevant decisions, every tagged resource, and your understanding of any attachment. Do not call research tools or mutate CRM records yourself. The specialist asks any essential clarification directly through ask_question and returns only when the draft is ready. Never retry agent_builder in the same turn. If the specialist fails, explain that the build could not finish and ask the user to try again instead of delegating again. If the specialist returns draft_ready, relay its concise summary and explain that the draft is ready for human review and is not deployed yet.` + ? `This private CRM chat turn is authorized to create or revise an agent. Call agent_builder exactly once and call it immediately; do not ask the user a clarification yourself. Pass the complete request, the conversation's relevant decisions, every tagged resource, and your understanding of any attachment. Do not call research tools or mutate CRM records yourself. The specialist inspects authoritative context, asks any essential clarification directly through ask_question, and returns only when the draft is ready. Never retry agent_builder in the same turn. If the specialist fails, explain that the build could not finish and ask the user to try again instead of delegating again. If the specialist returns draft_ready, relay its concise summary and explain that the draft is ready for human review and is not deployed yet.` : `This is a private CRM assistant chat. Answer the user's question directly. Use tagged records as scope and use available read-only CRM and research tools when evidence is needed. Use list_deals for pipeline-wide, open-deal, or inactivity questions and follow its pagination until the requested scope is complete. The chat renders list_deals output as a structured deal list. Do not restate or enumerate individual deal rows in prose, bullets, or tables; the structured list is the sole row-level presentation. Give only a concise synthesis, caveats, and useful next actions after the tool results. If one materially necessary decision is missing, call ask_question with one focused follow-up instead of guessing; do not interrupt for optional detail. Do not call agent_builder, create an agent draft, or mutate CRM records on this turn. Agent creation begins only from an explicit request to create or build one. Be concise, distinguish CRM evidence from inference, and say when the CRM does not contain the answer.`; return needsTitle diff --git a/apps/agent/agent/lib/agent-actions.ts b/apps/agent/agent/lib/agent-actions.ts new file mode 100644 index 000000000..a2993a50d --- /dev/null +++ b/apps/agent/agent/lib/agent-actions.ts @@ -0,0 +1,42 @@ +export const AGENT_ACTION_TYPES = { + CRM_ACTIVITY_CREATE: "crm.activity.create", + RUN_SUMMARY: "run.summary", + SLACK_MESSAGE_POST: "slack.message.post", +} as const; + +export type AgentActionType = + (typeof AGENT_ACTION_TYPES)[keyof typeof AGENT_ACTION_TYPES]; + +export const AGENT_ACTION_EXECUTORS = { + [AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE]: "create_crm_activity", + [AGENT_ACTION_TYPES.RUN_SUMMARY]: "finish_run", + [AGENT_ACTION_TYPES.SLACK_MESSAGE_POST]: "post_slack_message", +} as const satisfies Record; + +export function isAgentActionType(value: unknown): value is AgentActionType { + return Object.hasOwn(AGENT_ACTION_EXECUTORS, String(value)); +} + +export type AgentActionDependency = { + readonly id: string; + readonly label: string; + readonly resourceId: string; + readonly fix: string; +}; + +export const AGENT_ACTION_DEPENDENCIES = { + [AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE]: null, + [AGENT_ACTION_TYPES.RUN_SUMMARY]: null, + [AGENT_ACTION_TYPES.SLACK_MESSAGE_POST]: { + id: "slack", + label: "Slack", + resourceId: "slack:workspace", + fix: "Connect Slack in Settings → Connections.", + }, +} as const satisfies Record; + +export function actionDependency( + type: AgentActionType, +): AgentActionDependency | null { + return AGENT_ACTION_DEPENDENCIES[type]; +} diff --git a/apps/agent/agent/lib/agent-manifest.ts b/apps/agent/agent/lib/agent-manifest.ts new file mode 100644 index 000000000..f8d8b661c --- /dev/null +++ b/apps/agent/agent/lib/agent-manifest.ts @@ -0,0 +1,111 @@ +import { CRM_EVENT_TYPES } from "@crm/db/crm-events"; +import { z } from "zod"; +import { AGENT_ACTION_TYPES } from "./agent-actions"; + +const slackDestination = z.object({ + kind: z.enum(["channel", "user"]), + resolution: z.literal("chosen"), + id: z.string().trim().min(1).max(120), + label: z.string().trim().min(1).max(120), +}); + +export const agentManifestAction = z.discriminatedUnion("type", [ + z.object({ + type: z.literal(AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE), + provider: z.literal("crm"), + summary: z.string(), + activityTypes: z + .array(z.enum(["NOTE", "TASK"])) + .min(1) + .max(2), + }), + z.object({ + type: z.literal(AGENT_ACTION_TYPES.RUN_SUMMARY), + provider: z.literal("crm"), + summary: z.string(), + }), + z.object({ + type: z.literal(AGENT_ACTION_TYPES.SLACK_MESSAGE_POST), + provider: z.literal("slack"), + summary: z.string(), + destination: slackDestination, + }), +]); + +export const agentManifestTrigger = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("MANUAL"), + name: z.string(), + summary: z.string(), + config: z.object({}), + }), + z.object({ + type: z.literal("SCHEDULE"), + name: z.string(), + summary: z.string(), + config: z.object({ + nextRunAt: z.string(), + intervalMinutes: z.number().int().min(1), + }), + }), + z.object({ + type: z.literal("EVENT"), + name: z.string(), + summary: z.string(), + config: z.object({ event: z.enum(CRM_EVENT_TYPES) }), + }), +]); + +export const agentManifestResource = z.object({ + id: z.string(), + kind: z.enum(["company", "contact", "deal", "integration"]), + label: z.string(), +}); + +export const agentManifest = z + .object({ + description: z.string().optional(), + actions: z.array(agentManifestAction).min(1), + triggers: z.array(agentManifestTrigger).min(1), + dataScope: z.object({ + mode: z.enum(["SELECTED", "WORKSPACE"]), + summary: z.string(), + resources: z.array(agentManifestResource).default([]), + }), + }) + .superRefine((manifest, context) => { + const actionTypes = new Set(); + for (const [index, action] of manifest.actions.entries()) { + if (actionTypes.has(action.type)) { + context.addIssue({ + code: "custom", + path: ["actions", index, "type"], + message: `Duplicate ${action.type} action`, + }); + } + actionTypes.add(action.type); + } + }); + +export type SlackDestination = z.infer; +export type AgentManifestAction = z.infer; +export type AgentManifestTrigger = z.infer; +export type AgentManifest = z.infer; + +export class InvalidAgentManifest extends Error { + constructor(readonly issues: string) { + super(`The deployed version's manifest is unreadable: ${issues}`); + this.name = "InvalidAgentManifest"; + } +} + +export function parseAgentManifest(value: unknown): AgentManifest { + const parsed = agentManifest.safeParse(value); + if (parsed.success) return parsed.data; + + throw new InvalidAgentManifest( + parsed.error.issues + .map((issue) => `${issue.path.join(".") || "manifest"} ${issue.message}`) + .join("; "), + ); +} diff --git a/apps/agent/agent/lib/builder-input.ts b/apps/agent/agent/lib/builder-input.ts new file mode 100644 index 000000000..6e97f263c --- /dev/null +++ b/apps/agent/agent/lib/builder-input.ts @@ -0,0 +1,101 @@ +import { db, type Prisma } from "@crm/db"; +import { lockIdempotencyKey } from "@crm/db/idempotency"; +import { type InputRequested, parse, schemas } from "@crm/validation"; +import { + builderIdFromToken, + builderToken, + lockBuilderConversation, +} from "./custom-agent-dispatch"; + +const BUILDER_INPUT = { + eventType: "input.requested", + idPrefix: "builder-input", +} as const; + +export async function persistBuilderInputRequest( + data: unknown, + continuationToken: string | undefined, + authenticatedConversationId?: string | null, +): Promise { + const conversationId = + authenticatedConversationId?.trim() || + builderIdFromToken(continuationToken); + if (!conversationId) return false; + + const event = parse( + schemas.agents.inputRequested, + data, + BUILDER_INPUT.eventType, + ); + const question = event.requests.find( + (request) => request.kind === "question", + ); + if (!question) return false; + + const eventId = `${eventPrefix(conversationId)}${question.requestId}`; + + return db.$transaction(async (tx) => { + await lockIdempotencyKey(tx, eventId); + + const replay = await tx.agentEvent.findUnique({ + where: { id: eventId }, + select: { id: true }, + }); + if (replay) return false; + + const conversation = await lockBuilderConversation(tx, conversationId); + if (conversation?.kind !== "BUILDER" || !conversation.sessionId) { + return false; + } + + const recorded = await tx.agentEvent.findFirst({ + where: { + conversationId: conversation.id, + id: { startsWith: eventPrefix(conversation.id) }, + }, + orderBy: [{ emittedAt: "desc" }, { id: "desc" }], + select: { id: true, data: true }, + }); + if ( + recorded && + !supersedes( + event, + parse(schemas.agents.inputRequested, recorded.data, recorded.id), + ) + ) { + return false; + } + + await tx.agentEvent.create({ + data: { + id: eventId, + sessionId: conversation.sessionId, + conversationId: conversation.id, + type: BUILDER_INPUT.eventType, + data: event as Prisma.InputJsonValue, + emittedAt: new Date(), + }, + }); + + await tx.agentConversation.update({ + where: { id: conversation.id }, + data: { + continuationToken: builderToken(conversation.id), + pendingInputRequest: question as Prisma.InputJsonValue, + }, + }); + + return true; + }); +} + +function eventPrefix(conversationId: string): string { + return `${BUILDER_INPUT.idPrefix}:${conversationId}:`; +} + +function supersedes(event: InputRequested, current: InputRequested): boolean { + if (event.sequence !== current.sequence) { + return event.sequence > current.sequence; + } + return event.stepIndex > current.stepIndex; +} diff --git a/apps/agent/agent/lib/builder-runtime.ts b/apps/agent/agent/lib/builder-runtime.ts index 434e59ff7..8a20494c0 100644 --- a/apps/agent/agent/lib/builder-runtime.ts +++ b/apps/agent/agent/lib/builder-runtime.ts @@ -1,6 +1,14 @@ import { isDeepStrictEqual } from "node:util"; import { db, type Prisma } from "@crm/db"; +import { + CRM_EVENT_CATALOG, + CRM_EVENT_TYPES, + type CrmEventType, +} from "@crm/db/crm-events"; import { readAgentModel } from "@crm/db/settings"; +import { WORKSPACE_ID } from "@crm/db/workspace"; +import { AGENT_ACTION_TYPES, actionDependency } from "./agent-actions"; +import { requestStaleSlackInventorySync } from "./slack-people"; const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"; const CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.readonly"; @@ -12,31 +20,43 @@ export type BuilderResource = { }; export type DraftTrigger = { - type: "MANUAL" | "SCHEDULE"; + type: "MANUAL" | "SCHEDULE" | "EVENT"; name: string; summary: string; + event?: CrmEventType | null; nextRunAt?: string | null; intervalMinutes?: number | null; }; export type DraftAction = | { - type: "crm.activity.create"; + type: typeof AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE; provider: "crm"; summary: string; activityTypes: ("NOTE" | "TASK")[]; } | { - type: "run.summary"; + type: typeof AGENT_ACTION_TYPES.RUN_SUMMARY; provider: "crm"; summary: string; + } + | { + type: typeof AGENT_ACTION_TYPES.SLACK_MESSAGE_POST; + provider: "slack"; + summary: string; + destination: { + kind: "channel" | "user"; + resolution: "chosen"; + id: string; + label: string; + }; }; export type DraftAgentInput = { name: string; description: string; instructions: string; - trigger: DraftTrigger; + triggers: DraftTrigger[]; recordScope: "SELECTED" | "WORKSPACE"; resources: BuilderResource[]; actions: DraftAction[]; @@ -152,6 +172,10 @@ export async function builderContext(conversationId: string, userId: string) { title: conversation.title, }, availableConnections: await connectionStatus(userId), + crmEvents: CRM_EVENT_TYPES.map((type) => ({ + type, + ...CRM_EVENT_CATALOG[type], + })), resources: await describeResources(resources), existingDraft: conversation.agent, now: new Date().toISOString(), @@ -190,23 +214,25 @@ export async function saveBuilderDraft( const model = await readAgentModel(db); const now = new Date(); - const nextRunAt = scheduleDate(input.trigger, now); + const manifestTriggers = input.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "SCHEDULE" + ? { + intervalMinutes: trigger.intervalMinutes, + nextRunAt: scheduleDate(trigger, now)?.toISOString(), + } + : trigger.type === "EVENT" + ? { event: trigger.event } + : {}, + })); const manifest = { kind: "crm-team-agent", name: input.name, description: input.description, - trigger: { - type: input.trigger.type, - name: input.trigger.name, - summary: input.trigger.summary, - config: - input.trigger.type === "SCHEDULE" - ? { - intervalMinutes: input.trigger.intervalMinutes, - nextRunAt: nextRunAt?.toISOString(), - } - : {}, - }, + triggers: manifestTriggers, dataScope: { mode: input.recordScope, summary: scopeSummary(input.recordScope, input.resources), @@ -334,16 +360,16 @@ export async function saveBuilderDraft( await persistArtifactSnapshots(tx, conversationId, version.id, files); - await tx.agentTrigger.create({ - data: { + await tx.agentTrigger.createMany({ + data: input.triggers.map((trigger, index) => ({ agentId, versionId: version.id, - type: input.trigger.type, - name: input.trigger.name, - config: manifest.trigger.config as Prisma.InputJsonValue, + type: trigger.type, + name: trigger.name, + config: manifestTriggers[index]?.config as Prisma.InputJsonValue, createdById: userId, - nextRunAt, - }, + nextRunAt: scheduleDate(trigger, now), + })), }); if (created) { @@ -493,20 +519,39 @@ async function validateDraft( if (resource.id === "google:calendar" && !connections.calendar) { issues.push("Google Calendar is not connected for the chat owner."); } - if (!["google:gmail", "google:calendar"].includes(resource.id)) { + if (resource.id === "slack:workspace" && !connections.slack) { + issues.push("Slack is not connected for this workspace."); + } + if ( + !["google:gmail", "google:calendar", "slack:workspace"].includes( + resource.id, + ) + ) { issues.push(`${resource.label} is not an available integration.`); continue; } capabilities.add(`${resource.id}.read`); } + const actionTypes = new Set(); for (const action of input.actions) { + if (actionTypes.has(action.type)) { + issues.push(`The ${action.type} action is listed more than once.`); + } + actionTypes.add(action.type); capabilities.add(action.type); - if (action.type !== "crm.activity.create") continue; + if (action.type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST) { + issues.push(...slackDestinationIssues(action.destination, connections)); + } + if (action.type !== AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE) continue; if (new Set(action.activityTypes).size !== action.activityTypes.length) { issues.push("CRM activity permissions must not repeat an activity type."); } } + if (!actionTypes.has(AGENT_ACTION_TYPES.RUN_SUMMARY)) { + issues.push("An agent needs one run summary action."); + } + issues.push(...actionIntegrationIssues(input.actions, input.resources)); const missingRecords = await missingResourceIds(input.resources); issues.push( @@ -515,21 +560,44 @@ async function validateDraft( ), ); - if (input.trigger.type === "SCHEDULE") { - const next = Date.parse(input.trigger.nextRunAt ?? ""); - if (!Number.isFinite(next) || next <= Date.now()) { - issues.push("A scheduled agent needs a future next run time."); + const eventTypes = new Set(); + let manualTriggers = 0; + if (input.triggers.length === 0) { + issues.push("An agent needs at least one trigger."); + } + for (const trigger of input.triggers) { + if (trigger.type === "MANUAL") manualTriggers += 1; + if (trigger.type === "SCHEDULE") { + const next = Date.parse(trigger.nextRunAt ?? ""); + if (!Number.isFinite(next) || next <= Date.now()) { + issues.push("A scheduled trigger needs a future next run time."); + } + if ( + !trigger.intervalMinutes || + trigger.intervalMinutes < 1 || + trigger.intervalMinutes > 525_600 + ) { + issues.push( + "A scheduled trigger needs a recurrence from 1 minute to 1 year.", + ); + } } - if ( - !input.trigger.intervalMinutes || - input.trigger.intervalMinutes < 1 || - input.trigger.intervalMinutes > 525_600 - ) { - issues.push( - "A scheduled agent needs a recurrence from 1 minute to 1 year.", - ); + if (trigger.type === "EVENT") { + if (!trigger.event) { + issues.push("An event trigger needs a supported CRM event."); + } else if (eventTypes.has(trigger.event)) { + issues.push(`The ${trigger.event} event is already a trigger.`); + } else { + eventTypes.add(trigger.event); + } + if (input.recordScope !== "WORKSPACE") { + issues.push("Event triggers need workspace CRM scope."); + } } } + if (manualTriggers > 1) { + issues.push("An agent needs at most one manual trigger."); + } return { valid: issues.length === 0, @@ -540,21 +608,117 @@ async function validateDraft( } async function connectionStatus(userId: string) { - const accounts = await db.account.findMany({ - where: { userId, providerId: "google" }, - select: { scope: true }, + const [googleAccounts, slackAccount, workspaceMembers] = await Promise.all([ + db.account.findMany({ + where: { userId, providerId: "google" }, + select: { providerId: true, scope: true }, + }), + db.account.findFirst({ + where: { providerId: "slack", accessToken: { not: null } }, + orderBy: { updatedAt: "desc" }, + select: { id: true }, + }), + db.member.findMany({ + where: { organizationId: WORKSPACE_ID }, + orderBy: { user: { name: "asc" } }, + select: { + user: { + select: { + name: true, + email: true, + slackMemberMatch: { + select: { + slackUserId: true, + slackHandle: true, + slackEmail: true, + }, + }, + }, + }, + }, + }), + ]); + if (slackAccount) void requestStaleSlackInventorySync(); + + const slackChannels = await db.slackChannel.findMany({ + where: { available: true }, + orderBy: { name: "asc" }, + take: 100, + select: { id: true, name: true, memberCount: true }, }); const scopes = new Set( - accounts.flatMap((account) => (account.scope ?? "").split(/[,\s]+/)), + googleAccounts.flatMap((account) => (account.scope ?? "").split(/[,\s]+/)), ); + const slackPeople = workspaceMembers.flatMap(({ user }) => { + const match = user.slackMemberMatch; + return match?.slackUserId && match.slackHandle + ? [ + { + id: match.slackUserId, + label: match.slackHandle, + name: user.name, + email: user.email, + slackEmail: match.slackEmail, + }, + ] + : []; + }); return { gmail: scopes.has(GMAIL_SCOPE), calendar: scopes.has(CALENDAR_SCOPE), + slack: Boolean(slackAccount), + slackChannels: slackChannels.map((channel) => ({ + id: channel.id, + label: `#${channel.name}`, + memberCount: channel.memberCount, + })), + slackPeople, crm: true, }; } +type SlackConnections = Awaited>; + +export function actionIntegrationIssues( + actions: DraftAction[], + resources: BuilderResource[], +): string[] { + const integrations = new Set( + resources + .filter((resource) => resource.kind === "integration") + .map((resource) => resource.id), + ); + + return actions.flatMap((action) => { + const dependency = actionDependency(action.type); + if (!dependency || integrations.has(dependency.resourceId)) return []; + return [ + `Posting to ${dependency.label} needs ${dependency.label} in this agent's integrations.`, + ]; + }); +} + +export function slackDestinationIssues( + destination: Extract< + DraftAction, + { type: typeof AGENT_ACTION_TYPES.SLACK_MESSAGE_POST } + >["destination"], + connections: Pick, +): string[] { + const noun = destination.kind === "user" ? "person" : "channel"; + const options = + destination.kind === "user" + ? connections.slackPeople + : connections.slackChannels; + const option = options.find((entry) => entry.id === destination.id); + if (!option) return [`The Slack ${noun} is not available to this workspace.`]; + if (option.label !== destination.label) { + return [`The Slack ${noun} must use its exact inspected label.`]; + } + return []; +} + async function describeResources(resources: BuilderResource[]) { return Promise.all( resources.map(async (resource) => { @@ -652,11 +816,14 @@ function scheduleDate(trigger: DraftTrigger, now: Date): Date | null { } function artifactFiles(input: DraftAgentInput, manifest: object) { + const triggerSummary = input.triggers + .map((trigger) => `- ${trigger.summary}`) + .join("\n"); return [ { path: "agent/README.md" as const, language: ARTIFACT_LANGUAGES["agent/README.md"], - content: `# ${input.name}\n\n${input.description}\n\n## Trigger\n\n${input.trigger.summary}\n\n## Access\n\n${input.access.map((item) => `- ${item}`).join("\n") || "- CRM data in the approved scope"}\n`, + content: `# ${input.name}\n\n${input.description}\n\n## Triggers\n\n${triggerSummary}\n\n## Access\n\n${input.access.map((item) => `- ${item}`).join("\n") || "- CRM data in the approved scope"}\n`, }, { path: "agent/instructions.md" as const, diff --git a/apps/agent/agent/lib/custom-agent-dispatch.ts b/apps/agent/agent/lib/custom-agent-dispatch.ts index 1685b025a..e351939f4 100644 --- a/apps/agent/agent/lib/custom-agent-dispatch.ts +++ b/apps/agent/agent/lib/custom-agent-dispatch.ts @@ -1,12 +1,21 @@ -import { db, type Prisma } from "@crm/db"; +import { db, Prisma } from "@crm/db"; +import { CRM_EVENT_CATALOG, isCrmEventType } from "@crm/db/crm-events"; +import { lockIdempotencyKey } from "@crm/db/idempotency"; import type { SendFn } from "eve/channels"; -import { lockAgentRun, runTerminalEventId } from "./run-state"; - -const BUILDER_BATCH = 20; -const RUN_BATCH = 20; -const MAX_BUILDER_ATTEMPTS = 3; -const BUILDER_LEASE_MS = 5 * 60_000; -const RUN_DELIVERY_LEASE_MS = 5 * 60_000; +import { DISPATCH } from "./dispatch-config"; +import { DEPENDENCY_UNAVAILABLE, runDependencyFailure } from "./run-preflight"; +import { + isTerminalRunStatus, + lockAgentRun, + runTerminalEventId, +} from "./run-state"; +import type { LeasedTask } from "./tasks"; + +const BUILDER_BATCH = DISPATCH.builder.batch; +const RUN_BATCH = DISPATCH.run.batch; +const MAX_BUILDER_ATTEMPTS = DISPATCH.builder.maxAttempts; +const BUILDER_LEASE_MS = DISPATCH.builder.leaseMs; +const RUN_DELIVERY_LEASE_MS = DISPATCH.run.deliveryLeaseMs; export async function pendingBuilderSubmissionIds(): Promise { await recoverBuilderSubmissions(); @@ -156,7 +165,10 @@ export async function dispatchBuilderSubmission( }); await tx.agentConversation.update({ where: { id: conversationId }, - data: { sessionId: session.id }, + data: { + sessionId: session.id, + pendingInputRequest: Prisma.DbNull, + }, }); }); @@ -253,22 +265,171 @@ export async function queueDueAgentRuns(now = new Date()): Promise { return queued; } +export async function queueEventAgentRuns( + task: Pick< + LeasedTask, + "id" | "contactId" | "companyId" | "dealId" | "payload" + >, +): Promise { + const payload = recordOf(task.payload); + const eventType = payload.type; + const record = recordOf(payload.record); + const recordKind = textOf(record.kind); + const recordId = textOf(record.id); + const occurredAt = textOf(payload.occurredAt); + const occurredAtDate = new Date(occurredAt); + const taskRecordId = + recordKind === "contact" + ? task.contactId + : recordKind === "company" + ? task.companyId + : recordKind === "deal" + ? task.dealId + : null; + if ( + !isCrmEventType(eventType) || + CRM_EVENT_CATALOG[eventType].recordKind !== recordKind || + !recordId || + taskRecordId !== recordId || + !occurredAt || + Number.isNaN(occurredAtDate.getTime()) + ) { + throw new Error("The queued agent event is invalid."); + } + + const triggers = await db.agentTrigger.findMany({ + where: { + enabled: true, + type: "EVENT", + agent: { status: "LIVE" }, + }, + orderBy: { id: "asc" }, + select: { + id: true, + agentId: true, + versionId: true, + config: true, + }, + }); + + let matched = 0; + for (const trigger of triggers) { + if (recordOf(trigger.config).event !== eventType) continue; + const idempotencyKey = `event:${task.id}:trigger:${trigger.id}`; + + const queued = await db.$transaction(async (tx) => { + await lockIdempotencyKey(tx, idempotencyKey); + const eligible = await tx.agentTrigger.findFirst({ + where: { + id: trigger.id, + enabled: true, + type: "EVENT", + agent: { status: "LIVE" }, + }, + select: { id: true }, + }); + if (!eligible) return false; + + await tx.agentRun.upsert({ + where: { idempotencyKey }, + create: { + agentId: trigger.agentId, + versionId: trigger.versionId, + triggerId: trigger.id, + triggerType: "EVENT", + idempotencyKey, + correlationId: `trigger:${trigger.id}:event:${task.id}`, + input: { + event: { + type: eventType, + occurredAt, + data: recordOf(payload.data), + }, + record: { kind: recordKind, id: recordId }, + } as Prisma.InputJsonValue, + events: { + create: { + sequence: 0, + type: "run.queued", + data: { eventType, taskId: task.id }, + }, + }, + }, + update: {}, + }); + await tx.agentTrigger.updateMany({ + where: { id: trigger.id, enabled: true }, + data: { lastRunAt: occurredAtDate }, + }); + return true; + }); + if (queued) matched += 1; + } + + return matched; +} + export async function pendingAgentRunIds(): Promise { await recoverAgentRuns(); const rows = await db.agentRun.findMany({ - where: { status: "QUEUED", agent: { status: "LIVE" } }, + where: { + status: "QUEUED", + agent: { + status: "LIVE", + runs: { + none: { status: { in: ["RUNNING", "WAITING_FOR_APPROVAL"] } }, + }, + }, + }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], - take: RUN_BATCH, - select: { id: true }, + take: RUN_BATCH * 4, + select: { id: true, agentId: true, versionId: true }, }); - return rows.map((row) => row.id); + + const runnable: string[] = []; + const selectedAgents = new Set(); + for (const row of rows) { + if (selectedAgents.has(row.agentId)) continue; + const blocked = await runDependencyFailure(row.versionId); + if (blocked) { + await failRun(row.id, DEPENDENCY_UNAVAILABLE, blocked).catch(() => {}); + continue; + } + selectedAgents.add(row.agentId); + runnable.push(row.id); + if (runnable.length === RUN_BATCH) break; + } + + return runnable; } export async function drainAgentRuns(send: SendFn): Promise { await queueDueAgentRuns(); - const ids = await pendingAgentRunIds(); - await Promise.all(ids.map((id) => dispatchAgentRun(id, send))); - return ids.length; + + let dispatched = 0; + for (let pass = 0; pass < DISPATCH.run.maxPasses; pass += 1) { + const ids = await pendingAgentRunIds(); + if (ids.length === 0) break; + + const outcomes = await Promise.all( + ids.map((id) => + dispatchAgentRun(id, send).then( + () => true, + (error) => { + console.error( + `[agent] run ${id} could not be dispatched: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return false; + }, + ), + ), + ); + dispatched += outcomes.filter(Boolean).length; + } + + return dispatched; } export async function dispatchAgentRun(runId: string, send: SendFn) { @@ -290,14 +451,24 @@ export async function dispatchAgentRun(runId: string, send: SendFn) { throw new Error("Agent run was already claimed or is not live."); } - const claimed = await db.$transaction(async (tx) => { + const claim = await db.$transaction(async (tx) => { const [agent] = await tx.$queryRaw>` SELECT id, status FROM "agentDefinition" WHERE id = ${run.agentId} FOR UPDATE `; - if (agent?.status !== "LIVE") return false; + if (agent?.status !== "LIVE") return "unavailable" as const; + + const active = await tx.agentRun.findFirst({ + where: { + agentId: run.agentId, + id: { not: run.id }, + status: { in: ["RUNNING", "WAITING_FOR_APPROVAL"] }, + }, + select: { id: true }, + }); + if (active) return "deferred" as const; const updated = await tx.agentRun.updateMany({ where: { id: runId, status: "QUEUED" }, @@ -307,9 +478,16 @@ export async function dispatchAgentRun(runId: string, send: SendFn) { modelId: run.version.modelId, }, }); - return updated.count === 1; + return updated.count === 1 + ? ("claimed" as const) + : ("unavailable" as const); }); - if (!claimed) + if (claim === "deferred") { + throw new Error( + "This agent already has an active run; this run remains queued.", + ); + } + if (claim !== "claimed") throw new Error("Agent run was already claimed or is not live."); const principalId = run.initiatedById ?? run.agent.createdById; @@ -400,6 +578,68 @@ export async function failRun(runId: string, code: string, message: string) { }); } +export async function cancelRun(runId: string, code: string, message: string) { + return db.$transaction(async (tx) => { + const run = await lockAgentRun(tx, runId); + if (isTerminalRunStatus(run.status)) { + return { id: run.id, status: run.status, settled: false }; + } + + const sequence = run.nextEventSequence + 1; + const finishedAt = new Date(); + await tx.agentRun.update({ + where: { id: runId }, + data: { + status: "CANCELLED", + errorCode: code, + errorMessage: message, + finishedAt, + nextEventSequence: sequence, + }, + }); + await tx.agentAction.updateMany({ + where: { runId: run.id, status: { in: ["PLANNED", "RUNNING"] } }, + data: { + status: "CANCELLED", + errorCode: code, + errorMessage: message, + completedAt: finishedAt, + }, + }); + await tx.agentRunEvent.create({ + data: { + id: runTerminalEventId(run.id, "cancelled"), + runId: run.id, + sequence, + type: "run.cancelled", + data: { code, message }, + emittedAt: finishedAt, + }, + }); + await tx.agentAuditEvent.upsert({ + where: { + agentId_type_requestId: { + agentId: run.agentId, + type: "run.cancelled", + requestId: run.id, + }, + }, + create: { + agentId: run.agentId, + versionId: run.versionId, + actorType: "AGENT", + actorId: run.id, + type: "run.cancelled", + summary: message, + requestId: run.id, + }, + update: {}, + }); + + return { id: run.id, status: "CANCELLED" as const, settled: true }; + }); +} + export function builderToken(conversationId: string): string { return `builder:${conversationId}`; } @@ -457,14 +697,14 @@ async function recoverBuilderSubmissions() { } } -type LockedBuilderConversation = { +export type LockedBuilderConversation = { id: string; kind: string; sessionId: string | null; continuationToken: string | null; }; -async function lockBuilderConversation( +export async function lockBuilderConversation( tx: Prisma.TransactionClient, conversationId: string, ): Promise { @@ -477,7 +717,34 @@ async function lockBuilderConversation( return conversation ?? null; } +export const RUN_TIMED_OUT = "RUN_TIMED_OUT"; + +async function timeOutOverrunningRuns() { + const overrun = new Date(Date.now() - DISPATCH.run.executionTimeoutMs); + const rows = await db.agentRun.findMany({ + where: { + status: "RUNNING", + sessionId: { not: null }, + startedAt: { lt: overrun }, + }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + take: RUN_BATCH * 3, + select: { id: true }, + }); + + const minutes = Math.round(DISPATCH.run.executionTimeoutMs / 60_000); + for (const row of rows) { + await failRun( + row.id, + RUN_TIMED_OUT, + `This run passed ${minutes} minutes without finishing and was stopped.`, + ).catch(() => {}); + } +} + async function recoverAgentRuns() { + await timeOutOverrunningRuns(); + const stale = new Date(Date.now() - RUN_DELIVERY_LEASE_MS); const rows = await db.agentRun.findMany({ where: { @@ -550,12 +817,19 @@ export function builderDeliveryMessage( ): Parameters[0] { const message = recordOf(value); const inputResponse = recordOf(message.inputResponse); - const response = - typeof inputResponse.requestId === "string" && - typeof inputResponse.answer === "string" - ? inputResponse.answer.trim() - : ""; - if (response) return response; + const requestId = textOf(inputResponse.requestId); + const optionId = textOf(inputResponse.optionId); + const responseText = textOf(inputResponse.text); + if (requestId && (optionId || responseText)) { + return { + inputResponses: [ + { + requestId, + ...(optionId ? { optionId } : { text: responseText }), + }, + ], + }; + } const text = typeof message.text === "string" ? message.text : ""; const resources = Array.isArray(message.resources) ? message.resources : []; @@ -588,8 +862,8 @@ export function builderCommandType( value: unknown, ): string { const inputResponse = recordOf(recordOf(value).inputResponse); - return typeof inputResponse.requestId === "string" && - typeof inputResponse.answer === "string" + return textOf(inputResponse.requestId) && + (textOf(inputResponse.optionId) || textOf(inputResponse.text)) ? "CREATE_AGENT" : commandType; } @@ -605,6 +879,10 @@ function resourceLabel(value: unknown): string | null { return typeof row.label === "string" ? row.label : null; } +function textOf(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + function intervalOf(value: unknown): number { const interval = recordOf(value).intervalMinutes; return typeof interval === "number" && diff --git a/apps/agent/agent/lib/deadline.ts b/apps/agent/agent/lib/deadline.ts new file mode 100644 index 000000000..a57b2f8cb --- /dev/null +++ b/apps/agent/agent/lib/deadline.ts @@ -0,0 +1,18 @@ +export async function settledWithin( + work: Promise, + timeoutMs: number, +): Promise<{ settled: true; value: T } | { settled: false }> { + let timer: ReturnType | undefined; + const late = new Promise<{ settled: false }>((resolve) => { + timer = setTimeout(() => resolve({ settled: false }), timeoutMs); + }); + + try { + return await Promise.race([ + work.then((value) => ({ settled: true as const, value })), + late, + ]); + } finally { + clearTimeout(timer); + } +} diff --git a/apps/agent/agent/lib/dispatch-config.ts b/apps/agent/agent/lib/dispatch-config.ts new file mode 100644 index 000000000..916fe4656 --- /dev/null +++ b/apps/agent/agent/lib/dispatch-config.ts @@ -0,0 +1,43 @@ +const MINUTE_MS = 60_000; + +export const DISPATCH = { + visible: { + batch: 60, + concurrency: 6, + leaseMs: 6 * MINUTE_MS, + }, + + research: { + batch: 12, + leaseMs: 30 * MINUTE_MS, + link: { attempts: 3, retryMs: 250 }, + }, + + builder: { + batch: 20, + maxAttempts: 3, + leaseMs: 5 * MINUTE_MS, + }, + + run: { + batch: 20, + maxPasses: 5, + deliveryLeaseMs: 5 * MINUTE_MS, + actionLeaseMs: 5 * MINUTE_MS, + executionTimeoutMs: 20 * MINUTE_MS, + noActionTriggerTypes: ["EVENT", "SCHEDULE", "WEBHOOK"], + }, + + task: { + leaseMs: 10 * MINUTE_MS, + }, + + sweep: { + timeoutMs: 4 * MINUTE_MS, + staleQueueMs: 5 * MINUTE_MS, + startTimeoutMs: MINUTE_MS, + itemTimeoutMs: 2 * MINUTE_MS, + maxAbandoned: 1, + abandonGraceMs: 15 * MINUTE_MS, + }, +} as const; diff --git a/apps/agent/agent/lib/dispatch.ts b/apps/agent/agent/lib/dispatch.ts index ee0f6bda7..5f0b9a048 100644 --- a/apps/agent/agent/lib/dispatch.ts +++ b/apps/agent/agent/lib/dispatch.ts @@ -1,9 +1,14 @@ import { EnrichmentStatus } from "@crm/db"; import { APP_AUTH, type AppAuth } from "./app-auth"; import { brandOutcome, runBrand } from "./brand"; +import { queueEventAgentRuns } from "./custom-agent-dispatch"; +import { settledWithin } from "./deadline"; +import { DISPATCH } from "./dispatch-config"; import { markRunning, settle } from "./enrichment"; import { collapsing, runLimited } from "./pool"; import { runPortrait } from "./portrait"; +import { runSlackChannelJoin } from "./slack-join-task"; +import { runSlackPeopleMatch } from "./slack-people"; import { claimDue, completeTask, @@ -14,12 +19,12 @@ import { type TaskSubject, } from "./tasks"; -export const VISIBLE_BATCH = 60; -export const VISIBLE_CONCURRENCY = 6; -export const VISIBLE_LEASE_MS = 2 * 60_000; +export const VISIBLE_BATCH = DISPATCH.visible.batch; +export const VISIBLE_CONCURRENCY = DISPATCH.visible.concurrency; +export const VISIBLE_LEASE_MS = DISPATCH.visible.leaseMs; -export const RESEARCH_BATCH = 12; -export const RESEARCH_LEASE_MS = 30 * 60_000; +export const RESEARCH_BATCH = DISPATCH.research.batch; +export const RESEARCH_LEASE_MS = DISPATCH.research.leaseMs; export async function retireAbandoned(): Promise { let abandoned: TaskSubject[] = []; @@ -39,10 +44,12 @@ export async function retireAbandoned(): Promise { } } -export async function runVisibleLane(): Promise { +export async function runVisibleLane(signal?: AbortSignal): Promise { let handled = 0; while (handled < VISIBLE_BATCH) { + if (signal?.aborted) break; + const tasks = await claimDue( Math.min(VISIBLE_CONCURRENCY, VISIBLE_BATCH - handled), { only: DIRECT_KINDS }, @@ -51,48 +58,103 @@ export async function runVisibleLane(): Promise { if (tasks.length === 0) break; - await runLimited(VISIBLE_CONCURRENCY, tasks, runDirect); + await runLimited(VISIBLE_CONCURRENCY, tasks, runDirect, signal); handled += tasks.length; } return handled; } -async function runDirect(task: LeasedTask): Promise { - try { - if (task.kind === "brand" && task.companyId) { - const result = await runBrand({ companyId: task.companyId }); - if (result.retryable) return; +type DirectOutcome = { finished: true } | { finished: false; reason: string }; - await completeTask(task.id, brandOutcome(result)); - return; - } +export async function runDirect( + task: LeasedTask, + handle: (task: LeasedTask) => Promise = handleDirect, + timeoutMs: number = DISPATCH.sweep.itemTimeoutMs, +): Promise { + const work: Promise = handle(task).then( + () => ({ finished: true }) as const, + (error) => ({ finished: false, reason: reasonOf(error) }) as const, + ); - if (task.kind === "portrait" && task.contactId) { - const portrait = await runPortrait({ - contactId: task.contactId, - spend: () => ({ ok: true }), - }); - - await completeTask( - task.id, - portrait.stored - ? `Picture stored from ${portrait.source}.` - : (portrait.reason ?? "No picture found."), - ); - return; - } + const outcome = await settledWithin(work, timeoutMs); - await completeTask(task.id, "The record this names is gone."); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - await settle(task, EnrichmentStatus.FAILED, reason).catch(() => {}); + if (outcome.settled) { + await reconcileDirect(task, outcome.value); + return; } + + pendingItems += 1; + void work + .then((late) => reconcileDirect(task, late)) + .finally(() => { + pendingItems -= 1; + }); +} + +async function reconcileDirect( + task: LeasedTask, + outcome: DirectOutcome, +): Promise { + if (outcome.finished) return; + + await settle(task, EnrichmentStatus.FAILED, outcome.reason).catch(() => {}); +} + +async function handleDirect(task: LeasedTask): Promise { + if (task.kind === "brand" && task.companyId) { + const result = await runBrand({ companyId: task.companyId }); + if (result.retryable) return; + + await completeTask(task.id, brandOutcome(result)); + return; + } + + if (task.kind === "portrait" && task.contactId) { + const portrait = await runPortrait({ + contactId: task.contactId, + spend: () => ({ ok: true }), + }); + + await completeTask( + task.id, + portrait.stored + ? `Picture stored from ${portrait.source}.` + : (portrait.reason ?? "No picture found."), + ); + return; + } + + if (task.kind === "slack-people-match") { + await completeTask(task.id, await runSlackPeopleMatch()); + return; + } + + if (task.kind === "slack-channel-join") { + await completeTask(task.id, await runSlackChannelJoin(task.payload)); + return; + } + + if (task.kind === "agent-event") { + const queued = await queueEventAgentRuns(task); + await completeTask( + task.id, + queued === 1 + ? "Queued 1 matching agent run." + : `Queued ${queued} matching agent runs.`, + ); + return; + } + + await completeTask(task.id, "The record this names is gone."); } export async function runResearchLane( start: (task: LeasedTask) => Promise<{ id: string }>, + signal?: AbortSignal, ): Promise { + if (signal?.aborted) return 0; + const tasks = await claimDue( RESEARCH_BATCH, { except: DIRECT_KINDS }, @@ -100,20 +162,98 @@ export async function runResearchLane( ); if (tasks.length === 0) return 0; + let started = 0; + await Promise.all( tasks.map(async (task) => { - try { - await markRunning(task); - const session = await start(task); - await noteSession(task.id, session.id); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - await settle(task, EnrichmentStatus.FAILED, reason).catch(() => {}); - } + if (signal?.aborted) return; + started += 1; + await beginResearch(task, start); }), ); - return tasks.length; + return started; +} + +type StartOutcome = + | { accepted: true; sessionId: string } + | { accepted: false; reason: string }; + +async function beginResearch( + task: LeasedTask, + start: (task: LeasedTask) => Promise<{ id: string }>, +): Promise { + try { + await markRunning(task); + } catch (error) { + await settle(task, EnrichmentStatus.FAILED, reasonOf(error)).catch( + () => {}, + ); + return; + } + + const send: Promise = start(task).then( + (session) => ({ accepted: true, sessionId: session.id }) as const, + (error) => ({ accepted: false, reason: reasonOf(error) }) as const, + ); + + const outcome = await settledWithin(send, DISPATCH.sweep.startTimeoutMs); + + if (outcome.settled) { + await reconcileStart(task, outcome.value); + return; + } + + pendingStarts += 1; + void send + .then((late) => reconcileStart(task, late)) + .finally(() => { + pendingStarts -= 1; + }); +} + +async function reconcileStart( + task: LeasedTask, + outcome: StartOutcome, +): Promise { + if (outcome.accepted) { + await linkSession(task, outcome.sessionId); + return; + } + + await settle(task, EnrichmentStatus.FAILED, outcome.reason).catch(() => {}); +} + +export async function linkSession( + task: LeasedTask, + sessionId: string, + note: (taskId: string, sessionId: string) => Promise = noteSession, + link: { attempts: number; retryMs: number } = DISPATCH.research.link, +): Promise { + for (let attempt = 1; attempt <= link.attempts; attempt += 1) { + try { + await note(task.id, sessionId); + return true; + } catch (error) { + if (attempt < link.attempts) { + await new Promise((resolve) => + setTimeout(resolve, link.retryMs * attempt), + ); + continue; + } + + unlinkedSessions += 1; + console.error( + `[agent] Task ${task.id} accepted session ${sessionId}, but the session id was not recorded: ${reasonOf(error)}`, + ); + } + } + + return false; +} + +function reasonOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); } export function taskAuth(task: LeasedTask, base: AppAuth = APP_AUTH): AppAuth { @@ -125,14 +265,136 @@ export function taskAuth(task: LeasedTask, base: AppAuth = APP_AUTH): AppAuth { budget: String(task.budget), ...(task.contactId ? { contactId: task.contactId } : {}), ...(task.companyId ? { companyId: task.companyId } : {}), + ...(task.dealId ? { dealId: task.dealId } : {}), }, }; } +export const DRAIN_TIMEOUT_MS = DISPATCH.sweep.timeoutMs; + +let lastSweepStartedAt: Date | null = null; +let lastSweepFinishedAt: Date | null = null; +let lastSweepError: string | null = null; +let abandonedSweeps = 0; +let pendingStarts = 0; +let pendingItems = 0; +let unlinkedSessions = 0; + +const unsettledSweeps = new Set<{ startedAt: Date }>(); + +function oldestUnsettledAt(): Date | null { + let oldest: Date | null = null; + + for (const sweep of unsettledSweeps) { + if (!oldest || sweep.startedAt.getTime() < oldest.getTime()) { + oldest = sweep.startedAt; + } + } + + return oldest; +} + +export function dispatchHealth() { + const startedAt = lastSweepStartedAt; + const finishedAt = lastSweepFinishedAt; + const collapsed = Boolean( + startedAt && (!finishedAt || finishedAt.getTime() < startedAt.getTime()), + ); + const unsettledAt = oldestUnsettledAt(); + const running = collapsed || unsettledAt !== null; + + const since = collapsed && startedAt ? startedAt : unsettledAt; + const oldest = + since && unsettledAt && unsettledAt.getTime() < since.getTime() + ? unsettledAt + : since; + + return { + startedAt: startedAt?.toISOString() ?? null, + finishedAt: finishedAt?.toISOString() ?? null, + running, + stalledMs: oldest ? Math.max(0, Date.now() - oldest.getTime()) : 0, + abandonedSweeps, + unsettledSweeps: unsettledSweeps.size, + pendingStarts, + pendingItems, + unlinkedSessions, + lastError: lastSweepError, + }; +} + export const drainAll = collapsing( async (start: (task: LeasedTask) => Promise<{ id: string }>) => { - await retireAbandoned(); - await Promise.all([runVisibleLane(), runResearchLane(start)]); + if (unsettledSweeps.size >= DISPATCH.sweep.maxAbandoned) { + lastSweepError = + "An abandoned dispatch sweep is still in flight, so this sweep did not start."; + console.error(`[agent] ${lastSweepError}`); + return; + } + + const startedAt = new Date(); + lastSweepStartedAt = startedAt; + lastSweepError = null; + + const controller = new AbortController(); + const signal = controller.signal; + + const sweep = (async () => { + await retireAbandoned(); + await Promise.all([ + runVisibleLane(signal), + runResearchLane(start, signal), + ]); + })(); + + let timer: ReturnType | undefined; + const abandon = new Promise((_, reject) => { + timer = setTimeout(() => { + abandonedSweeps += 1; + + const unsettled = { startedAt }; + unsettledSweeps.add(unsettled); + + const forget = setTimeout(() => { + if (!unsettledSweeps.delete(unsettled)) return; + console.error( + `[agent] An abandoned dispatch sweep never settled within ${DISPATCH.sweep.abandonGraceMs}ms, so dispatch is starting again without it.`, + ); + }, DISPATCH.sweep.abandonGraceMs); + forget.unref?.(); + + void sweep + .catch((error) => { + console.error( + `[agent] An abandoned dispatch sweep then failed: ${reasonOf(error)}`, + ); + }) + .finally(() => { + clearTimeout(forget); + unsettledSweeps.delete(unsettled); + }); + + controller.abort(); + reject( + new Error( + `Dispatch sweep exceeded ${DRAIN_TIMEOUT_MS}ms and was abandoned so the next one can start.`, + ), + ); + }, DRAIN_TIMEOUT_MS); + }); + + sweep.catch(() => {}); + + try { + await Promise.race([sweep, abandon]); + } catch (error) { + lastSweepError = reasonOf(error); + console.error(`[agent] ${lastSweepError}`); + throw error; + } finally { + clearTimeout(timer); + lastSweepFinishedAt = new Date(); + } }, ); diff --git a/apps/agent/agent/lib/enrichment.ts b/apps/agent/agent/lib/enrichment.ts index a68b24dcd..601a9e5f1 100644 --- a/apps/agent/agent/lib/enrichment.ts +++ b/apps/agent/agent/lib/enrichment.ts @@ -1,6 +1,14 @@ -import { db, EnrichmentStatus } from "@crm/db"; +import { db, EnrichmentStatus, type Prisma } from "@crm/db"; import type { TaskSubject } from "./tasks"; +type SettleGuard = { + enrichmentStatus?: EnrichmentStatus; + OR?: Array<{ + enrichmentStatus: EnrichmentStatus; + updatedAt?: { lt: Date }; + }>; +}; + export async function markRunning(subject: TaskSubject): Promise { await write(subject, EnrichmentStatus.RUNNING, null, false); } @@ -19,14 +27,16 @@ async function write( error: string | null, onlyIfRunning: boolean, ): Promise { + if (!subject.contactId && !subject.companyId) return; + const data = { enrichmentStatus: status, enrichmentError: error, ...(status === EnrichmentStatus.COMPLETE ? { enrichedAt: new Date() } : {}), }; - const guard = onlyIfRunning - ? { enrichmentStatus: EnrichmentStatus.RUNNING } + const guard: SettleGuard = onlyIfRunning + ? await settleable(subject, status) : {}; if (subject.contactId) { @@ -43,3 +53,48 @@ async function write( }); } } + +async function settleable( + subject: TaskSubject, + status: EnrichmentStatus, +): Promise { + const running = { enrichmentStatus: EnrichmentStatus.RUNNING }; + if (status !== EnrichmentStatus.FAILED) return running; + + const endedAt = await taskEndedAt(subject.id); + if (!endedAt) return running; + if (await hasOpenRequest(subject)) return running; + + return { + OR: [ + running, + { + enrichmentStatus: EnrichmentStatus.PENDING, + updatedAt: { lt: endedAt }, + }, + ], + }; +} + +async function taskEndedAt(taskId: string): Promise { + const task = await db.agentTask.findUnique({ + where: { id: taskId }, + select: { finishedAt: true }, + }); + + return task?.finishedAt ?? null; +} + +async function hasOpenRequest(subject: TaskSubject): Promise { + const owners: Prisma.AgentTaskWhereInput[] = []; + if (subject.contactId) owners.push({ contactId: subject.contactId }); + if (subject.companyId) owners.push({ companyId: subject.companyId }); + if (owners.length === 0) return false; + + const open = await db.agentTask.findFirst({ + where: { id: { not: subject.id }, finishedAt: null, OR: owners }, + select: { id: true }, + }); + + return open !== null; +} diff --git a/apps/agent/agent/lib/event-persistence.ts b/apps/agent/agent/lib/event-persistence.ts new file mode 100644 index 000000000..57b0f5aab --- /dev/null +++ b/apps/agent/agent/lib/event-persistence.ts @@ -0,0 +1,5 @@ +const TRANSPORT_ONLY_SUFFIX = ".appended"; + +export function isTransportOnlyEvent(type: string): boolean { + return type.endsWith(TRANSPORT_ONLY_SUFFIX); +} diff --git a/apps/agent/agent/lib/pool.ts b/apps/agent/agent/lib/pool.ts index 93a1ef672..8840fae96 100644 --- a/apps/agent/agent/lib/pool.ts +++ b/apps/agent/agent/lib/pool.ts @@ -40,12 +40,16 @@ export async function runLimited( concurrency: number, items: readonly T[], run: (item: T) => Promise, + signal?: AbortSignal, ): Promise { const width = Math.max(1, Math.min(concurrency, items.length)); const queue = items[Symbol.iterator](); const workers = Array.from({ length: width }, async () => { - for (const item of queue) await run(item); + for (const item of queue) { + if (signal?.aborted) break; + await run(item); + } }); await Promise.all(workers); diff --git a/apps/agent/agent/lib/run-preflight.ts b/apps/agent/agent/lib/run-preflight.ts new file mode 100644 index 000000000..c1d71468b --- /dev/null +++ b/apps/agent/agent/lib/run-preflight.ts @@ -0,0 +1,55 @@ +import { db } from "@crm/db"; +import { actionDependency } from "./agent-actions"; +import { + type AgentManifest, + InvalidAgentManifest, + parseAgentManifest, +} from "./agent-manifest"; +import { slackConnected } from "./slack-connection"; + +export const DEPENDENCY_UNAVAILABLE = "DEPENDENCY_UNAVAILABLE"; + +const CHECKS: Record Promise> = { + slack: slackConnected, +}; + +export async function missingRunDependencies( + manifest: AgentManifest, +): Promise { + const required = new Map(); + + for (const action of manifest.actions) { + const dependency = actionDependency(action.type); + if (dependency) required.set(dependency.id, dependency.fix); + } + + const missing: string[] = []; + for (const [id, fix] of required) { + const check = CHECKS[id]; + if (check && !(await check())) missing.push(fix); + } + + return missing; +} + +export async function runDependencyFailure( + versionId: string, +): Promise { + const version = await db.agentVersion.findUnique({ + where: { id: versionId }, + select: { manifest: true }, + }); + if (!version) return null; + + let manifest: AgentManifest; + try { + manifest = parseAgentManifest(version.manifest); + } catch (error) { + return error instanceof InvalidAgentManifest ? error.message : null; + } + + const missing = await missingRunDependencies(manifest); + if (missing.length === 0) return null; + + return `This agent cannot run yet. ${missing.join(" ")}`; +} diff --git a/apps/agent/agent/lib/run-runtime.ts b/apps/agent/agent/lib/run-runtime.ts index f3a2e310d..75262e010 100644 --- a/apps/agent/agent/lib/run-runtime.ts +++ b/apps/agent/agent/lib/run-runtime.ts @@ -1,12 +1,28 @@ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { ActivityType, db, type Prisma } from "@crm/db"; +import type { AgentActionStatus, AgentTriggerType } from "@crm/db/enums"; import { lockIdempotencyKey } from "@crm/db/idempotency"; import { readCompanyHistory, readDealHistory } from "./accounts"; +import { + AGENT_ACTION_EXECUTORS, + AGENT_ACTION_TYPES, + isAgentActionType, +} from "./agent-actions"; +import { parseAgentManifest } from "./agent-manifest"; import { readCrmHistory } from "./crm"; +import { DISPATCH } from "./dispatch-config"; import { searchCrm } from "./lookup"; -import { lockAgentRun, runTerminalEventId } from "./run-state"; +import { + type LockedAgentRun, + lockAgentRun, + runTerminalEventId, +} from "./run-state"; +import { slackAccessToken } from "./slack-connection"; -const ACTION_LEASE_MS = 5 * 60_000; +const ACTION_LEASE_MS = DISPATCH.run.actionLeaseMs; +const NO_ACTION_TRIGGER_TYPES = new Set( + DISPATCH.run.noActionTriggerTypes, +); type RunResource = { kind: "integration" | "company" | "contact" | "deal"; @@ -16,6 +32,31 @@ type RunResource = { type RunRecordScope = "SELECTED" | "WORKSPACE"; +type RunActionRow = { + id: string; + status: AgentActionStatus; + externalId: string | null; + requestHash: string | null; + metadata: Prisma.JsonValue; +}; + +type RunActionClaim = + | { claimed: false; actionId: string; externalId: string | null } + | { + claimed: true; + actionId: string; + claimedAt: Date; + metadata: Prisma.JsonValue; + }; + +const RUN_ACTION_FIELDS = { + id: true, + status: true, + externalId: true, + requestHash: true, + metadata: true, +} as const; + export async function approvedRunInstructions(runId: string): Promise { const run = await db.agentRun.findUnique({ where: { id: runId }, @@ -168,17 +209,7 @@ export async function createRunActivity( }); const idempotencyKey = `${runId}:${callId}`; const requestHash = actionRequestHash(input); - const existing = await db.agentAction.findUnique({ - where: { idempotencyKey }, - select: { - id: true, - status: true, - externalId: true, - errorMessage: true, - requestHash: true, - }, - }); - if (existing) assertActionRequestMatches(existing.requestHash, requestHash); + const existing = await findRunAction(idempotencyKey, requestHash); if (existing?.status === "SUCCEEDED") { return { actionId: existing.id, @@ -202,99 +233,36 @@ export async function createRunActivity( const target = await targetRecord(input.targetKind, input.targetId); if (!target) throw new Error("The requested CRM target no longer exists."); - let action = existing; - if (!action) { - action = await db.$transaction(async (tx) => { - await lockIdempotencyKey(tx, idempotencyKey); - const winner = await tx.agentAction.findUnique({ - where: { idempotencyKey }, - select: { - id: true, - status: true, - externalId: true, - errorMessage: true, - requestHash: true, - }, - }); - if (winner) { - assertActionRequestMatches(winner.requestHash, requestHash); - return winner; - } - - return tx.agentAction.create({ - data: { - agentId: run.agentId, - runId, - type: "crm.activity.create", - provider: "crm", - targetType: input.targetKind, - targetId: input.targetId, - targetLabel: target.label, - summary: - input.subject?.trim() || - `Create a ${input.type.toLowerCase()} on ${target.label}`, - metadata: { activityType: input.type }, - idempotencyKey, - requestHash, - }, - select: { - id: true, - status: true, - externalId: true, - errorMessage: true, - requestHash: true, - }, - }); - }); - } - if (action.status === "SUCCEEDED") { + const claim = await claimRunAction(existing, idempotencyKey, requestHash, { + agentId: run.agentId, + runId, + type: "crm.activity.create", + provider: "crm", + targetType: input.targetKind, + targetId: input.targetId, + targetLabel: target.label, + summary: + input.subject?.trim() || + `Create a ${input.type.toLowerCase()} on ${target.label}`, + metadata: { activityType: input.type }, + }); + if (!claim.claimed) { return { - actionId: action.id, - activityId: action.externalId, + actionId: claim.actionId, + activityId: claim.externalId, replayed: true, }; } - const claimed = await db.agentAction.updateMany({ - where: { - id: action.id, - OR: [ - { status: { in: ["PLANNED", "FAILED"] } }, - { - status: "RUNNING", - startedAt: { lt: new Date(Date.now() - ACTION_LEASE_MS) }, - }, - ], - }, - data: { - status: "RUNNING", - startedAt: new Date(), - completedAt: null, - attemptCount: { increment: 1 }, - errorCode: null, - errorMessage: null, - }, - }); - if (claimed.count === 0) { - const current = await db.agentAction.findUnique({ - where: { id: action.id }, - select: { status: true, externalId: true }, - }); - if (current?.status === "SUCCEEDED") { - return { - actionId: action.id, - activityId: current.externalId, - replayed: true, - }; - } - throw new Error("This agent action is already in progress."); - } - try { - const activityId = `agent-action-${action.id}`; + const activityId = `agent-action-${claim.actionId}`; const now = new Date(); await db.$transaction(async (tx) => { + const activeRun = await lockAgentRun(tx, runId); + if (activeRun.status !== "RUNNING") { + throw new Error("This agent run is not active."); + } await tx.activity.upsert({ where: { id: activityId }, create: { @@ -312,7 +280,7 @@ export async function createRunActivity( source: "agent", agentId: run.agentId, runId, - actionId: action.id, + actionId: claim.actionId, }, }, update: {}, @@ -338,7 +306,7 @@ export async function createRunActivity( } await tx.agentAction.update({ - where: { id: action.id }, + where: { id: claim.actionId }, data: { status: "SUCCEEDED", externalId: activityId, @@ -347,38 +315,401 @@ export async function createRunActivity( }); }); - return { actionId: action.id, activityId, replayed: false }; + return { actionId: claim.actionId, activityId, replayed: false }; } catch (error) { const message = error instanceof Error ? error.message : String(error); - await db.agentAction.updateMany({ - where: { id: action.id, status: "RUNNING" }, + await failRunAction(claim, "ACTION_REJECTED", message); + throw error; + } +} + +export async function postRunSlackMessage( + runId: string, + callId: string, + input: { text: string }, + abortSignal?: AbortSignal, +) { + const run = await db.agentRun.findUnique({ + where: { id: runId }, + select: { + id: true, + status: true, + agentId: true, + version: { select: { manifest: true } }, + }, + }); + if (!run) throw new Error("This agent run is unavailable."); + + const destination = approvedSlackDestination(run.version.manifest); + const text = input.text.trim(); + if (!text) throw new Error("A Slack message needs text."); + const idempotencyKey = `${runId}:${callId}`; + const requestHash = hashRequest({ destinationId: destination.id, text }); + const existing = await findRunAction(idempotencyKey, requestHash); + if (existing?.status === "SUCCEEDED") { + return { + actionId: existing.id, + messageId: existing.externalId, + destination: destination.label, + replayed: true, + }; + } + if (run.status !== "RUNNING") { + throw new Error("This agent run is not active."); + } + + const claim = await claimRunAction(existing, idempotencyKey, requestHash, { + agentId: run.agentId, + runId, + type: "slack.message.post", + provider: "slack", + targetType: destination.kind, + targetId: destination.id, + targetLabel: destination.label, + summary: `Post a message to ${destination.label}`, + metadata: { clientMessageId: randomUUID() }, + }); + if (!claim.claimed) { + return { + actionId: claim.actionId, + messageId: claim.externalId, + destination: destination.label, + replayed: true, + }; + } + + const { actionId, claimedAt } = claim; + try { + await assertRunActive(runId); + const clientMessageId = recordOf(claim.metadata).clientMessageId; + if (typeof clientMessageId !== "string" || !clientMessageId) { + throw new Error("This Slack action is missing its replay key."); + } + const accessToken = await slackAccessToken(); + if (!accessToken) throw new Error("Slack is not connected."); + + const posted = await sendSlackMessage( + accessToken, + destination, + text, + clientMessageId, + { + abortSignal, + beforePost: () => holdRunActionClaim(runId, actionId, claimedAt), + }, + ); + const messageId = `${posted.channel}:${posted.ts}`; + const completed = await db.agentAction.updateMany({ + where: { id: actionId, status: "RUNNING", startedAt: claimedAt }, data: { - status: "FAILED", - errorCode: "ACTION_REJECTED", - errorMessage: message, + status: "SUCCEEDED", + externalId: messageId, completedAt: new Date(), }, }); + if (completed.count === 0) { + await recordDeliveryOutsideClaim(actionId, messageId); + throw new Error( + "This agent run stopped while Slack was accepting the message.", + ); + } + + return { + actionId, + messageId, + destination: destination.label, + replayed: false, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await failRunAction(claim, slackActionErrorCode(message), message); throw error; } } +async function findRunAction( + idempotencyKey: string, + requestHash: string, +): Promise { + const existing = await db.agentAction.findUnique({ + where: { idempotencyKey }, + select: RUN_ACTION_FIELDS, + }); + if (existing) assertActionRequestMatches(existing.requestHash, requestHash); + return existing; +} + +async function claimRunAction( + existing: RunActionRow | null, + idempotencyKey: string, + requestHash: string, + data: Omit< + Prisma.AgentActionUncheckedCreateInput, + "idempotencyKey" | "requestHash" + >, +): Promise { + const action = + existing ?? + (await db.$transaction(async (tx) => { + await lockIdempotencyKey(tx, idempotencyKey); + const winner = await tx.agentAction.findUnique({ + where: { idempotencyKey }, + select: RUN_ACTION_FIELDS, + }); + if (winner) { + assertActionRequestMatches(winner.requestHash, requestHash); + return winner; + } + + return tx.agentAction.create({ + data: { ...data, idempotencyKey, requestHash }, + select: RUN_ACTION_FIELDS, + }); + })); + if (action.status === "SUCCEEDED") { + return { + claimed: false, + actionId: action.id, + externalId: action.externalId, + }; + } + + const claimedAt = new Date(); + const claimed = await db.agentAction.updateMany({ + where: { + id: action.id, + OR: [ + { status: { in: ["PLANNED", "FAILED"] } }, + { + status: "RUNNING", + startedAt: { lt: new Date(claimedAt.getTime() - ACTION_LEASE_MS) }, + }, + ], + }, + data: { + status: "RUNNING", + startedAt: claimedAt, + completedAt: null, + attemptCount: { increment: 1 }, + errorCode: null, + errorMessage: null, + }, + }); + if (claimed.count === 0) { + const current = await db.agentAction.findUnique({ + where: { id: action.id }, + select: { status: true, externalId: true }, + }); + if (current?.status === "SUCCEEDED") { + return { + claimed: false, + actionId: action.id, + externalId: current.externalId, + }; + } + throw new Error("This agent action is already in progress."); + } + + return { + claimed: true, + actionId: action.id, + claimedAt, + metadata: action.metadata, + }; +} + +async function failRunAction( + claim: Extract, + code: string, + message: string, +): Promise { + await db.agentAction.updateMany({ + where: { + id: claim.actionId, + status: "RUNNING", + startedAt: claim.claimedAt, + }, + data: { + status: "FAILED", + errorCode: code, + errorMessage: message, + completedAt: new Date(), + }, + }); +} + +export async function sendSlackMessage( + accessToken: string, + destination: { kind: "channel" | "user"; id: string; label: string }, + text: string, + clientMessageId: string, + options: { + fetcher?: typeof fetch; + abortSignal?: AbortSignal; + beforePost?: () => Promise; + } = {}, +): Promise<{ channel: string; ts: string }> { + const { fetcher = fetch, abortSignal, beforePost } = options; + let channel = destination.id; + if (destination.kind === "user") { + const opened = await slackApiRequest( + fetcher, + accessToken, + "conversations.open", + { users: destination.id, return_im: true }, + abortSignal, + ); + const conversation = recordOf(opened.channel); + if (typeof conversation.id !== "string" || !conversation.id) { + throw new Error("Slack did not return a direct-message channel."); + } + channel = conversation.id; + } + await beforePost?.(); + + const data = await slackApiRequest( + fetcher, + accessToken, + "chat.postMessage", + { + channel, + text, + client_msg_id: clientMessageId, + }, + abortSignal, + ); + if (typeof data.channel !== "string" || typeof data.ts !== "string") { + throw new Error("Slack returned an incomplete message receipt."); + } + + return { channel: data.channel, ts: data.ts }; +} + +async function assertRunActive(runId: string): Promise { + const run = await db.agentRun.findUnique({ + where: { id: runId }, + select: { status: true }, + }); + if (run?.status !== "RUNNING") { + throw new Error("This agent run is not active."); + } +} + +async function holdRunActionClaim( + runId: string, + actionId: string, + claimedAt: Date, +): Promise { + await db.$transaction(async (tx) => { + const run = await lockAgentRun(tx, runId); + if (run.status !== "RUNNING") { + throw new Error("This agent run is not active."); + } + const held = await tx.agentAction.count({ + where: { id: actionId, status: "RUNNING", startedAt: claimedAt }, + }); + if (held === 0) { + throw new Error("This agent action is no longer held by this run."); + } + }); +} + +async function recordDeliveryOutsideClaim( + actionId: string, + messageId: string, +): Promise { + const delivered = + "Slack accepted this message before the run stopped, and it cannot be withdrawn."; + const current = await db.agentAction.findUnique({ + where: { id: actionId }, + select: { status: true, externalId: true, errorMessage: true }, + }); + if (!current || current.status === "SUCCEEDED" || current.externalId) return; + + await db.agentAction.updateMany({ + where: { id: actionId, status: { not: "SUCCEEDED" }, externalId: null }, + data: { + externalId: messageId, + errorMessage: current.errorMessage + ? `${current.errorMessage} ${delivered}` + : delivered, + }, + }); +} + +async function slackApiRequest( + fetcher: typeof fetch, + accessToken: string, + method: string, + body: Record, + abortSignal?: AbortSignal, +): Promise> { + const response = await fetcher(`https://slack.com/api/${method}`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify(body), + signal: abortSignal, + }); + if (!response.ok) throw new Error("Slack message delivery failed."); + + const data = recordOf(await response.json()); + if (data.ok !== true) { + const reason = typeof data.error === "string" ? data.error : "rejected"; + if (reason === "not_in_channel") { + throw new Error( + "The Slack bot is not in the selected channel. Invite the app to that channel and retry the run.", + ); + } + if (reason === "missing_scope") { + throw new Error( + "Slack needs an additional permission. Reconnect Slack, then retry the run.", + ); + } + throw new Error(`Slack rejected the message (${reason}).`); + } + return data; +} + +function slackActionErrorCode(message: string): string { + return message === "Slack is not connected." || + message.includes("additional permission") + ? "NOT_AUTHORISED" + : "PROVIDER_ERROR"; +} + export async function stageRunResult( runId: string, - input: { summary: string; result?: Record | null }, + input: { + summary: string; + result?: Record | null; + noActionNeeded?: { reason: string } | null; + }, ) { return db.$transaction(async (tx) => { const run = await lockAgentRun(tx, runId); if (run.status !== "RUNNING") { throw new Error(`This agent run already ended with ${run.status}.`); } + if (input.noActionNeeded) { + const refusal = await noActionNeededRefusal(tx, run); + if (refusal) throw new Error(refusal); + } + + const result = { + ...(input.result ?? {}), + ...(input.noActionNeeded + ? { noActionNeeded: input.noActionNeeded.reason } + : {}), + }; - await assertRunSummaryAllowed(tx, run.versionId); await tx.agentRun.update({ where: { id: runId }, data: { summary: input.summary, - result: (input.result ?? {}) as Prisma.InputJsonValue, + result: result as Prisma.InputJsonValue, }, }); @@ -386,6 +717,15 @@ export async function stageRunResult( }); } +export function runReportedNoActionNeeded(result: unknown): boolean { + return ( + typeof result === "object" && + result !== null && + !Array.isArray(result) && + typeof (result as Record).noActionNeeded === "string" + ); +} + export async function finishRun( runId: string, input: { summary: string; result?: Record | null }, @@ -398,7 +738,15 @@ export async function finishRun( if (run.status !== "RUNNING") { throw new Error(`This agent run already ended with ${run.status}.`); } - await assertRunSummaryAllowed(tx, run.versionId); + const noActionAccepted = + runReportedNoActionNeeded(input.result) && + (await noActionNeededRefusal(tx, run)) === null; + const actionFailure = noActionAccepted + ? null + : await requiredActionFailure(tx, run); + if (actionFailure) { + return failLockedRun(tx, run, actionFailure.code, actionFailure.message); + } const sequence = run.nextEventSequence + 1; const finishedAt = new Date(); @@ -446,50 +794,146 @@ export async function finishRun( }); } -async function assertRunSummaryAllowed( +async function noActionNeededRefusal( tx: Prisma.TransactionClient, - versionId: string, -): Promise { + run: LockedAgentRun, +): Promise { + const { triggerType } = await tx.agentRun.findUniqueOrThrow({ + where: { id: run.id }, + select: { triggerType: true }, + }); + if (NO_ACTION_TRIGGER_TYPES.has(triggerType)) return null; + const version = await tx.agentVersion.findUniqueOrThrow({ - where: { id: versionId }, + where: { id: run.versionId }, select: { manifest: true }, }); - if ( - !manifestActions(version.manifest).some( - (action) => action.type === "run.summary", - ) - ) { - throw new Error("Agent version does not allow a run summary."); + if (externalManifestActions(version.manifest).length === 0) return null; + + return `This ${triggerType.toLowerCase()} run cannot end with no action needed, because its agent declares an action. Perform the declared action, or report why it failed.`; +} + +async function requiredActionFailure( + tx: Prisma.TransactionClient, + run: LockedAgentRun, +): Promise<{ code: string; message: string } | null> { + const version = await tx.agentVersion.findUniqueOrThrow({ + where: { id: run.versionId }, + select: { manifest: true }, + }); + const external = externalManifestActions(version.manifest); + const recorded = await tx.agentAction.findMany({ + where: { runId: run.id }, + orderBy: [{ completedAt: "desc" }, { plannedAt: "desc" }], + select: { + type: true, + status: true, + errorCode: true, + errorMessage: true, + }, + }); + + for (const action of external) { + const type = typeof action.type === "string" ? action.type : "unknown"; + const rows = recorded.filter((row) => row.type === type); + if (rows.some((row) => row.status === "SUCCEEDED")) continue; + + const executable = + isAgentActionType(type) && Object.hasOwn(AGENT_ACTION_EXECUTORS, type); + const latestFailure = rows.find((row) => row.status === "FAILED"); + const code = executable + ? (latestFailure?.errorCode ?? "ACTION_NOT_PERFORMED") + : "NO_EXECUTOR"; + const message = executable + ? (latestFailure?.errorMessage ?? + `The declared ${type} action was not performed.`) + : `The declared ${type} action has no executor.`; + + if (rows.length === 0) { + await tx.agentAction.create({ + data: { + agentId: run.agentId, + runId: run.id, + type, + provider: + type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST ? "slack" : "crm", + summary: + typeof action.summary === "string" + ? action.summary + : `Perform ${type}`, + status: "FAILED", + idempotencyKey: `run:${run.id}:required:${type}`, + requestHash: hashRequest({ type, required: true }), + errorCode: code, + errorMessage: message, + completedAt: new Date(), + }, + }); + } + + return { code, message }; } + + return null; +} + +async function failLockedRun( + tx: Prisma.TransactionClient, + run: LockedAgentRun, + code: string, + message: string, +) { + const sequence = run.nextEventSequence + 1; + const finishedAt = new Date(); + await tx.agentRun.update({ + where: { id: run.id }, + data: { + status: "FAILED", + errorCode: code, + errorMessage: message, + finishedAt, + nextEventSequence: sequence, + }, + }); + await tx.agentRunEvent.create({ + data: { + id: runTerminalEventId(run.id, "failed"), + runId: run.id, + sequence, + type: "run.failed", + data: { code, message }, + emittedAt: finishedAt, + }, + }); + await tx.agentAuditEvent.upsert({ + where: { + agentId_type_requestId: { + agentId: run.agentId, + type: "run.failed", + requestId: run.id, + }, + }, + create: { + agentId: run.agentId, + versionId: run.versionId, + actorType: "AGENT", + actorId: run.id, + type: "run.failed", + summary: message, + requestId: run.id, + }, + update: {}, + }); + + return { id: run.id, status: "FAILED" as const }; } function manifestDataScope(value: unknown): { mode: RunRecordScope; resources: RunResource[]; } { - const manifest = recordOf(value); - const scope = recordOf(manifest.dataScope); - if (scope.mode !== "SELECTED" && scope.mode !== "WORKSPACE") { - throw new Error("Agent version has no valid CRM record scope."); - } - if (!Array.isArray(scope.resources)) { - throw new Error("Agent version has no valid CRM resources."); - } - - const resources = scope.resources.flatMap((resource) => { - if (!resource || typeof resource !== "object") return []; - const row = resource as Record; - if ( - !["integration", "company", "contact", "deal"].includes( - String(row.kind), - ) || - typeof row.id !== "string" || - typeof row.label !== "string" - ) { - return []; - } - return [resource as RunResource]; - }); + const scope = parseAgentManifest(value).dataScope; + const resources = scope.resources as RunResource[]; const records = resources.filter( (resource) => resource.kind !== "integration", ); @@ -503,8 +947,13 @@ function manifestDataScope(value: unknown): { } function manifestActions(value: unknown) { - const actions = recordOf(value).actions; - return Array.isArray(actions) ? actions.map(recordOf) : []; + return parseAgentManifest(value).actions; +} + +function externalManifestActions(value: unknown) { + return manifestActions(value).filter( + (action) => action.type !== AGENT_ACTION_TYPES.RUN_SUMMARY, + ); } function assertActivityAllowed( @@ -524,6 +973,51 @@ function assertActivityAllowed( } } +export function approvedSlackDestination(manifest: unknown): { + kind: "channel" | "user"; + id: string; + label: string; +} { + const scope = manifestDataScope(manifest); + if ( + !scope.resources.some( + (resource) => + resource.kind === "integration" && resource.id === "slack:workspace", + ) + ) { + throw new Error("Agent version does not allow Slack."); + } + + const destinations = manifestActions(manifest).flatMap((action) => { + if (action.type !== "slack.message.post") return []; + const destination = recordOf(action.destination); + if ( + !["channel", "user"].includes(String(destination.kind)) || + typeof destination.id !== "string" || + !destination.id || + typeof destination.label !== "string" || + !destination.label + ) { + return []; + } + return [ + { + kind: destination.kind as "channel" | "user", + id: destination.id, + label: destination.label, + }, + ]; + }); + const [destination] = destinations; + if (!destination || destinations.length !== 1) { + throw new Error( + "Agent version needs exactly one approved Slack destination.", + ); + } + + return destination; +} + function assertResourceAllowed( mode: RunRecordScope, resources: RunResource[], @@ -620,18 +1114,18 @@ function actionRequestHash(input: { body?: string | null; dueAt?: string | null; }): string { - return createHash("sha256") - .update( - JSON.stringify({ - type: input.type, - targetKind: input.targetKind, - targetId: input.targetId, - subject: input.subject?.trim() || null, - body: input.body?.trim() || null, - dueAt: input.dueAt?.trim() || null, - }), - ) - .digest("hex"); + return hashRequest({ + type: input.type, + targetKind: input.targetKind, + targetId: input.targetId, + subject: input.subject?.trim() || null, + body: input.body?.trim() || null, + dueAt: input.dueAt?.trim() || null, + }); +} + +function hashRequest(input: Record): string { + return createHash("sha256").update(JSON.stringify(input)).digest("hex"); } function assertActionRequestMatches( diff --git a/apps/agent/agent/lib/run-state.ts b/apps/agent/agent/lib/run-state.ts index e92f06567..c98fcb359 100644 --- a/apps/agent/agent/lib/run-state.ts +++ b/apps/agent/agent/lib/run-state.ts @@ -27,7 +27,21 @@ export async function lockAgentRun( export function runTerminalEventId( runId: string, - terminal: "completed" | "failed", + terminal: "completed" | "failed" | "cancelled", ) { return `run-terminal:${runId}:${terminal}`; } + +export const TERMINAL_RUN_STATUSES = [ + "SUCCEEDED", + "FAILED", + "CANCELLED", +] as const satisfies readonly AgentRunStatus[]; + +export function isTerminalRunStatus( + status: AgentRunStatus, +): status is (typeof TERMINAL_RUN_STATUSES)[number] { + return TERMINAL_RUN_STATUSES.includes( + status as (typeof TERMINAL_RUN_STATUSES)[number], + ); +} diff --git a/apps/agent/agent/lib/slack-config.ts b/apps/agent/agent/lib/slack-config.ts new file mode 100644 index 000000000..d7e687c32 --- /dev/null +++ b/apps/agent/agent/lib/slack-config.ts @@ -0,0 +1,16 @@ +const SECOND_MS = 1_000; +const MINUTE_MS = 60 * SECOND_MS; + +export const SLACK = { + request: { + timeoutMs: 15 * SECOND_MS, + maxAttempts: 3, + retryUnitMs: SECOND_MS, + }, + + inventory: { + pageSize: 200, + channelTypes: "public_channel,private_channel", + staleMs: 15 * MINUTE_MS, + }, +} as const; diff --git a/apps/agent/agent/lib/slack-connection.ts b/apps/agent/agent/lib/slack-connection.ts new file mode 100644 index 000000000..41b267c90 --- /dev/null +++ b/apps/agent/agent/lib/slack-connection.ts @@ -0,0 +1,28 @@ +import { db } from "@crm/db"; + +export async function slackAccessToken(): Promise { + const account = await db.account.findFirst({ + where: { providerId: "slack", accessToken: { not: null } }, + orderBy: { updatedAt: "desc" }, + select: { accessToken: true }, + }); + + return account?.accessToken ?? null; +} + +export async function slackConnected(): Promise { + return (await slackAccessToken()) !== null; +} + +export async function slackUserToken(): Promise { + const grant = await db.slackWorkspaceGrant.findFirst({ + orderBy: { updatedAt: "desc" }, + select: { userToken: true }, + }); + + return grant?.userToken ?? null; +} + +export async function slackCanInviteItself(): Promise { + return (await slackUserToken()) !== null; +} diff --git a/apps/agent/agent/lib/slack-join-task.ts b/apps/agent/agent/lib/slack-join-task.ts new file mode 100644 index 000000000..b2482bab3 --- /dev/null +++ b/apps/agent/agent/lib/slack-join-task.ts @@ -0,0 +1,19 @@ +import { parse, schemas } from "@crm/validation"; +import { joinSlackChannel } from "./slack-membership"; + +export async function runSlackChannelJoin(value: unknown): Promise { + const { channelId, channelName } = parse( + schemas.slack.joinPayload, + value, + "A slack-channel-join task carries an unreadable payload", + ); + const outcome = await joinSlackChannel(channelId); + + if (outcome.joined) { + return outcome.already + ? `Comp AI was already in #${channelName}.` + : `Comp AI joined #${channelName}.`; + } + + return `Comp AI could not join #${channelName}. ${outcome.reason}`; +} diff --git a/apps/agent/agent/lib/slack-membership.ts b/apps/agent/agent/lib/slack-membership.ts new file mode 100644 index 000000000..c776d5c3d --- /dev/null +++ b/apps/agent/agent/lib/slack-membership.ts @@ -0,0 +1,282 @@ +import { db } from "@crm/db"; +import { schemas } from "@crm/validation"; +import { z } from "zod"; +import { SLACK } from "./slack-config"; +import { slackAccessToken, slackUserToken } from "./slack-connection"; +import { requestSlackInventorySync } from "./slack-people"; + +export type JoinOutcome = + | { joined: true; already: boolean } + | { joined: false; reason: string; needsHuman: boolean }; + +type ChannelState = { isPrivate: boolean; isMember: boolean }; + +const ALREADY_IN_CHANNEL = "already_in_channel"; + +const CHANNEL_NOT_FOUND = "channel_not_found"; + +const channelInfo = schemas.slack.reply.extend({ + channel: z + .object({ + is_private: z.boolean().optional(), + is_member: z.boolean().optional(), + }) + .nullish(), +}); + +async function call( + token: string, + method: string, + body: Record, + attempt = 1, +): Promise<{ ok: boolean; error?: string }> { + const response = await fetch(`https://slack.com/api/${method}`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json; charset=utf-8", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(SLACK.request.timeoutMs), + }); + + const parsed = schemas.slack.reply.safeParse(await response.json()); + if (!parsed.success) return { ok: false, error: "unreadable_reply" }; + if (parsed.data.ok) return { ok: true }; + + if ( + parsed.data.error === "ratelimited" && + attempt < SLACK.request.maxAttempts + ) { + const wait = Number(response.headers.get("retry-after") ?? "1"); + await new Promise((resolve) => + setTimeout(resolve, wait * SLACK.request.retryUnitMs), + ); + return call(token, method, body, attempt + 1); + } + + return { ok: false, error: parsed.data.error }; +} + +async function botUserId(token: string): Promise { + const response = await fetch("https://slack.com/api/auth.test", { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(SLACK.request.timeoutMs), + }); + const parsed = schemas.slack.authTest.safeParse(await response.json()); + return parsed.success && parsed.data.ok + ? (parsed.data.user_id ?? null) + : null; +} + +async function liveChannelState( + token: string, + channelId: string, +): Promise { + const url = new URL("https://slack.com/api/conversations.info"); + url.searchParams.set("channel", channelId); + + try { + const response = await fetch(url, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(SLACK.request.timeoutMs), + }); + const parsed = channelInfo.safeParse(await response.json()); + if (!parsed.success) return null; + + if (parsed.data.ok && parsed.data.channel) { + return { + isPrivate: parsed.data.channel.is_private ?? false, + isMember: parsed.data.channel.is_member ?? false, + }; + } + + return parsed.data.error === CHANNEL_NOT_FOUND + ? { isPrivate: true, isMember: false } + : null; + } catch { + return null; + } +} + +async function classifyChannel( + channelId: string, + cached: ChannelState, + token: string, +): Promise { + const live = await liveChannelState(token, channelId); + if (!live) return cached; + if ( + live.isPrivate === cached.isPrivate && + live.isMember === cached.isMember + ) { + return live; + } + + await db.slackChannel + .update({ + where: { id: channelId }, + data: { ...live, classifiedAt: new Date() }, + }) + .catch(() => null); + await requestSlackInventorySync(); + + return live; +} + +export async function joinSlackChannel( + channelId: string, +): Promise { + const channel = await db.slackChannel.findUnique({ + where: { id: channelId }, + select: { id: true, isPrivate: true, isMember: true }, + }); + + if (!channel) { + return { joined: false, reason: "No such channel.", needsHuman: false }; + } + + const bot = await slackAccessToken(); + if (!bot) { + return { + joined: false, + reason: "Slack is not connected.", + needsHuman: true, + }; + } + + const state = await classifyChannel( + channelId, + { isPrivate: channel.isPrivate, isMember: channel.isMember }, + bot, + ); + if (state.isMember) return { joined: true, already: true }; + + const outcome = state.isPrivate + ? await inviteWithUserToken(channelId, bot) + : await call(bot, "conversations.join", { channel: channelId }); + + if (!outcome.ok && outcome.error !== ALREADY_IN_CHANNEL) { + return { + joined: false, + reason: explain(outcome.error ?? "rejected"), + needsHuman: needsHuman(outcome.error ?? "rejected"), + }; + } + + await db.slackChannel.update({ + where: { id: channelId }, + data: { + isMember: true, + available: true, + inviteRequestedAt: null, + classifiedAt: new Date(), + }, + }); + + return { joined: true, already: outcome.error === ALREADY_IN_CHANNEL }; +} + +async function inviteWithUserToken( + channelId: string, + bot: string, +): Promise<{ ok: boolean; error?: string }> { + const user = await slackUserToken(); + if (!user) return { ok: false, error: "no_user_grant" }; + + const id = await botUserId(bot); + if (!id) return { ok: false, error: "unknown_bot_user" }; + + return call(user, "conversations.invite", { channel: channelId, users: id }); +} + +function needsHuman(error: string): boolean { + return [ + "no_user_grant", + "channel_not_found", + "missing_scope", + "not_in_channel", + "invalid_auth", + "token_revoked", + "is_archived", + ].includes(error); +} + +function explain(error: string): string { + switch (error) { + case "no_user_grant": + return "This workspace did not grant Comp AI permission to add itself to a private channel."; + case "channel_not_found": + return "Slack cannot see this channel. A member has to invite Comp AI."; + case "is_archived": + return "This channel is archived. Somebody has to unarchive it before Comp AI can join."; + case "missing_scope": + return "Slack refused: a permission is missing. Reconnect Slack."; + case "invalid_auth": + case "token_revoked": + return "Slack needs to be reconnected."; + case "unknown_bot_user": + return "Slack did not report which user Comp AI is."; + default: + return `Slack refused the request (${error}).`; + } +} + +export async function createSlackChannel( + name: string, + isPrivate: boolean, +): Promise<{ id: string; name: string } | { error: string }> { + const user = await slackUserToken(); + const bot = await slackAccessToken(); + const token = isPrivate ? user : (user ?? bot); + + if (!token) { + return { + error: isPrivate + ? "This workspace did not grant Comp AI permission to create a private channel." + : "Slack is not connected.", + }; + } + + const response = await fetch("https://slack.com/api/conversations.create", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ name, is_private: isPrivate }), + signal: AbortSignal.timeout(SLACK.request.timeoutMs), + }); + + const parsed = schemas.slack.createReply.safeParse(await response.json()); + if (!parsed.success) + return { error: "Slack sent back something unreadable." }; + + if (!parsed.data.ok || !parsed.data.channel) { + return { error: explain(parsed.data.error ?? "rejected") }; + } + + const channel = parsed.data.channel; + + await db.slackChannel.upsert({ + where: { id: channel.id }, + create: { + id: channel.id, + name: channel.name, + isPrivate, + isMember: !isPrivate && token === bot, + available: true, + classifiedAt: new Date(), + }, + update: { + name: channel.name, + isPrivate, + available: true, + classifiedAt: new Date(), + }, + }); + + if (token === user) await joinSlackChannel(channel.id); + + return channel; +} diff --git a/apps/agent/agent/lib/slack-people.ts b/apps/agent/agent/lib/slack-people.ts new file mode 100644 index 000000000..b73d14e12 --- /dev/null +++ b/apps/agent/agent/lib/slack-people.ts @@ -0,0 +1,296 @@ +import { db } from "@crm/db"; +import { queueSlackInventorySync } from "@crm/db/slack-inventory"; +import { WORKSPACE_ID } from "@crm/db/workspace"; +import { parse, schemas } from "@crm/validation"; +import { z } from "zod"; +import { SLACK } from "./slack-config"; +import { slackAccessToken, slackUserToken } from "./slack-connection"; + +const slackMember = z.object({ + id: z.string().trim().min(1), + name: z.string().trim().min(1).optional(), + profile: z.object({ email: z.string().trim().min(1).nullish() }).nullish(), + deleted: z.boolean().optional(), + is_bot: z.boolean().optional(), +}); + +const slackChannel = z.object({ + id: z.string().trim().min(1), + name: z.string().trim().min(1), + num_members: z.number().int().nonnegative().nullish(), + is_member: z.boolean().optional(), + is_archived: z.boolean().optional(), + is_private: z.boolean().optional(), +}); + +const pageMetadata = z.object({ next_cursor: z.string().nullish() }).nullish(); + +const memberPage = schemas.slack.reply.extend({ + members: z.array(slackMember).default([]), + response_metadata: pageMetadata, +}); + +const channelPage = schemas.slack.reply.extend({ + channels: z.array(slackChannel).default([]), + response_metadata: pageMetadata, +}); + +type SlackMember = z.infer; +type SlackChannel = z.infer; +type SlackPage = { + ok: boolean; + error?: string; + response_metadata?: { next_cursor?: string | null } | null; +}; + +const RECONNECT_ERRORS = ["invalid_auth", "account_inactive", "token_revoked"]; + +const INVENTORY_REASON = + "Read Slack people and channels again: the cached inventory is stale"; + +export async function requestSlackInventorySync(): Promise { + await queueSlackInventorySync(INVENTORY_REASON); +} + +export async function requestStaleSlackInventorySync(): Promise { + try { + const newest = await db.slackChannel.findFirst({ + orderBy: { updatedAt: "desc" }, + select: { updatedAt: true }, + }); + const fresh = + newest !== null && + Date.now() - newest.updatedAt.getTime() < SLACK.inventory.staleMs; + if (fresh) return; + } catch { + return; + } + + await requestSlackInventorySync(); +} + +export async function runSlackPeopleMatch(): Promise { + const accessToken = await slackAccessToken(); + if (!accessToken) return "Slack is not connected."; + + const userToken = await slackUserToken(); + const [slackMembers, slackChannels] = await Promise.all([ + listSlackMembers(accessToken), + visibleChannels(accessToken, userToken), + ]); + const availableMembers = slackMembers.filter( + (member) => !member.deleted && !member.is_bot, + ); + const byEmail = new Map( + availableMembers.flatMap((member) => { + const email = member.profile?.email?.trim().toLowerCase(); + return email ? [[email, member] as const] : []; + }), + ); + const crmMembers = await db.member.findMany({ + where: { organizationId: WORKSPACE_ID }, + select: { + user: { + select: { + id: true, + email: true, + }, + }, + }, + }); + + let matched = 0; + for (const { user } of crmMembers) { + const slack = byEmail.get(user.email.trim().toLowerCase()); + const slackHandle = slack ? `@${slack.name ?? slack.id}` : null; + await db.slackMemberMatch.upsert({ + where: { crmUserId: user.id }, + create: { + crmUserId: user.id, + slackUserId: slack?.id, + slackHandle, + slackEmail: slack?.profile?.email, + }, + update: { + slackUserId: slack?.id ?? null, + slackHandle, + slackEmail: slack?.profile?.email ?? null, + }, + }); + if (slack) matched += 1; + } + + const availableChannels = await persistSlackChannels( + slackChannels, + Boolean(userToken), + ); + return `Matched ${matched} workspace ${matched === 1 ? "member" : "members"} by email and found ${availableChannels} available ${availableChannels === 1 ? "channel" : "channels"}.`; +} + +export async function refreshSlackChannels(): Promise { + const accessToken = await slackAccessToken(); + if (!accessToken) return 0; + + const userToken = await slackUserToken(); + return persistSlackChannels( + await visibleChannels(accessToken, userToken), + Boolean(userToken), + ); +} + +async function visibleChannels( + botToken: string, + userToken: string | null, +): Promise { + const fromBot = await listSlackChannels(botToken); + if (!userToken) return fromBot; + + const seen = new Map(fromBot.map((channel) => [channel.id, channel])); + + const fromUser = await listSlackChannels(userToken).catch(() => []); + + for (const channel of fromUser) { + if (seen.has(channel.id)) continue; + seen.set(channel.id, { ...channel, is_member: false }); + } + + return [...seen.values()]; +} + +export async function persistSlackChannels( + channels: SlackChannel[], + canInviteItself: boolean, +): Promise { + const available = [ + ...new Map( + channels + .filter( + (channel) => + !channel.is_archived && + (channel.is_member || !channel.is_private || canInviteItself), + ) + .map((channel) => [channel.id, channel] as const), + ).values(), + ]; + + return db.$transaction(async (tx) => { + const [account] = await tx.$queryRaw>` + SELECT id + FROM "account" + WHERE "providerId" = 'slack' AND "accessToken" IS NOT NULL + ORDER BY "updatedAt" DESC + LIMIT 1 + FOR UPDATE + `; + if (!account) return 0; + + const ids = available.map((channel) => channel.id); + await tx.slackChannel.updateMany({ + where: { id: { notIn: ids } }, + data: { available: false }, + }); + if (ids.length === 0) return 0; + + await tx.$executeRaw` + INSERT INTO "slackChannel" (id, name, "memberCount", "isPrivate", "isMember", available, "classifiedAt", "createdAt", "updatedAt") + SELECT id, name, "memberCount", "isPrivate", "isMember", true, NOW(), NOW(), NOW() + FROM UNNEST( + ${ids}::text[], + ${available.map((channel) => channel.name)}::text[], + ${available.map((channel) => channel.num_members ?? null)}::int[], + ${available.map((channel) => channel.is_private ?? false)}::boolean[], + ${available.map((channel) => channel.is_member ?? false)}::boolean[] + ) AS incoming(id, name, "memberCount", "isPrivate", "isMember") + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + "memberCount" = EXCLUDED."memberCount", + "isPrivate" = EXCLUDED."isPrivate", + "isMember" = EXCLUDED."isMember", + available = true, + "classifiedAt" = NOW(), + "updatedAt" = NOW() + `; + + return ids.length; + }); +} + +async function listSlackMembers(accessToken: string): Promise { + const members: SlackMember[] = []; + let cursor = ""; + do { + const page = await readSlackPage( + accessToken, + listUrl("users.list", cursor), + memberPage, + "member lookup", + ); + members.push(...page.members); + cursor = page.response_metadata?.next_cursor ?? ""; + } while (cursor); + return members; +} + +async function listSlackChannels(accessToken: string): Promise { + const channels: SlackChannel[] = []; + let cursor = ""; + do { + const page = await readSlackPage( + accessToken, + listUrl("conversations.list", cursor, { + exclude_archived: "true", + types: SLACK.inventory.channelTypes, + }), + channelPage, + "channel lookup", + ); + channels.push(...page.channels); + cursor = page.response_metadata?.next_cursor ?? ""; + } while (cursor); + return channels; +} + +function listUrl( + method: string, + cursor: string, + params: Record = {}, +): URL { + const url = new URL(`https://slack.com/api/${method}`); + url.searchParams.set("limit", String(SLACK.inventory.pageSize)); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + if (cursor) url.searchParams.set("cursor", cursor); + return url; +} + +async function readSlackPage>( + token: string, + url: URL, + schema: Schema, + operation: string, +): Promise> { + const response = await fetch(url, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(SLACK.request.timeoutMs), + }); + if (!response.ok) throw new Error(`Slack ${operation} failed.`); + + const page = parse(schema, await response.json(), `Slack ${operation}`); + if (!page.ok) throw rejected(page.error ?? "rejected", operation); + return page; +} + +function rejected(reason: string, operation: string): Error { + if (reason === "missing_scope") { + return new Error( + `Slack ${operation} needs an additional permission. Reconnect Slack and retry.`, + ); + } + if (RECONNECT_ERRORS.includes(reason)) { + return new Error( + `Slack ${operation} needs the workspace to be reconnected.`, + ); + } + return new Error(`Slack ${operation} was rejected (${reason}).`); +} diff --git a/apps/agent/agent/lib/tasks.ts b/apps/agent/agent/lib/tasks.ts index a58aad9b3..89ab2d518 100644 --- a/apps/agent/agent/lib/tasks.ts +++ b/apps/agent/agent/lib/tasks.ts @@ -1,12 +1,15 @@ -import { db, Prisma } from "@crm/db"; +import { db, type Prisma } from "@crm/db"; import { MAX_ATTEMPTS, RETIRED_OUTCOME } from "@crm/db/agent-tasks"; +import { DISPATCH } from "./dispatch-config"; export type LeasedTask = { id: string; contactId: string | null; companyId: string | null; + dealId: string | null; kind: string; reason: string; + payload: Prisma.JsonValue | null; budget: number; attempts: number; priority: number; @@ -17,10 +20,11 @@ export type TaskSubject = { id: string; contactId: string | null; companyId: string | null; + dealId: string | null; kind: string; }; -const LEASE_MS = 10 * 60_000; +const LEASE_MS = DISPATCH.task.leaseMs; export { DIRECT_KINDS, MAX_ATTEMPTS } from "@crm/db/agent-tasks"; @@ -32,10 +36,10 @@ export async function claimDue( const now = new Date(); const until = new Date(now.getTime() + leaseMs); - const list = "only" in kinds ? kinds.only : kinds.except; + const list = "only" in kinds ? [...kinds.only] : [...kinds.except]; if ("only" in kinds && list.length === 0) return []; - const match = Prisma.sql`t2.kind ${"only" in kinds ? Prisma.sql`IN` : Prisma.sql`NOT IN`} (${Prisma.join(list)})`; + const onlyMode = "only" in kinds; const claimed = await db.$queryRaw` UPDATE "agentTask" AS t @@ -48,13 +52,16 @@ export async function claimDue( AND t2."dueAt" <= ${now} AND (t2."leasedUntil" IS NULL OR t2."leasedUntil" < ${now}) AND t2."attempts" < ${MAX_ATTEMPTS} - AND ${match} + AND CASE + WHEN ${onlyMode}::boolean THEN t2.kind = ANY(${list}::text[]) + ELSE t2.kind <> ALL(${list}::text[]) + END ORDER BY t2."priority" DESC, t2."dueAt" ASC LIMIT ${limit} FOR UPDATE SKIP LOCKED ) AS due WHERE t.id = due.id - RETURNING t.id, t."contactId", t."companyId", t.kind, t.reason, + RETURNING t.id, t."contactId", t."companyId", t."dealId", t.kind, t.reason, t.payload, t.budget, t.attempts, t.priority, t."dueAt"; `; @@ -73,7 +80,7 @@ export async function retireExhausted(): Promise { WHERE t."finishedAt" IS NULL AND t."attempts" >= ${MAX_ATTEMPTS} AND (t."leasedUntil" IS NULL OR t."leasedUntil" < ${now}) - RETURNING t.id, t."contactId", t."companyId", t.kind; + RETURNING t.id, t."contactId", t."companyId", t."dealId", t.kind; `; } @@ -95,14 +102,26 @@ export async function completeTask( return db.agentTask.findUnique({ where: { id: taskId }, - select: { id: true, contactId: true, companyId: true, kind: true }, + select: { + id: true, + contactId: true, + companyId: true, + dealId: true, + kind: true, + }, }); } export async function taskSubject(taskId: string): Promise { return db.agentTask.findUnique({ where: { id: taskId }, - select: { id: true, contactId: true, companyId: true, kind: true }, + select: { + id: true, + contactId: true, + companyId: true, + dealId: true, + kind: true, + }, }); } @@ -119,8 +138,10 @@ export async function noteSession( export async function scheduleTask(input: { contactId?: string | null; companyId?: string | null; + dealId?: string | null; kind: string; reason: string; + payload?: Prisma.InputJsonValue | null; dueAt: Date; priority?: number; budget?: number; @@ -131,6 +152,7 @@ export async function scheduleTask(input: { finishedAt: null, contactId: input.contactId ?? undefined, companyId: input.companyId ?? undefined, + dealId: input.dealId ?? undefined, }, select: { id: true }, }); @@ -147,8 +169,10 @@ export async function scheduleTask(input: { data: { contactId: input.contactId ?? null, companyId: input.companyId ?? null, + dealId: input.dealId ?? null, kind: input.kind, reason: input.reason, + payload: input.payload ?? undefined, dueAt: input.dueAt, priority: input.priority ?? 0, budget: input.budget ?? 4, diff --git a/apps/agent/agent/schedules/dispatch.ts b/apps/agent/agent/schedules/dispatch.ts index 3032757e9..c3b650af7 100644 --- a/apps/agent/agent/schedules/dispatch.ts +++ b/apps/agent/agent/schedules/dispatch.ts @@ -15,14 +15,14 @@ export default defineSchedule({ Promise.all([ sweepBlankFacts(), - drainAll((task) => - receive(crm, { - message: brief(task), - target: { taskId: task.id }, - auth: taskAuth(task, appAuth), - }), - ), (async () => { + await drainAll((task) => + receive(crm, { + message: brief(task), + target: { taskId: task.id }, + auth: taskAuth(task, appAuth), + }), + ); await queueDueAgentRuns(); const [builderIds, runIds] = await Promise.all([ pendingBuilderSubmissionIds(), diff --git a/apps/agent/agent/subagents/agent_builder/instructions.md b/apps/agent/agent/subagents/agent_builder/instructions.md index 1742462f4..d4321cf79 100644 --- a/apps/agent/agent/subagents/agent_builder/instructions.md +++ b/apps/agent/agent/subagents/agent_builder/instructions.md @@ -5,29 +5,60 @@ private builder chat. Call `inspect_context` first. It is the authority for connected integrations, selected CRM records, the current time, and any existing draft. Never invent a -connection or record. +connection or record. If the user answers that they connected Slack, invited +the bot, or otherwise changed connection access, call `inspect_context` again +before asking another question or saving. The user should not need to provide a complete specification. Treat a short description of the job or desired outcome as enough to draft when a safe, bounded interpretation exists. Use the inspected CRM context and existing draft to do the design work: infer a clear name, instructions, relevant CRM record -types, and useful output. When omitted, prefer a manual trigger, no external +types, and useful output. When omitted, prefer one manual trigger, no external integration, and `run.summary` over a side effect. Use exact tagged records when present. A request about a pipeline, workspace-wide collection, or class of CRM records may use `WORKSPACE`; do not expand a request about one record into workspace access. Human review of the completed draft is the place to expose these choices. +The `crmEvents` returned by `inspect_context` are the complete supported +real-time CRM event catalog. Use one `EVENT` trigger with the exact `type` for +each independently requested event. Keep requested lifecycle moments separate; +do not collapse created, stage-changed, opened, or closed behavior into one +trigger. Event agents use `WORKSPACE` record scope because the triggering record +cannot be selected before it exists. Never replace a supported event with a +polling schedule or claim support for an event absent from inspected context. +Always send `triggers` as an array, including when the agent has only one. + Make the smallest agent that solves the stated pain. Its instructions must say exactly when it runs, which CRM records it may read, what output or CRM action it may produce, and when it must stop. Preserve the user's meaning and wording where that is clearer than a rewrite. The currently executable action types are `crm.activity.create` for CRM notes -and tasks, and `run.summary` for a logged result with no external side effect. +and tasks, `run.summary` for a logged result with no external side effect, and +`slack.message.post` for a message to one approved Slack channel or person. Gmail and Google Calendar are read-only sources when connected. Do not promise -email sending, Slack, arbitrary webhooks, or any integration the context does -not report. +email sending, arbitrary webhooks, or any integration the context does not +report. + +Every executable Slack destination is `chosen` and pinned to an inspected Slack +id. When a named person matches +exactly one entry in `availableConnections.slackPeople` by CRM name, CRM email, +Slack email, or Slack handle, use that exact inspected id and label silently. +When zero or multiple people plausibly match, call `ask_question` with two to +four matched Slack people as options, use their inspected ids as option ids, +their handles as labels, and their CRM names and emails as descriptions. Do not +ask the user to type a handle or Slack id when inspected people are available. +When the user explicitly names a channel and exactly one inspected channel has +that label, use its inspected id and label silently. For a channel that was not +already explicitly selected, ask one focused `ask_question`, offer only +inspected channels, include member counts in option descriptions, allow a +channel search as the escape hatch, and restate why it cannot be derived. If an +explicitly named channel is not inspected, tell the user to add the Slack bot +to it and ask them to answer after that is done; re-inspect when they answer. +Never accept a pasted name or id as an executable destination until it appears +in inspected context. Save a Slack destination with `kind`, the exact inspected +`id` and `label`, and `resolution: chosen`. If no safe and useful draft is possible because an essential target, explicitly requested connection, schedule, outcome, or side effect remains ambiguous, do @@ -49,10 +80,12 @@ workspace access. The `save_agent_draft` resource contract is exact. Copy only tagged companies, contacts, and deals from `inspect_context` into `resources`, preserving each -kind, id, and label byte for byte. Put read-only sources in `integrations` using -only `gmail` or `calendar`, and only when `availableConnections` reports that -source. Never put CRM, Gmail, Google Calendar, or another integration in -`resources`. The runtime derives the human-readable access list. +kind, id, and label byte for byte. Declare every granted source in +`integrations` using only `gmail`, `calendar`, or `slack`, and only when +`availableConnections` reports that source. Gmail and Google Calendar are +read-only there. Slack is executable, so declare it whenever the agent posts a +Slack message. Never put CRM, Gmail, Google Calendar, Slack, or another +integration in `resources`. The runtime derives the human-readable access list. For `crm.activity.create`, list the exact allowed activity types. Authorize `NOTE`, `TASK`, or both only when the request calls for them. A prose summary @@ -68,4 +101,4 @@ same behavior. A successful save creates exact final file snapshots and an immutable version in READY state for human review. It does not deploy it. After a successful save, call no tool except `final_output`. Return `draft_ready` immediately with the saved agent and version ids plus a -plain-language summary of the trigger, data scope, action, and access. +plain-language summary of the triggers, data scope, action, and access. diff --git a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts index e2d55ea5a..453e3cf39 100644 --- a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts +++ b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts @@ -1,4 +1,6 @@ +import { CRM_EVENT_TYPES } from "@crm/db/crm-events"; import { z } from "zod"; +import { AGENT_ACTION_TYPES } from "../../../lib/agent-actions"; import type { DraftAgentInput } from "../../../lib/builder-runtime"; const recordResource = z.object({ @@ -7,17 +9,29 @@ const recordResource = z.object({ label: z.string().min(1).max(120), }); -const trigger = z.object({ - type: z.enum(["MANUAL", "SCHEDULE"]), +const triggerMetadata = { name: z.string().trim().min(1).max(120), summary: z.string().trim().min(1).max(240), - nextRunAt: z.string().nullish(), - intervalMinutes: z.number().int().min(1).max(525_600).nullish(), -}); +}; + +const trigger = z.discriminatedUnion("type", [ + z.object({ type: z.literal("MANUAL"), ...triggerMetadata }), + z.object({ + type: z.literal("SCHEDULE"), + ...triggerMetadata, + nextRunAt: z.string(), + intervalMinutes: z.number().int().min(1).max(525_600), + }), + z.object({ + type: z.literal("EVENT"), + ...triggerMetadata, + event: z.enum(CRM_EVENT_TYPES), + }), +]); const action = z.discriminatedUnion("type", [ z.object({ - type: z.literal("crm.activity.create"), + type: z.literal(AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE), provider: z.literal("crm"), summary: z.string().trim().min(1).max(240), activityTypes: z @@ -26,20 +40,31 @@ const action = z.discriminatedUnion("type", [ .max(2), }), z.object({ - type: z.literal("run.summary"), + type: z.literal(AGENT_ACTION_TYPES.RUN_SUMMARY), provider: z.literal("crm"), summary: z.string().trim().min(1).max(240), }), + z.object({ + type: z.literal(AGENT_ACTION_TYPES.SLACK_MESSAGE_POST), + provider: z.literal("slack"), + summary: z.string().trim().min(1).max(240), + destination: z.object({ + kind: z.enum(["channel", "user"]), + resolution: z.literal("chosen"), + id: z.string().trim().min(1).max(120), + label: z.string().trim().min(1).max(120), + }), + }), ]); export const builderDraftToolInput = z.object({ name: z.string().trim().min(1).max(100), description: z.string().trim().min(1).max(320), instructions: z.string().trim().min(40).max(20_000), - trigger, + triggers: z.array(trigger).min(1).max(10), recordScope: z.enum(["SELECTED", "WORKSPACE"]), resources: z.array(recordResource).max(30), - integrations: z.array(z.enum(["gmail", "calendar"])).max(2), + integrations: z.array(z.enum(["gmail", "calendar", "slack"])).max(3), actions: z.array(action).min(1).max(10), }); @@ -59,6 +84,7 @@ const INTEGRATIONS = { id: "google:calendar", label: "Google Calendar", }, + slack: { kind: "integration", id: "slack:workspace", label: "Slack" }, } as const; export function draftInputFromTool( @@ -68,18 +94,22 @@ export function draftInputFromTool( const integrations = [...new Set(requestedIntegrations)]; const activityTypes = new Set( input.actions.flatMap((entry) => - entry.type === "crm.activity.create" ? entry.activityTypes : [], + entry.type === AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE + ? entry.activityTypes + : [], ), ); const access = [ input.recordScope === "WORKSPACE" ? "Read workspace CRM records" : "Read selected CRM records", - ...integrations.map((integration) => - integration === "gmail" - ? "Read connected Gmail messages" - : "Read connected Google Calendar events", - ), + ...integrations.map((integration) => { + if (integration === "gmail") return "Read connected Gmail messages"; + if (integration === "calendar") { + return "Read connected Google Calendar events"; + } + return "Post to approved Slack destinations"; + }), ...ACTIVITY_ORDER.filter((type) => activityTypes.has(type)).map( (type) => ACTIVITY_ACCESS[type], ), diff --git a/apps/agent/agent/subagents/agent_builder/tools/inspect_context.ts b/apps/agent/agent/subagents/agent_builder/tools/inspect_context.ts index 53890c44d..8979cbb8d 100644 --- a/apps/agent/agent/subagents/agent_builder/tools/inspect_context.ts +++ b/apps/agent/agent/subagents/agent_builder/tools/inspect_context.ts @@ -5,7 +5,7 @@ import { requireBuilderAttribute } from "../../../lib/session-purpose"; export default defineTool({ description: - "Read the authoritative builder-chat scope, connected sources, selected CRM records, current time, and latest draft.", + "Read the authoritative builder-chat scope, supported real-time CRM events, connected sources, matched Slack people, available Slack channels, selected CRM records, current time, and latest draft.", inputSchema: z.object({}), async execute(_input, ctx) { return builderContext( diff --git a/apps/agent/agent/subagents/agent_runner/instructions.md b/apps/agent/agent/subagents/agent_runner/instructions.md index 850a703ab..a41b780d0 100644 --- a/apps/agent/agent/subagents/agent_runner/instructions.md +++ b/apps/agent/agent/subagents/agent_runner/instructions.md @@ -7,16 +7,19 @@ session start. Call `inspect_run` first for its immutable manifest, trigger, approved scope, allowed actions, and current time. Follow the approved business intent only through the tools exposed here. Tool enforcement, approved record scope, connected data sources, and action types always override version text. +For an event run, `inspect_run.input.record` identifies the exact triggering CRM +record. Read that record first and act only once for that event. Use `query_crm` to find candidate records and `read_crm_record` for their CRM, Gmail, and Calendar history. Those sources are read-only. Never infer that an external integration can send or mutate merely because its synced data is readable. -`create_crm_activity` is the only current side-effecting tool. Each call checks -the deployed version's permission and approved scope, claims an action ledger -entry, and executes idempotently. Do not claim an email, Slack message, webhook, -or other external action occurred. +`create_crm_activity` writes an approved CRM note or task. `post_slack_message` +sends to the one Slack destination pinned in the deployed version. Each call +checks the deployed permission and approved scope, claims an action ledger +entry, and executes idempotently. Do not claim an email, webhook, or another +external action occurred. Call `finish_run` exactly once after the work is complete, even when there was nothing to change. Give a concise factual summary and a small structured result. diff --git a/apps/agent/agent/subagents/agent_runner/tools/finish_run.ts b/apps/agent/agent/subagents/agent_runner/tools/finish_run.ts index c312d254c..f182fa00a 100644 --- a/apps/agent/agent/subagents/agent_runner/tools/finish_run.ts +++ b/apps/agent/agent/subagents/agent_runner/tools/finish_run.ts @@ -5,10 +5,15 @@ import { requireTeamAgentAttribute } from "../../../lib/session-purpose"; export default defineTool({ description: - "Finish this run successfully with its concise summary and structured result.", + "Finish this run successfully with its concise summary and structured result. Set noActionNeeded when the trigger fired but this run's condition was not met, so none of the declared actions applied — an agent that watches for something is expected to do nothing when that thing did not happen.", inputSchema: z.object({ summary: z.string().trim().min(1).max(1000), result: z.record(z.string(), z.unknown()).nullish(), + noActionNeeded: z + .object({ + reason: z.string().trim().min(1).max(500), + }) + .nullish(), }), async execute(input, ctx) { return stageRunResult(requireTeamAgentAttribute(ctx, "runId"), input); diff --git a/apps/agent/agent/subagents/agent_runner/tools/post_slack_message.ts b/apps/agent/agent/subagents/agent_runner/tools/post_slack_message.ts new file mode 100644 index 000000000..d46432323 --- /dev/null +++ b/apps/agent/agent/subagents/agent_runner/tools/post_slack_message.ts @@ -0,0 +1,20 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { postRunSlackMessage } from "../../../lib/run-runtime"; +import { requireTeamAgentAttribute } from "../../../lib/session-purpose"; + +export default defineTool({ + description: + "Post one message to the exact Slack channel or person approved in the deployed version. The destination comes from the manifest and the action is idempotent across retries.", + inputSchema: z.object({ + text: z.string().trim().min(1).max(4_000), + }), + async execute(input, ctx) { + return postRunSlackMessage( + requireTeamAgentAttribute(ctx, "runId"), + ctx.callId, + input, + ctx.abortSignal, + ); + }, +}); diff --git a/apps/agent/package.json b/apps/agent/package.json index d7c6fa609..37aaa7ccb 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -22,6 +22,7 @@ "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/telemetry": "workspace:*", + "@crm/validation": "workspace:*", "context.dev": "^2.7.0", "eve": "^0.29.4", "zod": "^4.4.3" diff --git a/apps/agent/test/builder-runtime.integration.spec.ts b/apps/agent/test/builder-runtime.integration.spec.ts index ba625c6fe..f390d37ce 100644 --- a/apps/agent/test/builder-runtime.integration.spec.ts +++ b/apps/agent/test/builder-runtime.integration.spec.ts @@ -1,10 +1,12 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { db } from "@crm/db"; +import { persistBuilderInputRequest } from "../agent/lib/builder-input"; import { saveBuilderDraft, writeBuilderArtifact, } from "../agent/lib/builder-runtime"; import { setBuilderConversationTitle } from "../agent/lib/conversation-title"; +import { builderToken } from "../agent/lib/custom-agent-dispatch"; const suffix = crypto.randomUUID(); const userId = `builder-runtime-user-${suffix}`; @@ -21,7 +23,11 @@ beforeAll(async () => { }, }); const conversation = await db.agentConversation.create({ - data: { kind: "BUILDER", userId }, + data: { + kind: "BUILDER", + userId, + sessionId: `builder-question-session-${suffix}`, + }, select: { id: true }, }); conversationId = conversation.id; @@ -68,6 +74,163 @@ afterAll(async () => { }); describe("builder persistence", () => { + it("persists a proxied child question on its builder conversation", async () => { + const request = { + kind: "question", + requestId: "question-from-child", + prompt: "Which channel should receive this?", + action: { + kind: "tool-call", + callId: "call-from-child", + toolName: "ask_question", + input: { prompt: "Which channel should receive this?" }, + }, + display: "select", + options: [{ id: "C123", label: "#sales" }], + }; + const event = { + requests: [request], + sequence: 1, + stepIndex: 2, + turnId: "child-turn", + }; + + expect( + await persistBuilderInputRequest(event, undefined, conversationId), + ).toBe(true); + expect( + await db.agentConversation.findUnique({ + where: { id: conversationId }, + select: { continuationToken: true, pendingInputRequest: true }, + }), + ).toEqual({ + continuationToken: builderToken(conversationId), + pendingInputRequest: request, + }); + expect( + await db.agentEvent.findUnique({ + where: { + id: `builder-input:${conversationId}:${request.requestId}`, + }, + select: { conversationId: true, type: true, data: true }, + }), + ).toEqual({ + conversationId, + type: "input.requested", + data: event, + }); + + const newer = { + ...event, + sequence: 4, + requests: [ + { + ...request, + requestId: "newer-question-from-child", + prompt: "Which deal stage should this watch?", + }, + ], + }; + expect( + await persistBuilderInputRequest(newer, undefined, conversationId), + ).toBe(true); + + expect( + await persistBuilderInputRequest(event, undefined, conversationId), + ).toBe(false); + expect( + await persistBuilderInputRequest( + { + ...event, + sequence: 2, + requests: [{ ...request, requestId: "stale-question-from-child" }], + }, + undefined, + conversationId, + ), + ).toBe(false); + + expect( + await db.agentConversation.findUnique({ + where: { id: conversationId }, + select: { pendingInputRequest: true }, + }), + ).toEqual({ pendingInputRequest: newer.requests[0] }); + expect( + await db.agentEvent.count({ + where: { + id: `builder-input:${conversationId}:stale-question-from-child`, + }, + }), + ).toBe(0); + }); + + it("ignores a delayed question from a turn the session has already left", async () => { + const conversation = await db.agentConversation.create({ + data: { + kind: "BUILDER", + userId, + sessionId: `builder-stale-turn-session-${suffix}`, + }, + select: { id: true }, + }); + conversationIds.push(conversation.id); + + const question = (requestId: string, prompt: string) => ({ + kind: "question", + requestId, + prompt, + action: { + kind: "tool-call", + callId: `call-${requestId}`, + toolName: "ask_question", + input: { prompt }, + }, + display: "text", + }); + + const current = question("current-turn-question", "Which channel?"); + expect( + await persistBuilderInputRequest( + { + requests: [current], + sequence: 5, + stepIndex: 0, + turnId: "turn_5", + }, + undefined, + conversation.id, + ), + ).toBe(true); + + expect( + await persistBuilderInputRequest( + { + requests: [question("earlier-turn-question", "Which stage?")], + sequence: 3, + stepIndex: 7, + turnId: "turn_3", + }, + undefined, + conversation.id, + ), + ).toBe(false); + + expect( + await db.agentConversation.findUnique({ + where: { id: conversation.id }, + select: { pendingInputRequest: true }, + }), + ).toEqual({ pendingInputRequest: current }); + expect( + await db.agentEvent.count({ + where: { + id: `builder-input:${conversation.id}:earlier-turn-question`, + }, + }), + ).toBe(0); + }); + it("sets a concise model-authored title only once", async () => { const conversation = await db.agentConversation.create({ data: { kind: "BUILDER", userId }, @@ -114,11 +277,13 @@ describe("builder persistence", () => { name: "Meeting prep", description: "Prepare a concise CRM meeting brief.", instructions, - trigger: { - type: "MANUAL" as const, - name: "Manual", - summary: "Run when a rep requests meeting preparation.", - }, + triggers: [ + { + type: "MANUAL" as const, + name: "Manual", + summary: "Run when a rep requests meeting preparation.", + }, + ], recordScope: "WORKSPACE" as const, resources: [], actions: [ @@ -164,11 +329,13 @@ describe("builder persistence", () => { conversationIds.push(conversation.id); const base = { description: "Prepare a distinct CRM run summary.", - trigger: { - type: "MANUAL" as const, - name: "Manual", - summary: "Run when a rep requests it.", - }, + triggers: [ + { + type: "MANUAL" as const, + name: "Manual", + summary: "Run when a rep requests it.", + }, + ], recordScope: "WORKSPACE" as const, resources: [], actions: [ @@ -203,6 +370,76 @@ describe("builder persistence", () => { ).toBe(4); }); + it("persists every event trigger on one agent version", async () => { + const conversation = await db.agentConversation.create({ + data: { kind: "BUILDER", userId }, + select: { id: true }, + }); + conversationIds.push(conversation.id); + + const saved = await saveBuilderDraft(conversation.id, userId, { + name: "Deal lifecycle alerts", + description: "Report deal creation, opening, and closing.", + instructions: + "When a configured deal lifecycle event occurs, read the triggering deal, return one concise summary of the change, and stop without changing CRM records.", + triggers: [ + { + type: "EVENT", + name: "Deal created", + summary: "Run when a deal is created.", + event: "deal.created", + }, + { + type: "EVENT", + name: "Deal opened", + summary: "Run when a closed deal returns to the open pipeline.", + event: "deal.opened", + }, + { + type: "EVENT", + name: "Deal closed", + summary: "Run when an open deal enters a closed stage.", + event: "deal.closed", + }, + ], + recordScope: "WORKSPACE", + resources: [], + actions: [ + { + type: "run.summary", + provider: "crm", + summary: "Write a deal lifecycle summary.", + }, + ], + access: ["Read workspace CRM records"], + }); + if (!saved.saved) throw new Error("Lifecycle draft was not saved"); + + expect( + await db.agentTrigger.findMany({ + where: { versionId: saved.versionId }, + orderBy: { createdAt: "asc" }, + select: { type: true, config: true }, + }), + ).toEqual([ + { type: "EVENT", config: { event: "deal.created" } }, + { type: "EVENT", config: { event: "deal.opened" } }, + { type: "EVENT", config: { event: "deal.closed" } }, + ]); + + const version = await db.agentVersion.findUniqueOrThrow({ + where: { id: saved.versionId }, + select: { manifest: true }, + }); + expect(version.manifest).toMatchObject({ + triggers: [ + { config: { event: "deal.created" } }, + { config: { event: "deal.opened" } }, + { config: { event: "deal.closed" } }, + ], + }); + }); + it("fails closed on unsupported integrations and ambiguous record scope", async () => { const conversation = await db.agentConversation.create({ data: { kind: "BUILDER", userId }, @@ -214,11 +451,13 @@ describe("builder persistence", () => { description: "Keep a bounded CRM summary.", instructions: "When manually triggered, read only the approved CRM scope and return a concise summary without changing CRM records.", - trigger: { - type: "MANUAL" as const, - name: "Manual", - summary: "Run only when a teammate requests it.", - }, + triggers: [ + { + type: "MANUAL" as const, + name: "Manual", + summary: "Run only when a teammate requests it.", + }, + ], actions: [ { type: "run.summary" as const, @@ -250,6 +489,35 @@ describe("builder persistence", () => { saved: false, issues: ["Selected CRM scope needs at least one tagged record."], }); + + const missingSummary = await saveBuilderDraft(conversation.id, userId, { + ...base, + recordScope: "WORKSPACE", + resources: [], + actions: [ + { + type: "crm.activity.create", + provider: "crm", + summary: "Write one note.", + activityTypes: ["NOTE"], + }, + ], + }); + expect(missingSummary).toMatchObject({ + saved: false, + issues: ["An agent needs one run summary action."], + }); + + const duplicateActions = await saveBuilderDraft(conversation.id, userId, { + ...base, + recordScope: "WORKSPACE", + resources: [], + actions: [...base.actions, ...base.actions], + }); + expect(duplicateActions).toMatchObject({ + saved: false, + issues: ["The run.summary action is listed more than once."], + }); }); it("keeps a live definition unchanged until a revised version is deployed", async () => { @@ -264,11 +532,13 @@ describe("builder persistence", () => { description: "Report the workspace company count.", instructions: "When manually triggered, read workspace companies and return the company count in a concise run summary without changing CRM records.", - trigger: { - type: "MANUAL" as const, - name: "Manual", - summary: "Run when a teammate requests a workspace pulse.", - }, + triggers: [ + { + type: "MANUAL" as const, + name: "Manual", + summary: "Run when a teammate requests a workspace pulse.", + }, + ], recordScope: "WORKSPACE" as const, resources: [], actions: [ @@ -308,7 +578,7 @@ describe("builder persistence", () => { conversation.id, userId, "agent/README.md", - `# ${revised.name}\n\n${revised.description}\n\n## Trigger\n\n${revised.trigger.summary}\n\n## Access\n\n- ${revised.access[0]}\n`, + `# ${revised.name}\n\n${revised.description}\n\n## Triggers\n\n- ${revised.triggers[0]?.summary}\n\n## Access\n\n- ${revised.access[0]}\n`, ); const second = await saveBuilderDraft(conversation.id, userId, revised); if (!second.saved) throw new Error("Revised draft was not saved"); diff --git a/apps/agent/test/custom-agent-runtime.spec.ts b/apps/agent/test/custom-agent-runtime.spec.ts index c1e2d4676..4ae561681 100644 --- a/apps/agent/test/custom-agent-runtime.spec.ts +++ b/apps/agent/test/custom-agent-runtime.spec.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "bun:test"; import { builderTaskMarkdown } from "../agent/instructions/task"; +import { AGENT_ACTION_EXECUTORS } from "../agent/lib/agent-actions"; import { recordBuilderDelegation } from "../agent/lib/builder-delegation"; +import { + actionIntegrationIssues, + type DraftAction, + slackDestinationIssues, +} from "../agent/lib/builder-runtime"; import { builderCommandType, builderDeliveryMessage, @@ -9,7 +15,11 @@ import { runIdFromToken, runToken, } from "../agent/lib/custom-agent-dispatch"; -import { allowedHistorySources } from "../agent/lib/run-runtime"; +import { + allowedHistorySources, + approvedSlackDestination, + sendSlackMessage, +} from "../agent/lib/run-runtime"; import { assertResearchPurpose, attribute, @@ -56,27 +66,51 @@ describe("custom agent continuation tokens", () => { }); describe("builder delivery messages", () => { - it("keeps clarification answers in agent-creation mode", () => { + it("keeps clarification answers in agent-creation mode without coercing chat", () => { expect( builderCommandType("CHAT", { - inputResponse: { requestId: "question-1", answer: "crm-task" }, + inputResponse: { requestId: "question-1", optionId: "crm-task" }, }), ).toBe("CREATE_AGENT"); + expect(builderCommandType("CHAT", { text: "Also add a note" })).toBe( + "CHAT", + ); expect(builderCommandType("CHAT", { text: "Research this company" })).toBe( "CHAT", ); }); - it("delivers a question response without submission wrapper text", () => { + it("routes a selected answer back to the parked Eve input request", () => { expect( builderDeliveryMessage("submission-1", { text: "Use a CRM task instead", inputResponse: { requestId: "question-1", - answer: "crm-task", + optionId: "crm-task", }, }), - ).toBe("crm-task"); + ).toEqual({ + inputResponses: [{ requestId: "question-1", optionId: "crm-task" }], + }); + }); + + it("routes a written answer back to the parked Eve input request", () => { + expect( + builderDeliveryMessage("submission-1", { + text: "Use the private renewals channel", + inputResponse: { + requestId: "question-2", + text: "Use the private renewals channel", + }, + }), + ).toEqual({ + inputResponses: [ + { + requestId: "question-2", + text: "Use the private renewals channel", + }, + ], + }); }); it("delivers persisted attachment bytes with model-visible metadata", () => { @@ -154,10 +188,138 @@ describe("deployed agent data sources", () => { }); }); +describe("deployed Slack actions", () => { + const manifest = { + triggers: [ + { + type: "MANUAL", + name: "Run now", + summary: "Run on demand", + config: {}, + }, + ], + dataScope: { + mode: "WORKSPACE", + summary: "Workspace CRM records", + resources: [ + { kind: "integration", id: "slack:workspace", label: "Slack" }, + ], + }, + actions: [ + { + type: "slack.message.post", + provider: "slack", + summary: "Direct message the deal owner", + destination: { + kind: "user", + resolution: "chosen", + id: "U123", + label: "@grim", + }, + }, + { + type: "run.summary", + provider: "crm", + summary: "Summarize the Slack delivery", + }, + ], + }; + + it("derives the destination from the deployed manifest", () => { + expect(approvedSlackDestination(manifest)).toEqual({ + kind: "user", + id: "U123", + label: "@grim", + }); + expect(() => + approvedSlackDestination({ + ...manifest, + dataScope: { ...manifest.dataScope, resources: [] }, + }), + ).toThrow("does not allow Slack"); + }); + + it("posts with a stable Slack replay id", async () => { + const requests: Array<{ url: string; body: unknown }> = []; + const order: string[] = []; + const fetcher = (async (input: RequestInfo | URL, init?: RequestInit) => { + order.push(String(input)); + requests.push({ + url: String(input), + body: JSON.parse(String(init?.body)), + }); + return requests.length === 1 + ? Response.json({ ok: true, channel: { id: "D123" } }) + : Response.json({ ok: true, channel: "D123", ts: "123.456" }); + }) as typeof fetch; + + expect( + await sendSlackMessage( + "xoxb-test", + { kind: "user", id: "U123", label: "@grim" }, + "A deal just closed.", + "7d3e8854-79f9-48dd-a933-8cfb5994f99e", + { + fetcher, + beforePost: async () => { + order.push("guard"); + }, + }, + ), + ).toEqual({ channel: "D123", ts: "123.456" }); + expect(requests).toEqual([ + { + url: "https://slack.com/api/conversations.open", + body: { users: "U123", return_im: true }, + }, + { + url: "https://slack.com/api/chat.postMessage", + body: { + channel: "D123", + text: "A deal just closed.", + client_msg_id: "7d3e8854-79f9-48dd-a933-8cfb5994f99e", + }, + }, + ]); + expect(order).toEqual([ + "https://slack.com/api/conversations.open", + "guard", + "https://slack.com/api/chat.postMessage", + ]); + }); + + it("surfaces an actionable missing-channel error", async () => { + const fetcher = (async () => + Response.json({ ok: false, error: "not_in_channel" })) as typeof fetch; + + expect( + sendSlackMessage( + "xoxb-test", + { kind: "channel", id: "C123", label: "#alerts" }, + "A deal just closed.", + "7d3e8854-79f9-48dd-a933-8cfb5994f99e", + { fetcher }, + ), + ).rejects.toThrow("Invite the app"); + }); +}); + +describe("deployed action executors", () => { + it("registers an executor for every draftable action", () => { + const draftable = builderDraftToolInput.shape.actions.element.options.map( + (option) => option.shape.type.value, + ); + expect(Object.keys(AGENT_ACTION_EXECUTORS).sort()).toEqual( + draftable.sort(), + ); + }); +}); + describe("builder command routing", () => { it("delegates only the explicit creation command to the agent builder", () => { const creation = builderTaskMarkdown("CREATE_AGENT"); expect(creation).toContain("Call agent_builder exactly once"); + expect(creation).toContain("do not ask the user a clarification yourself"); expect(creation).toContain("Never retry agent_builder in the same turn"); expect(creation).toContain("asks any essential clarification directly"); const chat = builderTaskMarkdown("CHAT"); @@ -333,11 +495,13 @@ describe("agent builder draft input", () => { description: "Prepare a renewal call brief.", instructions: "Run manually. Read the selected deal and summarize renewal risks for review.", - trigger: { - type: "MANUAL", - name: "Prepare renewal brief", - summary: "Run before a renewal call", - }, + triggers: [ + { + type: "MANUAL", + name: "Prepare renewal brief", + summary: "Run before a renewal call", + }, + ], recordScope: "SELECTED", resources: [{ kind: "deal", id: "deal-1", label: "Acme renewal" }], integrations: ["gmail", "calendar"], @@ -374,11 +538,13 @@ describe("agent builder draft input", () => { description: "Prepare a renewal call brief.", instructions: "Run manually. Read the selected deal and summarize renewal risks for review.", - trigger: { - type: "MANUAL", - name: "Prepare renewal brief", - summary: "Run before a renewal call", - }, + triggers: [ + { + type: "MANUAL", + name: "Prepare renewal brief", + summary: "Run before a renewal call", + }, + ], recordScope: "SELECTED", resources: [{ kind: "integration", id: "gmail", label: "gmail" }], integrations: [], @@ -398,11 +564,13 @@ describe("agent builder draft input", () => { description: "Prepare a renewal call brief.", instructions: "Run manually. Read workspace deals and summarize renewal risks for review.", - trigger: { - type: "MANUAL", - name: "Prepare renewal brief", - summary: "Run before a renewal call", - }, + triggers: [ + { + type: "MANUAL", + name: "Prepare renewal brief", + summary: "Run before a renewal call", + }, + ], recordScope: "WORKSPACE", resources: [], integrations: ["crm"], @@ -417,6 +585,165 @@ describe("agent builder draft input", () => { ).toBe(false); }); + it("keeps the approved Slack destination and its resolution mode", () => { + const parsed = builderDraftToolInput.parse({ + name: "Demo welcome", + description: "Tell sales when a demo is booked.", + instructions: + "When a demo is booked, post the approved summary to the chosen sales channel and stop after one message.", + triggers: [ + { + type: "MANUAL", + name: "Welcome demo", + summary: "Run for a booked demo", + }, + ], + recordScope: "WORKSPACE", + resources: [], + integrations: ["slack"], + actions: [ + { + type: "slack.message.post", + provider: "slack", + summary: "Tell sales the prospect arrived", + destination: { + kind: "channel", + resolution: "chosen", + id: "C123", + label: "#sales", + }, + }, + ], + }); + + expect(draftInputFromTool(parsed)).toMatchObject({ + resources: [ + { kind: "integration", id: "slack:workspace", label: "Slack" }, + ], + actions: [ + { + type: "slack.message.post", + destination: { + kind: "channel", + resolution: "chosen", + id: "C123", + label: "#sales", + }, + }, + ], + access: [ + "Read workspace CRM records", + "Post to approved Slack destinations", + ], + }); + }); + + it("keeps an approved Slack person destination", () => { + const parsed = builderDraftToolInput.parse({ + name: "Deal alert", + description: "Tell Grim when a deal is created.", + instructions: + "When a deal is created, send one direct Slack message to the approved workspace member and stop.", + triggers: [ + { + type: "MANUAL", + name: "New deal alert", + summary: "Run for every new deal", + }, + ], + recordScope: "WORKSPACE", + resources: [], + integrations: ["slack"], + actions: [ + { + type: "slack.message.post", + provider: "slack", + summary: "Direct message Grim", + destination: { + kind: "user", + resolution: "chosen", + id: "U123", + label: "@grim", + }, + }, + ], + }); + + expect(draftInputFromTool(parsed).actions[0]).toMatchObject({ + type: "slack.message.post", + destination: { + kind: "user", + resolution: "chosen", + id: "U123", + label: "@grim", + }, + }); + }); + + it("rejects Slack destinations outside the inspected choices", () => { + const connections = { + slackChannels: [{ id: "C123", label: "#sales", memberCount: 8 }], + slackPeople: [ + { + id: "U123", + label: "@grim", + name: "Grim", + email: "grim@example.com", + slackEmail: "grim@example.com", + }, + ], + }; + + expect( + slackDestinationIssues( + { + kind: "user", + resolution: "chosen", + id: "U999", + label: "@invented", + }, + connections, + ), + ).toEqual(["The Slack person is not available to this workspace."]); + expect( + slackDestinationIssues( + { + kind: "user", + resolution: "chosen", + id: "U123", + label: "@wrong", + }, + connections, + ), + ).toEqual(["The Slack person must use its exact inspected label."]); + }); + + it("requires the Slack integration for a Slack post action", () => { + const actions: DraftAction[] = [ + { type: "run.summary", provider: "crm", summary: "Report the run" }, + { + type: "slack.message.post", + provider: "slack", + summary: "Direct message Grim", + destination: { + kind: "user", + resolution: "chosen", + id: "U123", + label: "@grim", + }, + }, + ]; + + expect(actionIntegrationIssues(actions, [])).toEqual([ + "Posting to Slack needs Slack in this agent's integrations.", + ]); + expect( + actionIntegrationIssues(actions, [ + { kind: "integration", id: "slack:workspace", label: "Slack" }, + ]), + ).toEqual([]); + }); + it("trims trigger metadata and rejects blank text", () => { const base = { name: "Renewal prep", @@ -438,26 +765,74 @@ describe("agent builder draft input", () => { expect( builderDraftToolInput.safeParse({ ...base, - trigger: { - type: "MANUAL", - name: " ", - summary: "Run before a renewal call", - }, + triggers: [ + { + type: "MANUAL", + name: " ", + summary: "Run before a renewal call", + }, + ], }).success, ).toBe(false); const parsed = builderDraftToolInput.parse({ ...base, - trigger: { - type: "MANUAL", - name: " Prepare renewal brief ", - summary: " Run before a renewal call ", - }, + triggers: [ + { + type: "MANUAL", + name: " Prepare renewal brief ", + summary: " Run before a renewal call ", + }, + ], }); - expect(parsed.trigger.name).toBe("Prepare renewal brief"); - expect(parsed.trigger.summary).toBe("Run before a renewal call"); + expect(parsed.triggers[0]?.name).toBe("Prepare renewal brief"); + expect(parsed.triggers[0]?.summary).toBe("Run before a renewal call"); expect(parsed.actions[0]?.summary).toBe("Write a reviewable renewal brief"); }); + + it("accepts multiple supported CRM events without a polling schedule", () => { + const parsed = builderDraftToolInput.parse({ + name: "Closed deal alert", + description: "Alert the team whenever a deal closes.", + instructions: + "When the triggering deal closes, read that deal, prepare one concise alert, and stop after the approved action.", + triggers: [ + { + type: "EVENT", + name: "When a deal is created", + summary: "Runs when a deal is created", + event: "deal.created", + }, + { + type: "EVENT", + name: "When a deal opens", + summary: "Runs when a closed deal returns to the open pipeline", + event: "deal.opened", + }, + { + type: "EVENT", + name: "When a deal closes", + summary: "Runs when a deal first enters a closed stage", + event: "deal.closed", + }, + ], + recordScope: "WORKSPACE", + resources: [], + integrations: [], + actions: [ + { + type: "run.summary", + provider: "crm", + summary: "Record the closed-deal alert", + }, + ], + }); + + expect(draftInputFromTool(parsed).triggers).toHaveLength(3); + expect( + draftInputFromTool(parsed).triggers.map((trigger) => trigger.event), + ).toEqual(["deal.created", "deal.opened", "deal.closed"]); + }); }); describe("agent builder draft access", () => { @@ -467,11 +842,13 @@ describe("agent builder draft access", () => { description: "Hand new customers to onboarding.", instructions: "Run on demand. Read closed-won deals and record the handoff for onboarding.", - trigger: { - type: "MANUAL", - name: "Run handoff", - summary: "Run for new customers", - }, + triggers: [ + { + type: "MANUAL", + name: "Run handoff", + summary: "Run for new customers", + }, + ], recordScope: "WORKSPACE", resources: [], integrations: [], diff --git a/apps/agent/test/dispatch-health.spec.ts b/apps/agent/test/dispatch-health.spec.ts new file mode 100644 index 000000000..bd42ca172 --- /dev/null +++ b/apps/agent/test/dispatch-health.spec.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from "bun:test"; +import { settledWithin } from "../agent/lib/deadline"; +import { + DRAIN_TIMEOUT_MS, + dispatchHealth, + linkSession, + runDirect, +} from "../agent/lib/dispatch"; +import { collapsing } from "../agent/lib/pool"; +import type { LeasedTask } from "../agent/lib/tasks"; + +describe("dispatch wedging", () => { + it("swallows every later call while a sweep never settles", async () => { + const gate = deferred(); + let runs = 0; + + const never = collapsing(async () => { + runs += 1; + await gate.promise; + }); + + const first = never(); + let second = "pending"; + const later = never().then(() => { + second = "resolved"; + }); + + expect(runs).toBe(1); + expect(second).toBe("pending"); + + gate.release(); + await Promise.all([first, later]); + + expect(second).toBe("resolved"); + expect(runs).toBe(2); + }); + + it("accepts the next sweep once a hung one is abandoned", async () => { + const abandon = deferred(); + const finish = deferred(); + const gates = [abandon, finish]; + let runs = 0; + + const timed = collapsing(async () => { + const gate = gates[runs]; + runs += 1; + await gate?.promise; + if (runs === 1) throw new Error("abandoned"); + }); + + const hung = timed(); + abandon.release(); + await expect(hung).rejects.toThrow("abandoned"); + + let after = "pending"; + const next = timed().then(() => { + after = "recovered"; + }); + finish.release(); + await next; + + expect(after).toBe("recovered"); + expect(runs).toBe(2); + }); + + it("reports health before any sweep has run", () => { + const health = dispatchHealth(); + + expect(health.running).toBe(false); + expect(health.abandonedSweeps).toBe(0); + expect(health.unsettledSweeps).toBe(0); + expect(health.pendingStarts).toBe(0); + expect(health.pendingItems).toBe(0); + expect(DRAIN_TIMEOUT_MS).toBeGreaterThan(0); + }); +}); + +function deferred() { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +async function flush(): Promise { + await new Promise((resolve) => setTimeout(resolve, 5)); +} + +function directTask(overrides: Partial = {}): LeasedTask { + return { + id: "task_direct", + contactId: null, + companyId: null, + dealId: null, + kind: "brand", + reason: "A new company", + payload: null, + budget: 0, + attempts: 1, + priority: 900, + dueAt: new Date(), + ...overrides, + }; +} + +describe("a direct task that outruns its item deadline", () => { + it("keeps the work in flight and lets the late result settle it once", async () => { + const gate = deferred(); + const outcomes: string[] = []; + + await runDirect( + directTask(), + async (task) => { + await gate.promise; + outcomes.push(task.id); + }, + 10, + ); + + expect(outcomes).toEqual([]); + expect(dispatchHealth().pendingItems).toBe(1); + + gate.release(); + await flush(); + + expect(outcomes).toEqual(["task_direct"]); + expect(dispatchHealth().pendingItems).toBe(0); + }); + + it("counts nothing in flight when the work lands inside the deadline", async () => { + let handled = 0; + + await runDirect( + directTask(), + async () => { + handled += 1; + }, + 1_000, + ); + + expect(handled).toBe(1); + expect(dispatchHealth().pendingItems).toBe(0); + }); + + it("holds a late failure in flight until it lands, then reconciles it", async () => { + const gate = deferred(); + let raised = false; + + await runDirect( + directTask(), + async () => { + await gate.promise; + raised = true; + throw new Error("the vendor refused"); + }, + 10, + ); + + expect(raised).toBe(false); + expect(dispatchHealth().pendingItems).toBe(1); + + gate.release(); + await flush(); + + expect(raised).toBe(true); + expect(dispatchHealth().pendingItems).toBe(0); + }); + + it("settles a failure inside the deadline without leaving it in flight", async () => { + await runDirect( + directTask(), + async () => { + throw new Error("the vendor refused"); + }, + 1_000, + ); + + expect(dispatchHealth().pendingItems).toBe(0); + }); +}); + +describe("recording the session a research task accepted", () => { + const link = { attempts: 3, retryMs: 1 }; + + it("keeps trying until the write lands, so the task holds its session", async () => { + const seen: string[] = []; + let failures = 2; + + const linked = await linkSession( + directTask({ id: "task_research", kind: "identify" }), + "ses_1", + async (taskId, sessionId) => { + seen.push(`${taskId}:${sessionId}`); + if (failures > 0) { + failures -= 1; + throw new Error("the database refused"); + } + }, + link, + ); + + expect(linked).toBe(true); + expect(seen).toHaveLength(3); + }); + + it("counts a session it could never record rather than losing it", async () => { + const before = dispatchHealth().unlinkedSessions; + + const linked = await linkSession( + directTask({ id: "task_research", kind: "identify" }), + "ses_2", + async () => { + throw new Error("the database refused"); + }, + link, + ); + + expect(linked).toBe(false); + expect(dispatchHealth().unlinkedSessions).toBe(before + 1); + }); +}); + +describe("settledWithin", () => { + it("hands back the value of work that finished inside the deadline", async () => { + const outcome = await settledWithin(Promise.resolve("session"), 50); + + expect(outcome).toEqual({ settled: true, value: "session" }); + }); + + it("reports unsettled work without failing it, so a late send still lands", async () => { + let accept: (value: string) => void = () => {}; + const send = new Promise((resolve) => { + accept = resolve; + }); + + const outcome = await settledWithin(send, 20); + expect(outcome.settled).toBe(false); + + accept("session"); + await expect(send).resolves.toBe("session"); + }); + + it("observes a rejection that lands after the deadline, so none escapes", async () => { + let fail: (error: Error) => void = () => {}; + const send = new Promise((_, reject) => { + fail = reject; + }); + + const outcome = await settledWithin(send, 10); + expect(outcome.settled).toBe(false); + + fail(new Error("the send failed late")); + await flush(); + + await expect(send).rejects.toThrow("the send failed late"); + }); +}); diff --git a/apps/agent/test/drain.spec.ts b/apps/agent/test/drain.spec.ts index cecb3f586..f1ea31864 100644 --- a/apps/agent/test/drain.spec.ts +++ b/apps/agent/test/drain.spec.ts @@ -92,8 +92,10 @@ function task(overrides: Partial = {}): LeasedTask { id: "task_1", contactId: "contact_1", companyId: null, + dealId: null, kind: "identify", reason: "A new contact", + payload: null, budget: 4, attempts: 1, priority: 100, diff --git a/apps/agent/test/durable-agent-runtime.integration.spec.ts b/apps/agent/test/durable-agent-runtime.integration.spec.ts index 41663744f..669f10f7e 100644 --- a/apps/agent/test/durable-agent-runtime.integration.spec.ts +++ b/apps/agent/test/durable-agent-runtime.integration.spec.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import { db } from "@crm/db"; import type { SendFn } from "eve/channels"; import audit from "../agent/hooks/audit"; @@ -10,6 +10,7 @@ import { pendingAgentRunIds, pendingBuilderSubmissionIds, queueDueAgentRuns, + queueEventAgentRuns, } from "../agent/lib/custom-agent-dispatch"; import { createRunActivity, @@ -64,8 +65,20 @@ beforeAll(async () => { status: "DEPLOYED", instructions: "Create one approved CRM activity.", manifest: { + triggers: [ + { + type: "SCHEDULE", + name: "Every hour", + summary: "Run every hour", + config: { + nextRunAt: new Date().toISOString(), + intervalMinutes: 60, + }, + }, + ], dataScope: { mode: "SELECTED", + summary: "Only the selected durable runtime company", resources: [ { kind: "company", @@ -75,8 +88,17 @@ beforeAll(async () => { ], }, actions: [ - { type: "crm.activity.create", activityTypes: ["NOTE"] }, - { type: "run.summary" }, + { + type: "crm.activity.create", + provider: "crm", + summary: "Create a CRM note", + activityTypes: ["NOTE"], + }, + { + type: "run.summary", + provider: "crm", + summary: "Summarize the run", + }, ], }, modelId: "test/model", @@ -108,6 +130,22 @@ beforeAll(async () => { triggerId = trigger.id; }); +afterEach(async () => { + if (!agentId) return; + await db.agentRun.updateMany({ + where: { + agentId, + status: { in: ["QUEUED", "RUNNING", "WAITING_FOR_APPROVAL"] }, + }, + data: { + status: "CANCELLED", + errorCode: "TEST_CLEANUP", + errorMessage: "Settled between tests.", + finishedAt: new Date(), + }, + }); +}); + afterAll(async () => { if (builderConversationIds.length > 0) { await db.agentConversation.deleteMany({ @@ -143,12 +181,13 @@ async function createRun( status: "QUEUED" | "RUNNING" = "RUNNING", startedAt: Date | null = new Date(), sessionId: string | null = null, + triggerType: "MANUAL" | "EVENT" = "MANUAL", ) { return db.agentRun.create({ data: { agentId, versionId, - triggerType: "MANUAL", + triggerType, status, startedAt, sessionId, @@ -160,7 +199,70 @@ async function createRun( }); } +async function satisfyRequiredActivity(runId: string, callId: string) { + return createRunActivity(runId, callId, { + type: "NOTE", + targetKind: "company", + targetId: companyId, + subject: "Runtime test", + body: "Completed the required action.", + }); +} + describe("durable custom-agent runtime", () => { + it("queues one run per matching event trigger and event occurrence", async () => { + const trigger = await db.agentTrigger.create({ + data: { + agentId, + versionId, + type: "EVENT", + name: "When a deal closes", + config: { event: "deal.closed" }, + createdById: userId, + enabled: true, + }, + select: { id: true }, + }); + const occurredAt = new Date().toISOString(); + const task = { + id: `event-task-${suffix}`, + contactId: null, + companyId: null, + dealId: `event-deal-${suffix}`, + payload: { + type: "deal.closed", + record: { kind: "deal", id: `event-deal-${suffix}` }, + occurredAt, + data: { from: "NEGOTIATION", to: "CLOSED_WON" }, + }, + }; + + await Promise.all([ + queueEventAgentRuns(task), + queueEventAgentRuns(task), + queueEventAgentRuns(task), + ]); + + const runs = await db.agentRun.findMany({ + where: { triggerId: trigger.id }, + select: { triggerType: true, status: true, input: true }, + }); + expect(runs).toEqual([ + { + triggerType: "EVENT", + status: "QUEUED", + input: { + event: { + type: "deal.closed", + occurredAt, + data: { from: "NEGOTIATION", to: "CLOSED_WON" }, + }, + record: { kind: "deal", id: `event-deal-${suffix}` }, + }, + }, + ]); + }); + it("advances a due trigger only when its run is committed", async () => { const now = new Date(); const results = await Promise.all( @@ -193,7 +295,7 @@ describe("durable custom-agent runtime", () => { db.agentRun.findUniqueOrThrow({ where: { id: recoverable.id } }), db.agentRun.findUniqueOrThrow({ where: { id: active.id } }), ]); - expect(pending).toContain(recoverable.id); + expect(pending).not.toContain(recoverable.id); expect(recovered).toMatchObject({ status: "QUEUED", startedAt: null }); expect( await db.agentRunEvent.count({ @@ -230,6 +332,41 @@ describe("durable custom-agent runtime", () => { }); }); + it("runs only one turn per agent while leaving later work queued", async () => { + const [first, second] = await Promise.all([ + createRun("QUEUED", null), + createRun("QUEUED", null), + ]); + const deliveries: string[] = []; + const send = (async (message: string) => { + deliveries.push(message); + return { id: `durable-session-${suffix}-serialized` }; + }) as unknown as SendFn; + + const results = await Promise.allSettled([ + dispatchAgentRun(first.id, send), + dispatchAgentRun(second.id, send), + ]); + + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === "rejected"), + ).toHaveLength(1); + expect(deliveries).toHaveLength(1); + expect( + ( + await db.agentRun.findMany({ + where: { id: { in: [first.id, second.id] } }, + select: { status: true }, + }) + ) + .map((run) => run.status) + .sort(), + ).toEqual(["QUEUED", "RUNNING"]); + }); + it("restores a builder continuation when a delivery lease expires", async () => { const conversation = await db.agentConversation.create({ data: { @@ -491,6 +628,7 @@ describe("durable custom-agent runtime", () => { it("lets the first terminal state win without duplicate terminal logs", async () => { const [completed, failed] = await Promise.all([createRun(), createRun()]); + await satisfyRequiredActivity(completed.id, "terminal-success"); await finishRun(completed.id, { summary: "Completed safely" }); await failRun(completed.id, "LATE_FAILURE", "This arrived late"); await failRun(failed.id, "FIRST_FAILURE", "Failed safely"); @@ -548,6 +686,7 @@ describe("durable custom-agent runtime", () => { }), ).toBe(0); + await satisfyRequiredActivity(run.id, "staged-success"); await finishRun(run.id, { summary: staged.summary ?? "Staged safely", result: staged.result as Record, @@ -557,6 +696,82 @@ describe("durable custom-agent runtime", () => { ).toMatchObject({ status: "SUCCEEDED", summary: "Staged safely" }); }); + it("fails a run that never performs its declared external action", async () => { + const run = await createRun(); + const finished = await finishRun(run.id, { + summary: "Claimed completion without acting", + }); + + expect(finished).toEqual({ id: run.id, status: "FAILED" }); + expect( + await db.agentRun.findUniqueOrThrow({ where: { id: run.id } }), + ).toMatchObject({ + status: "FAILED", + errorCode: "ACTION_NOT_PERFORMED", + }); + expect( + await db.agentAction.findUniqueOrThrow({ + where: { + idempotencyKey: `run:${run.id}:required:crm.activity.create`, + }, + }), + ).toMatchObject({ + status: "FAILED", + errorCode: "ACTION_NOT_PERFORMED", + }); + }); + + it("refuses a self-reported no-action ending on a manual run", async () => { + const run = await createRun(); + let stageError: Error | null = null; + try { + await stageRunResult(run.id, { + summary: "Nothing to do", + noActionNeeded: { reason: "The condition was not met." }, + }); + } catch (error) { + stageError = error as Error; + } + expect(stageError?.message).toContain( + "manual run cannot end with no action needed", + ); + + const finished = await finishRun(run.id, { + summary: "Nothing to do", + result: { noActionNeeded: "The condition was not met." }, + }); + expect(finished).toEqual({ id: run.id, status: "FAILED" }); + expect( + await db.agentRun.findUniqueOrThrow({ where: { id: run.id } }), + ).toMatchObject({ status: "FAILED", errorCode: "ACTION_NOT_PERFORMED" }); + expect( + await db.agentRunEvent.count({ + where: { runId: run.id, type: "run.completed" }, + }), + ).toBe(0); + }); + + it("accepts a no-action ending on an event run whose condition was not met", async () => { + const run = await createRun("RUNNING", new Date(), null, "EVENT"); + await stageRunResult(run.id, { + summary: "The deal was already closed", + noActionNeeded: { reason: "The condition was not met." }, + }); + const staged = await db.agentRun.findUniqueOrThrow({ + where: { id: run.id }, + }); + expect(staged.result).toMatchObject({ + noActionNeeded: "The condition was not met.", + }); + + const finished = await finishRun(run.id, { + summary: staged.summary ?? "The deal was already closed", + result: staged.result as Record, + }); + expect(finished).toEqual({ id: run.id, status: "SUCCEEDED" }); + expect(await db.agentAction.count({ where: { runId: run.id } })).toBe(0); + }); + it("claims an approved CRM action once and rejects scope before target access", async () => { const run = await createRun(); const input = { diff --git a/apps/agent/test/e2e/dispatch.e2e.ts b/apps/agent/test/e2e/dispatch.e2e.ts new file mode 100644 index 000000000..10e0db9e7 --- /dev/null +++ b/apps/agent/test/e2e/dispatch.e2e.ts @@ -0,0 +1,226 @@ +import { db } from "@crm/db"; +import { PRIORITY } from "@crm/db/agent-tasks"; +import { runVisibleLane } from "../../agent/lib/dispatch"; +import { + reasonOf, + removeAgent, + removeAgentsNamed, + removeEventRuns, +} from "./e2e-agents"; +import { E2E } from "./e2e-config"; + +const results: Array<{ name: string; ok: boolean; detail: string }> = []; + +function record(name: string, ok: boolean, detail: string) { + results.push({ name, ok, detail }); + console.log(`${ok ? "PASS" : "FAIL"} ${name} — ${detail}`); +} + +async function seedAgent() { + const owner = await db.user.findFirstOrThrow({ select: { id: true } }); + const agent = await db.agentDefinition.create({ + data: { + name: `${E2E.dispatch.agentPrefix} ${Date.now()}`, + description: "Seeded by the dispatch E2E. Safe to delete.", + status: "LIVE", + createdById: owner.id, + }, + select: { id: true }, + }); + const version = await db.agentVersion.create({ + data: { + agentId: agent.id, + number: 1, + status: "DEPLOYED", + instructions: "Dispatch test agent. Does nothing.", + modelId: "test/model", + sandboxPolicy: {}, + createdById: owner.id, + manifest: { + description: "dispatch test", + triggers: [ + { + type: "EVENT", + name: "Deal created", + summary: "Fires on deal creation", + config: { event: "deal.created" }, + }, + ], + dataScope: { mode: "WORKSPACE", summary: "Workspace", resources: [] }, + actions: [ + { type: "run.summary", provider: "crm", summary: "Log the result" }, + ], + }, + }, + select: { id: true }, + }); + await db.agentDefinition.update({ + where: { id: agent.id }, + data: { currentVersionId: version.id }, + }); + await db.agentTrigger.create({ + data: { + agentId: agent.id, + versionId: version.id, + type: "EVENT", + name: "Deal created", + config: { event: "deal.created" }, + enabled: true, + createdById: owner.id, + }, + }); + return { agentId: agent.id }; +} + +async function seedDeal() { + const stamp = Date.now(); + const company = await db.company.create({ + data: { + name: `${E2E.dispatch.companyPrefix} ${stamp}`, + domain: `${E2E.dispatch.domainPrefix}${stamp}${E2E.dispatch.domainSuffix}`, + }, + select: { id: true }, + }); + const owner = await db.user.findFirstOrThrow({ select: { id: true } }); + return db.deal.create({ + data: { + name: `E2E Deal ${Date.now()}`, + companyId: company.id, + ownerId: owner.id, + stage: "DEMO_BOOKED", + stageChangedAt: new Date(), + }, + select: { id: true, companyId: true }, + }); +} + +async function sweepLeftovers() { + for (const name of await removeAgentsNamed(E2E.dispatch.agentPrefix)) { + console.log(` removed leftover ${name}`); + } + + const stale = await db.company.findMany({ + where: { + domain: { + startsWith: E2E.dispatch.domainPrefix, + endsWith: E2E.dispatch.domainSuffix, + }, + name: { startsWith: E2E.dispatch.companyPrefix }, + }, + select: { id: true, name: true }, + }); + + for (const company of stale) { + const deals = await db.deal.findMany({ + where: { companyId: company.id }, + select: { id: true }, + }); + await db.agentTask.deleteMany({ + where: { dealId: { in: deals.map((deal) => deal.id) } }, + }); + await db.deal.deleteMany({ where: { companyId: company.id } }); + await db.company.delete({ where: { id: company.id } }); + console.log(` removed leftover ${company.name}`); + } +} + +async function cleanUp( + agentId: string | null, + companyId: string | null, + dealId: string | null, + taskId: string | null, +) { + await removeEventRuns(taskId ? [taskId] : []); + if (agentId) await removeAgent(agentId); + if (dealId) await db.agentTask.deleteMany({ where: { dealId } }); + if (!companyId) return; + await db.deal.deleteMany({ where: { companyId } }); + await db.company.delete({ where: { id: companyId } }); +} + +async function main() { + await sweepLeftovers(); + + let agentId: string | null = null; + let companyId: string | null = null; + let dealId: string | null = null; + let taskId: string | null = null; + + try { + const seededAgent = await seedAgent(); + agentId = seededAgent.agentId; + const deal = await seedDeal(); + companyId = deal.companyId; + dealId = deal.id; + const task = await db.agentTask.create({ + data: { + dealId: deal.id, + kind: "agent-event", + reason: "deal.created", + payload: { + type: "deal.created", + record: { kind: "deal", id: deal.id }, + occurredAt: new Date().toISOString(), + data: { companyId: deal.companyId, stage: "DEMO_BOOKED" }, + }, + priority: PRIORITY.event, + budget: 1, + dueAt: new Date(), + }, + select: { id: true }, + }); + taskId = task.id; + + const before = await db.agentRun.count(); + const handled = await runVisibleLane(); + const after = await db.agentRun.count(); + + record( + "drain claims a queued event", + handled > 0, + `visible lane handled ${handled} task(s)`, + ); + + const settled = await db.agentTask.findUnique({ + where: { id: task.id }, + select: { finishedAt: true, outcome: true }, + }); + record( + "drain settles the task", + Boolean(settled?.finishedAt), + settled?.outcome ?? "not settled", + ); + + const live = await db.agentDefinition.count({ where: { status: "LIVE" } }); + record( + "event matched against live agents", + after > before, + `${after - before} run(s) queued from ${live} live agent(s)`, + ); + + const stuck = await db.agentTask.count({ + where: { kind: "agent-event", finishedAt: null }, + }); + record("no agent-event backlog", stuck === 0, `${stuck} unclaimed`); + } finally { + const failure = await cleanUp(agentId, companyId, dealId, taskId).then( + () => null, + (error: unknown) => reasonOf(error), + ); + record( + "cleanup leaves no seeded rows", + failure === null, + failure ?? "agent, run, task, deal and company removed", + ); + } + + const failed = results.filter((row) => !row.ok).length; + console.log( + failed === 0 + ? `\nAll ${results.length} dispatch checks passed.` + : `\n${failed} of ${results.length} dispatch checks failed.`, + ); + process.exit(failed === 0 ? 0 : 1); +} + +await main(); diff --git a/apps/agent/test/e2e/e2e-agents.ts b/apps/agent/test/e2e/e2e-agents.ts new file mode 100644 index 000000000..5bb131ff2 --- /dev/null +++ b/apps/agent/test/e2e/e2e-agents.ts @@ -0,0 +1,54 @@ +import { db } from "@crm/db"; + +export async function removeAgent(agentId: string): Promise { + await db.agentRunEvent.deleteMany({ where: { run: { agentId } } }); + await db.agentAction.deleteMany({ where: { agentId } }); + await db.agentAuditEvent.deleteMany({ where: { agentId } }); + await db.agentRun.deleteMany({ where: { agentId } }); + await db.agentTrigger.deleteMany({ where: { agentId } }); + await db.agentDefinition.update({ + where: { id: agentId }, + data: { currentVersionId: null }, + }); + await db.agentVersion.deleteMany({ where: { agentId } }); + await db.agentDefinition.delete({ where: { id: agentId } }); +} + +export async function removeAgentsNamed(prefix: string): Promise { + const stale = await db.agentDefinition.findMany({ + where: { name: { startsWith: prefix } }, + select: { id: true, name: true }, + }); + + for (const agent of stale) await removeAgent(agent.id); + + return stale.map((agent) => agent.name); +} + +export async function removeEventRuns( + taskIds: readonly string[], +): Promise { + if (taskIds.length === 0) return 0; + + const runs = await db.agentRun.findMany({ + where: { + OR: taskIds.map((taskId) => ({ + idempotencyKey: { startsWith: `event:${taskId}:` }, + })), + }, + select: { id: true }, + }); + const runIds = runs.map((run) => run.id); + if (runIds.length === 0) return 0; + + await db.agentRunEvent.deleteMany({ where: { runId: { in: runIds } } }); + await db.agentAction.deleteMany({ where: { runId: { in: runIds } } }); + await db.agentAuditEvent.deleteMany({ where: { requestId: { in: runIds } } }); + await db.agentRun.deleteMany({ where: { id: { in: runIds } } }); + + return runIds.length; +} + +export function reasonOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/agent/test/e2e/e2e-config.ts b/apps/agent/test/e2e/e2e-config.ts new file mode 100644 index 000000000..233ee25c5 --- /dev/null +++ b/apps/agent/test/e2e/e2e-config.ts @@ -0,0 +1,43 @@ +const SECOND_MS = 1_000; +const MINUTE_MS = 60 * SECOND_MS; + +export const E2E = { + dispatch: { + agentPrefix: "E2E Dispatch Agent", + companyPrefix: "E2E Co", + domainPrefix: "e2e-", + domainSuffix: ".test", + }, + + load: { + agentPrefix: "E2E Load Agent", + companyPrefix: "Load Co", + domainPrefix: "load-", + domainSuffix: ".test", + defaultCount: 300, + drainPassSlack: 5, + }, + + retry: { + companyPrefix: "E2E Retry Co", + kind: "e2e-retry", + reason: "e2e.retry", + priority: 900, + claimLimit: 1, + leaseMs: 5 * MINUTE_MS, + expiredLeaseMs: MINUTE_MS, + holdBackMs: 30 * SECOND_MS, + }, + + slackJoin: { + bogusChannelId: "C00NOTREAL99", + bogusChannelName: "missing", + }, + + liveRun: { + agentPrefix: "E2E Live Agent", + agentUrl: "http://localhost:3010", + pollMs: 5 * SECOND_MS, + giveUpMs: 5 * MINUTE_MS, + }, +} as const; diff --git a/apps/agent/test/e2e/live-run.e2e.ts b/apps/agent/test/e2e/live-run.e2e.ts new file mode 100644 index 000000000..c6f5484c1 --- /dev/null +++ b/apps/agent/test/e2e/live-run.e2e.ts @@ -0,0 +1,189 @@ +import { db } from "@crm/db"; +import { reasonOf, removeAgent } from "./e2e-agents"; +import { E2E } from "./e2e-config"; + +const AGENT_URL = process.env.AGENT_URL ?? E2E.liveRun.agentUrl; +const SECRET = process.env.AGENT_BRIDGE_SECRET?.trim(); + +if (process.env.E2E_LIVE_MODEL !== "1") { + console.log( + "SKIP live model run — set E2E_LIVE_MODEL=1 to spend credits on this.", + ); + process.exit(0); +} + +async function seedAgent( + channelId: string, + channelName: string, + modelId: string, +) { + const owner = await db.user.findFirstOrThrow({ select: { id: true } }); + const agent = await db.agentDefinition.create({ + data: { + name: `${E2E.liveRun.agentPrefix} ${Date.now()}`, + description: "Seeded by the live run E2E. Safe to delete.", + status: "LIVE", + createdById: owner.id, + }, + select: { id: true }, + }); + const version = await db.agentVersion.create({ + data: { + agentId: agent.id, + number: 1, + status: "DEPLOYED", + instructions: `Post exactly one short Slack message to #${channelName} that says "E2E live run — ignore this message." Do nothing else.`, + modelId, + sandboxPolicy: {}, + createdById: owner.id, + manifest: { + description: "live run", + triggers: [ + { + type: "MANUAL", + name: "Run now", + summary: "Started by a person", + config: {}, + }, + ], + dataScope: { mode: "WORKSPACE", summary: "Workspace", resources: [] }, + actions: [ + { + type: "slack.message.post", + provider: "slack", + summary: `Post one message to #${channelName}`, + destination: { + kind: "channel", + resolution: "chosen", + id: channelId, + label: `#${channelName}`, + }, + }, + ], + }, + }, + select: { id: true }, + }); + await db.agentDefinition.update({ + where: { id: agent.id }, + data: { currentVersionId: version.id }, + }); + return { agentId: agent.id, versionId: version.id, ownerId: owner.id }; +} + +async function main() { + if (!SECRET) { + console.error("FAIL AGENT_BRIDGE_SECRET is not set. Cannot dispatch."); + process.exit(1); + } + + const channel = await db.slackChannel.findFirst({ + where: { available: true, name: "test" }, + select: { id: true, name: true }, + }); + if (!channel) { + console.error("FAIL No available #test channel. Connect Slack first."); + process.exit(1); + } + + const settings = await db.appSetting.findUnique({ + where: { id: "app" }, + select: { agentModelId: true }, + }); + if (!settings?.agentModelId) { + console.error( + "FAIL No model is configured. Pick one on the settings page first.", + ); + process.exit(1); + } + + const { agentId, versionId, ownerId } = await seedAgent( + channel.id, + channel.name, + settings.agentModelId, + ); + let ok = false; + + try { + const run = await db.agentRun.create({ + data: { + agentId, + versionId, + initiatedById: ownerId, + triggerType: "MANUAL", + idempotencyKey: crypto.randomUUID(), + correlationId: crypto.randomUUID(), + events: { create: { sequence: 0, type: "run.queued", data: {} } }, + }, + select: { id: true }, + }); + console.log(`Queued run ${run.id}. Dispatching…`); + + const dispatch = await fetch(`${AGENT_URL}/internal/crm/agent-dispatch`, { + method: "POST", + headers: { authorization: `Bearer ${SECRET}` }, + }); + if (dispatch.status !== 202) { + console.error(`FAIL Dispatch returned ${dispatch.status}.`); + } else { + const deadline = Date.now() + E2E.liveRun.giveUpMs; + let final: { status: string; errorCode: string | null } | null = null; + + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, E2E.liveRun.pollMs)); + const current = await db.agentRun.findUniqueOrThrow({ + where: { id: run.id }, + select: { status: true, errorCode: true, errorMessage: true }, + }); + if (["SUCCEEDED", "FAILED", "CANCELLED"].includes(current.status)) { + final = current; + break; + } + await fetch(`${AGENT_URL}/internal/crm/agent-dispatch`, { + method: "POST", + headers: { authorization: `Bearer ${SECRET}` }, + }).catch(() => {}); + console.log(` still ${current.status}…`); + } + + const actions = await db.agentAction.findMany({ + where: { runId: run.id }, + select: { + type: true, + status: true, + externalId: true, + errorMessage: true, + }, + }); + const events = await db.agentRunEvent.count({ where: { runId: run.id } }); + + console.log(`\n status ${final?.status ?? "never settled"}`); + console.log(` events ${events}`); + for (const action of actions) { + console.log( + ` action ${action.type} ${action.status} ${action.externalId ?? action.errorMessage ?? ""}`, + ); + } + + const posted = actions.some( + (action) => + action.type === "slack.message.post" && + action.status === "SUCCEEDED" && + Boolean(action.externalId), + ); + ok = final?.status === "SUCCEEDED" && posted; + } + } finally { + try { + await removeAgent(agentId); + } catch (error) { + ok = false; + console.error(` cleanup failed — ${reasonOf(error)}`); + } + } + + console.log(ok ? "\nPASS live model run" : "\nFAIL live model run"); + process.exit(ok ? 0 : 1); +} + +await main(); diff --git a/apps/agent/test/e2e/load.e2e.ts b/apps/agent/test/e2e/load.e2e.ts new file mode 100644 index 000000000..935c49913 --- /dev/null +++ b/apps/agent/test/e2e/load.e2e.ts @@ -0,0 +1,219 @@ +import { db } from "@crm/db"; +import { PRIORITY } from "@crm/db/agent-tasks"; +import { runVisibleLane, VISIBLE_BATCH } from "../../agent/lib/dispatch"; +import { + reasonOf, + removeAgent, + removeAgentsNamed, + removeEventRuns, +} from "./e2e-agents"; +import { E2E } from "./e2e-config"; + +const COUNT = Number(process.env.E2E_LOAD_COUNT ?? E2E.load.defaultCount); +const DRAIN_PASSES = Math.ceil(COUNT / VISIBLE_BATCH) + E2E.load.drainPassSlack; + +async function seedAgent() { + const owner = await db.user.findFirstOrThrow({ select: { id: true } }); + const agent = await db.agentDefinition.create({ + data: { + name: `${E2E.load.agentPrefix} ${Date.now()}`, + description: "Seeded for load testing. Safe to delete.", + status: "LIVE", + createdById: owner.id, + }, + select: { id: true }, + }); + const version = await db.agentVersion.create({ + data: { + agentId: agent.id, + number: 1, + status: "DEPLOYED", + instructions: "Load test agent. Does nothing.", + modelId: "test/model", + sandboxPolicy: {}, + createdById: owner.id, + manifest: { + description: "load test", + triggers: [ + { + type: "EVENT", + name: "Deal created", + summary: "Fires on deal creation", + config: { event: "deal.created" }, + }, + ], + dataScope: { mode: "WORKSPACE", summary: "Workspace", resources: [] }, + actions: [ + { type: "run.summary", provider: "crm", summary: "Log the result" }, + ], + }, + }, + select: { id: true }, + }); + await db.agentDefinition.update({ + where: { id: agent.id }, + data: { currentVersionId: version.id }, + }); + await db.agentTrigger.create({ + data: { + agentId: agent.id, + versionId: version.id, + type: "EVENT", + name: "Deal created", + config: { event: "deal.created" }, + enabled: true, + createdById: owner.id, + }, + }); + return { agentId: agent.id, versionId: version.id }; +} + +async function sweepLeftovers() { + for (const name of await removeAgentsNamed(E2E.load.agentPrefix)) { + console.log(` removed leftover ${name}`); + } + + const stale = await db.company.findMany({ + where: { + domain: { + startsWith: E2E.load.domainPrefix, + endsWith: E2E.load.domainSuffix, + }, + name: { startsWith: E2E.load.companyPrefix }, + }, + select: { id: true, name: true }, + }); + + for (const company of stale) { + const deals = await db.deal.findMany({ + where: { companyId: company.id }, + select: { id: true }, + }); + await db.agentTask.deleteMany({ + where: { dealId: { in: deals.map((deal) => deal.id) } }, + }); + await db.deal.deleteMany({ where: { companyId: company.id } }); + await db.company.delete({ where: { id: company.id } }); + console.log(` removed leftover ${company.name}`); + } +} + +async function cleanUp( + agentId: string | null, + companyId: string | null, + dealIds: string[], + taskIds: string[], +) { + await removeEventRuns(taskIds); + if (agentId) await removeAgent(agentId); + if (dealIds.length > 0) { + await db.agentTask.deleteMany({ where: { dealId: { in: dealIds } } }); + } + if (!companyId) return; + await db.deal.deleteMany({ where: { companyId } }); + await db.company.delete({ where: { id: companyId } }); +} + +async function main() { + await sweepLeftovers(); + + const dealIds: string[] = []; + const taskIds: string[] = []; + let agentId: string | null = null; + let companyId: string | null = null; + let ok = false; + + try { + const seededAgent = await seedAgent(); + agentId = seededAgent.agentId; + const owner = await db.user.findFirstOrThrow({ select: { id: true } }); + const company = await db.company.create({ + data: { + name: `Load Co ${Date.now()}`, + domain: `load-${Date.now()}.test`, + }, + select: { id: true }, + }); + companyId = company.id; + + console.log(`Seeding ${COUNT} deal.created events…`); + const seedStart = Date.now(); + const deals = await db.$transaction( + Array.from({ length: COUNT }, (_, index) => + db.deal.create({ + data: { + name: `Load Deal ${index}`, + companyId: company.id, + ownerId: owner.id, + stage: "DEMO_BOOKED", + stageChangedAt: new Date(), + }, + select: { id: true }, + }), + ), + ); + dealIds.push(...deals.map((deal) => deal.id)); + await db.agentTask.createMany({ + data: deals.map((deal) => ({ + dealId: deal.id, + kind: "agent-event" as const, + reason: "deal.created", + payload: { + type: "deal.created", + record: { kind: "deal", id: deal.id }, + occurredAt: new Date().toISOString(), + data: { companyId: company.id, stage: "DEMO_BOOKED" }, + }, + priority: PRIORITY.event, + budget: 1, + dueAt: new Date(), + })), + }); + const seeded = await db.agentTask.findMany({ + where: { dealId: { in: dealIds } }, + select: { id: true }, + }); + taskIds.push(...seeded.map((task) => task.id)); + console.log(` seeded in ${Date.now() - seedStart}ms`); + + console.log("Draining…"); + const drainStart = Date.now(); + let handled = 0; + for (let pass = 0; pass < DRAIN_PASSES; pass += 1) { + const done = await runVisibleLane(); + handled += done; + if (done === 0) break; + } + const drainMs = Date.now() - drainStart; + + const queued = await db.agentRun.count({ + where: { agentId: seededAgent.agentId }, + }); + const leftover = await db.agentTask.count({ + where: { kind: "agent-event", finishedAt: null }, + }); + + console.log(`\n tasks drained ${handled}`); + console.log(` runs queued ${queued}`); + console.log(` unclaimed left ${leftover}`); + console.log(` drain time ${drainMs}ms`); + console.log( + ` throughput ${Math.round((handled / drainMs) * 1000)}/s`, + ); + + ok = handled >= COUNT && leftover === 0 && queued >= COUNT; + } finally { + console.log("\nCleaning up…"); + try { + await cleanUp(agentId, companyId, dealIds, taskIds); + } catch (error) { + ok = false; + console.error(` cleanup failed — ${reasonOf(error)}`); + } + } + + console.log(ok ? "\nPASS load test" : "\nFAIL load test"); + process.exit(ok ? 0 : 1); +} + +await main(); diff --git a/apps/agent/test/e2e/retry.e2e.ts b/apps/agent/test/e2e/retry.e2e.ts new file mode 100644 index 000000000..a0bbbb7e8 --- /dev/null +++ b/apps/agent/test/e2e/retry.e2e.ts @@ -0,0 +1,147 @@ +import { db } from "@crm/db"; +import { MAX_ATTEMPTS, RETIRED_OUTCOME } from "@crm/db/agent-tasks"; +import { retireAbandoned } from "../../agent/lib/dispatch"; +import { claimDue } from "../../agent/lib/tasks"; +import { reasonOf } from "./e2e-agents"; +import { E2E } from "./e2e-config"; + +type HeldTask = { id: string; leasedUntil: Date | null }; + +const results: Array<{ name: string; ok: boolean; detail: string }> = []; + +function record(name: string, ok: boolean, detail: string) { + results.push({ name, ok, detail }); + console.log(`${ok ? "PASS" : "FAIL"} ${name} — ${detail}`); +} + +async function claimMine() { + return claimDue( + E2E.retry.claimLimit, + { only: [E2E.retry.kind] }, + E2E.retry.leaseMs, + ); +} + +async function expireLease(taskId: string) { + await db.agentTask.update({ + where: { id: taskId }, + data: { leasedUntil: new Date(Date.now() - E2E.retry.expiredLeaseMs) }, + }); +} + +async function holdBackOtherExhausted(taskId: string): Promise { + const now = new Date(); + const others = await db.agentTask.findMany({ + where: { + id: { not: taskId }, + finishedAt: null, + attempts: { gte: MAX_ATTEMPTS }, + OR: [{ leasedUntil: null }, { leasedUntil: { lt: now } }], + }, + select: { id: true, leasedUntil: true }, + }); + + if (others.length === 0) return []; + + await db.agentTask.updateMany({ + where: { id: { in: others.map((row) => row.id) } }, + data: { leasedUntil: new Date(Date.now() + E2E.retry.holdBackMs) }, + }); + console.log(` held back ${others.length} exhausted task(s) owned by nobody`); + + return others; +} + +async function releaseHeldBack(held: readonly HeldTask[]) { + for (const task of held) { + await db.agentTask.updateMany({ + where: { id: task.id }, + data: { leasedUntil: task.leasedUntil }, + }); + } +} + +async function main() { + const stamp = Date.now(); + const company = await db.company.create({ + data: { + name: `${E2E.retry.companyPrefix} ${stamp}`, + domain: `retry-${stamp}.test`, + }, + select: { id: true }, + }); + let held: HeldTask[] = []; + + try { + const task = await db.agentTask.create({ + data: { + companyId: company.id, + kind: E2E.retry.kind, + reason: E2E.retry.reason, + priority: E2E.retry.priority, + budget: 1, + dueAt: new Date(), + }, + select: { id: true }, + }); + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + const claimed = await claimMine(); + const mine = claimed.find((row) => row.id === task.id); + record( + `attempt ${attempt} is claimable`, + mine?.attempts === attempt, + mine ? `attempts=${mine.attempts}` : "not claimed", + ); + await expireLease(task.id); + } + + const afterLimit = await claimMine(); + record( + `no claim past ${MAX_ATTEMPTS} attempts`, + afterLimit.length === 0, + `${afterLimit.length} task(s) claimed`, + ); + + held = await holdBackOtherExhausted(task.id); + await retireAbandoned(); + + const retired = await db.agentTask.findUnique({ + where: { id: task.id }, + select: { finishedAt: true, outcome: true }, + }); + record( + "exhausted task is retired, not left queued", + Boolean(retired?.finishedAt) && retired?.outcome === RETIRED_OUTCOME, + retired?.outcome ?? "still queued", + ); + + const enrichment = await db.company.findUnique({ + where: { id: company.id }, + select: { enrichmentStatus: true, enrichmentError: true }, + }); + record( + "the record says why it gave up", + enrichment?.enrichmentStatus === "FAILED", + enrichment?.enrichmentError ?? "no reason recorded", + ); + } finally { + try { + await releaseHeldBack(held); + await db.agentTask.deleteMany({ where: { companyId: company.id } }); + await db.company.delete({ where: { id: company.id } }); + } catch (error) { + record("cleanup leaves nothing behind", false, reasonOf(error)); + } + } + + const failed = results.filter((row) => !row.ok).length; + console.log( + failed === 0 + ? `\nAll ${results.length} retry checks passed.` + : `\n${failed} of ${results.length} retry checks failed.`, + ); + process.exit(failed === 0 ? 0 : 1); +} + +await main(); diff --git a/apps/agent/test/e2e/slack-delivery.e2e.ts b/apps/agent/test/e2e/slack-delivery.e2e.ts new file mode 100644 index 000000000..fe7f62f99 --- /dev/null +++ b/apps/agent/test/e2e/slack-delivery.e2e.ts @@ -0,0 +1,110 @@ +import { db } from "@crm/db"; +import { sendSlackMessage } from "../../agent/lib/run-runtime"; +import { slackAccessToken } from "../../agent/lib/slack-connection"; + +type Case = { + name: string; + destination: { kind: "channel" | "user"; id: string; label: string }; + expect: "delivered" | "refused"; +}; + +const stamp = new Date().toISOString().slice(11, 19); + +async function main() { + const token = await slackAccessToken(); + if (!token) { + console.error("FAIL Slack is not connected. Nothing to test."); + process.exit(1); + } + + const channels = await db.slackChannel.findMany({ + select: { id: true, name: true }, + }); + const people = await db.slackMemberMatch.findMany({ + where: { slackUserId: { not: null } }, + select: { slackUserId: true, slackHandle: true }, + }); + + const testChannel = channels.find((row) => row.name === "test"); + const person = people[0]; + + const cases: Case[] = []; + + if (testChannel) { + cases.push({ + name: "channel message to a joined channel", + destination: { + kind: "channel", + id: testChannel.id, + label: `#${testChannel.name}`, + }, + expect: "delivered", + }); + } + + if (person?.slackUserId) { + cases.push({ + name: "direct message to a matched person", + destination: { + kind: "user", + id: person.slackUserId, + label: person.slackHandle ?? "@unknown", + }, + expect: "delivered", + }); + } + + const unjoined = channels.find((row) => row.name === "test2"); + if (unjoined) { + cases.push({ + name: "public channel the app has not joined", + destination: { + kind: "channel", + id: unjoined.id, + label: `#${unjoined.name}`, + }, + expect: "delivered", + }); + } + + cases.push({ + name: "channel that does not exist", + destination: { kind: "channel", id: "C00NOTREAL99", label: "#missing" }, + expect: "refused", + }); + + let failures = 0; + + for (const item of cases) { + const replayId = crypto.randomUUID(); + const text = `E2E ${stamp} — ${item.name}. Ignore this message.`; + + try { + const result = await sendSlackMessage( + token, + item.destination, + text, + replayId, + ); + const ok = item.expect === "delivered"; + console.log( + `${ok ? "PASS" : "FAIL"} ${item.name} — delivered ts=${result.ts}`, + ); + if (!ok) failures += 1; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + const ok = item.expect === "refused"; + console.log(`${ok ? "PASS" : "FAIL"} ${item.name} — refused: ${reason}`); + if (!ok) failures += 1; + } + } + + console.log( + failures === 0 + ? `\nAll ${cases.length} delivery cases behaved as expected.` + : `\n${failures} of ${cases.length} cases did not behave as expected.`, + ); + process.exit(failures === 0 ? 0 : 1); +} + +await main(); diff --git a/apps/agent/test/e2e/slack-join.e2e.ts b/apps/agent/test/e2e/slack-join.e2e.ts new file mode 100644 index 000000000..5874daf2a --- /dev/null +++ b/apps/agent/test/e2e/slack-join.e2e.ts @@ -0,0 +1,138 @@ +import { db } from "@crm/db"; +import { + slackCanInviteItself, + slackConnected, +} from "../../agent/lib/slack-connection"; +import { runSlackChannelJoin } from "../../agent/lib/slack-join-task"; +import { runSlackPeopleMatch } from "../../agent/lib/slack-people"; +import { E2E } from "./e2e-config"; + +const JOINS_FOR_REAL = process.env.E2E_SLACK_JOIN === "1"; +const SKIPPED = + "skipped: set E2E_SLACK_JOIN=1 to change the real Slack workspace"; + +const results: Array<{ name: string; ok: boolean; detail: string }> = []; + +function record(name: string, ok: boolean, detail: string) { + results.push({ name, ok, detail }); + console.log(`${ok ? "PASS" : "FAIL"} ${name} — ${detail}`); +} + +async function main() { + if (!(await slackConnected())) { + console.error("FAIL Slack is not connected. Nothing to test."); + process.exit(1); + } + + if (!JOINS_FOR_REAL) { + console.log( + "NOTE Comp AI joins no channel in this run. A join is permanent.", + ); + } + + console.log(await runSlackPeopleMatch()); + + const channels = await db.slackChannel.findMany({ + where: { available: true }, + select: { id: true, name: true, isPrivate: true, isMember: true }, + }); + const canInvite = await slackCanInviteItself(); + + record( + "inventory records privacy and membership", + channels.length > 0, + channels + .map( + (row) => + `${row.isPrivate ? "🔒" : "#"}${row.name}${row.isMember ? "[member]" : ""}`, + ) + .join(" "), + ); + + const publicUnjoined = channels.find( + (row) => !row.isPrivate && !row.isMember, + ); + if (!publicUnjoined) { + record( + "joins a public channel", + true, + "skipped: Comp AI is already in every public channel", + ); + } else if (!JOINS_FOR_REAL) { + record("joins a public channel", true, SKIPPED); + } else { + const outcome = await runSlackChannelJoin({ + type: "slack.channel.join", + channelId: publicUnjoined.id, + channelName: publicUnjoined.name, + }); + record("joins a public channel", outcome.includes("joined"), outcome); + } + + const privateUnjoined = channels.find( + (row) => row.isPrivate && !row.isMember, + ); + if (!privateUnjoined) { + record( + "private channel behaves as the grant allows", + true, + "skipped: no private channel is visible to Comp AI", + ); + } else if (!JOINS_FOR_REAL) { + record("private channel behaves as the grant allows", true, SKIPPED); + } else { + const outcome = await runSlackChannelJoin({ + type: "slack.channel.join", + channelId: privateUnjoined.id, + channelName: privateUnjoined.name, + }); + record( + "private channel behaves as the grant allows", + canInvite ? outcome.includes("joined") : outcome.includes("could not"), + outcome, + ); + } + + const bogus = await runSlackChannelJoin({ + type: "slack.channel.join", + channelId: E2E.slackJoin.bogusChannelId, + channelName: E2E.slackJoin.bogusChannelName, + }).catch((error: unknown) => + error instanceof Error ? error.message : String(error), + ); + record( + "a channel that does not exist is refused", + bogus.includes("could not") || bogus.includes("No such channel"), + bogus, + ); + + let rejected = ""; + try { + await runSlackChannelJoin({ type: "slack.channel.join" }); + } catch (error) { + rejected = error instanceof Error ? error.message : String(error); + } + record( + "an unreadable payload is a real error", + rejected.includes("unreadable payload"), + rejected || "no error thrown", + ); + + record( + "workspace grant present", + canInvite, + canInvite + ? "a user token lets Comp AI add itself to a private channel" + : "no user token, so Comp AI cannot add itself to a private channel", + ); + + const failed = results.filter((row) => !row.ok).length; + console.log( + failed === 0 + ? `\nAll ${results.length} join checks passed.` + : `\n${failed} of ${results.length} join checks failed.`, + ); + process.exit(failed === 0 ? 0 : 1); +} + +await main(); diff --git a/apps/agent/test/enrichment.integration.spec.ts b/apps/agent/test/enrichment.integration.spec.ts index 837664fdf..06eec5a0f 100644 --- a/apps/agent/test/enrichment.integration.spec.ts +++ b/apps/agent/test/enrichment.integration.spec.ts @@ -5,6 +5,7 @@ import { markRunning, settle } from "../agent/lib/enrichment"; const domain = "lifecycle.example.test"; async function clear() { + await db.agentTask.deleteMany({ where: { reason: "lifecycle" } }); await db.company.deleteMany({ where: { domain } }); await db.contact.deleteMany({ where: { email: { startsWith: "lifecycle-" } }, @@ -40,6 +41,25 @@ function subjectOf(ids: { contactId?: string; companyId?: string }) { }; } +async function retiredTask(companyId: string) { + const row = await db.company.findUniqueOrThrow({ + where: { id: companyId }, + select: { updatedAt: true }, + }); + + return db.agentTask.create({ + data: { + companyId, + kind: "company-profile", + reason: "lifecycle", + attempts: 3, + dueAt: row.updatedAt, + finishedAt: new Date(row.updatedAt.getTime() + 1), + }, + select: { id: true }, + }); +} + async function statusOfContact(id: string) { const row = await db.contact.findUnique({ where: { id }, @@ -125,6 +145,110 @@ describe("the record follows the task", () => { expect(row?.enrichmentError).toBeNull(); }); + it("records the failure of a task that was retired before it ran", async () => { + const org = await company(); + const task = await retiredTask(org.id); + + await settle( + { ...subjectOf({ companyId: org.id }), id: task.id }, + EnrichmentStatus.FAILED, + "Research was attempted several times and never completed.", + ); + + const row = await db.company.findUnique({ + where: { id: org.id }, + select: { enrichmentStatus: true }, + }); + expect(row?.enrichmentStatus).toBe("FAILED"); + }); + + it("leaves a fresh request alone when a retired task settles late", async () => { + const org = await company(); + const task = await retiredTask(org.id); + + await db.company.update({ + where: { id: org.id }, + data: { + enrichmentStatus: EnrichmentStatus.PENDING, + enrichmentError: null, + }, + }); + + await settle( + { ...subjectOf({ companyId: org.id }), id: task.id }, + EnrichmentStatus.FAILED, + "Research was attempted several times and never completed.", + ); + + const row = await db.company.findUnique({ + where: { id: org.id }, + select: { enrichmentStatus: true, enrichmentError: true }, + }); + expect(row?.enrichmentStatus).toBe("PENDING"); + expect(row?.enrichmentError).toBeNull(); + }); + + it("leaves a queued record alone when a task that never ended fails late", async () => { + const org = await company(); + const open = await db.agentTask.create({ + data: { + companyId: org.id, + kind: "company-profile", + reason: "lifecycle", + dueAt: new Date(), + }, + select: { id: true }, + }); + + await db.company.update({ + where: { id: org.id }, + data: { + enrichmentStatus: EnrichmentStatus.PENDING, + enrichmentError: null, + }, + }); + + await settle( + { ...subjectOf({ companyId: org.id }), id: open.id }, + EnrichmentStatus.FAILED, + "The agent turn failed.", + ); + + const row = await db.company.findUnique({ + where: { id: org.id }, + select: { enrichmentStatus: true, enrichmentError: true }, + }); + expect(row?.enrichmentStatus).toBe("PENDING"); + expect(row?.enrichmentError).toBeNull(); + }); + + it("leaves the record to a newer request when an ended task settles late", async () => { + const org = await company(); + const task = await retiredTask(org.id); + await db.agentTask.create({ + data: { + companyId: org.id, + kind: "recheck", + reason: "lifecycle", + dueAt: new Date(), + }, + select: { id: true }, + }); + + await settle( + { ...subjectOf({ companyId: org.id }), id: task.id }, + EnrichmentStatus.FAILED, + "Research was attempted several times and never completed.", + ); + + const row = await db.company.findUnique({ + where: { id: org.id }, + select: { enrichmentStatus: true, enrichmentError: true }, + }); + expect(row?.enrichmentStatus).toBe("PENDING"); + expect(row?.enrichmentError).toBeNull(); + }); + it("survives a record deleted while the agent was still reading about it", async () => { const person = await contact(); const subject = subjectOf({ contactId: person.id }); diff --git a/apps/agent/test/event-persistence.spec.ts b/apps/agent/test/event-persistence.spec.ts new file mode 100644 index 000000000..29314f2cc --- /dev/null +++ b/apps/agent/test/event-persistence.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "bun:test"; +import { isTransportOnlyEvent } from "../agent/lib/event-persistence"; + +describe("event persistence", () => { + it("never stores a streaming frame", () => { + expect(isTransportOnlyEvent("message.appended")).toBe(true); + expect(isTransportOnlyEvent("reasoning.appended")).toBe(true); + }); + + it("stores the completed record for the same stream", () => { + expect(isTransportOnlyEvent("message.completed")).toBe(false); + expect(isTransportOnlyEvent("reasoning.completed")).toBe(false); + }); + + it("stores semantic run events", () => { + for (const type of [ + "session.started", + "turn.started", + "turn.completed", + "step.completed", + "actions.requested", + "action.result", + "session.completed", + ]) { + expect(isTransportOnlyEvent(type)).toBe(false); + } + }); +}); diff --git a/apps/agent/test/keyless-brand.integration.spec.ts b/apps/agent/test/keyless-brand.integration.spec.ts index 31512c0e3..7b2902b35 100644 --- a/apps/agent/test/keyless-brand.integration.spec.ts +++ b/apps/agent/test/keyless-brand.integration.spec.ts @@ -16,8 +16,12 @@ import { settle } from "../agent/lib/enrichment"; * nothing to say so. */ const created: string[] = []; +const tasks: string[] = []; afterEach(async () => { + if (tasks.length > 0) { + await db.agentTask.deleteMany({ where: { id: { in: tasks.splice(0) } } }); + } if (created.length === 0) return; await db.company.deleteMany({ where: { id: { in: created.splice(0) } } }); }); @@ -36,6 +40,37 @@ async function company(status: EnrichmentStatus) { return row.id; } +const subjectOf = (companyId: string) => ({ + id: `keyless-${companyId}`, + kind: "brand", + contactId: null, + companyId, + dealId: null, +}); + +async function retiredSubjectOf(companyId: string) { + await db.$executeRaw` + UPDATE "company" + SET "updatedAt" = NOW() - INTERVAL '1 second' + WHERE id = ${companyId} + `; + + const row = await db.agentTask.create({ + data: { + companyId, + kind: "brand", + reason: "keyless", + attempts: 3, + dueAt: new Date(), + finishedAt: new Date(), + }, + select: { id: true }, + }); + + tasks.push(row.id); + return { ...subjectOf(companyId), id: row.id }; +} + const statusOf = async (id: string) => ( await db.company.findUnique({ @@ -49,7 +84,7 @@ describe("a brand task with no key", () => { const id = await company(EnrichmentStatus.PENDING); await settle( - { companyId: id }, + subjectOf(id), EnrichmentStatus.SKIPPED, "Context.dev is not configured, so there is nowhere to look.", ); @@ -60,7 +95,7 @@ describe("a brand task with no key", () => { it("does not strand a company that had already failed", async () => { const id = await company(EnrichmentStatus.FAILED); - await settle({ companyId: id }, EnrichmentStatus.SKIPPED, "no key"); + await settle(subjectOf(id), EnrichmentStatus.SKIPPED, "no key"); expect(await statusOf(id)).toBe(EnrichmentStatus.FAILED); }); @@ -68,8 +103,32 @@ describe("a brand task with no key", () => { it("still settles a lookup that genuinely ran", async () => { const id = await company(EnrichmentStatus.RUNNING); - await settle({ companyId: id }, EnrichmentStatus.SKIPPED, "No brand."); + await settle(subjectOf(id), EnrichmentStatus.SKIPPED, "No brand."); expect(await statusOf(id)).toBe(EnrichmentStatus.SKIPPED); }); + + it("records a failure on a company that never started", async () => { + const id = await company(EnrichmentStatus.PENDING); + + await settle( + await retiredSubjectOf(id), + EnrichmentStatus.FAILED, + "Research was attempted several times and never completed.", + ); + + expect(await statusOf(id)).toBe(EnrichmentStatus.FAILED); + }); + + it("does not revive a company that already completed", async () => { + const id = await company(EnrichmentStatus.COMPLETE); + + await settle( + await retiredSubjectOf(id), + EnrichmentStatus.FAILED, + "too late", + ); + + expect(await statusOf(id)).toBe(EnrichmentStatus.COMPLETE); + }); }); diff --git a/apps/agent/test/pool.spec.ts b/apps/agent/test/pool.spec.ts index 3a35df7be..534070e56 100644 --- a/apps/agent/test/pool.spec.ts +++ b/apps/agent/test/pool.spec.ts @@ -51,6 +51,23 @@ describe("runLimited", () => { expect(calls).toBe(0); }); + it("stops handing out items once the signal aborts", async () => { + const controller = new AbortController(); + const seen: number[] = []; + + await runLimited( + 1, + [1, 2, 3, 4], + async (n) => { + seen.push(n); + if (n === 2) controller.abort(); + }, + controller.signal, + ); + + expect(seen).toEqual([1, 2]); + }); + it("does not spawn more workers than items", async () => { let peak = 0; let running = 0; diff --git a/apps/agent/test/slack-membership.integration.spec.ts b/apps/agent/test/slack-membership.integration.spec.ts new file mode 100644 index 000000000..b5796ad9d --- /dev/null +++ b/apps/agent/test/slack-membership.integration.spec.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { joinSlackChannel } from "../agent/lib/slack-membership"; + +const USER_ID = "slack-join-spec-user"; +const ACCOUNT_ID = "slack-join-spec-account"; +const CHANNEL_ID = "CJOINSPEC1"; +const GRANT_ID = "slack-join-spec-grant"; +const INVENTORY_KIND = "slack-people-match"; + +const realFetch = globalThis.fetch; + +async function connect() { + await db.user.upsert({ + where: { id: USER_ID }, + create: { + id: USER_ID, + name: "Slack Join Spec", + email: `${USER_ID}@example.com`, + }, + update: {}, + }); + await db.account.upsert({ + where: { id: ACCOUNT_ID }, + create: { + id: ACCOUNT_ID, + accountId: "T-JOIN-SPEC", + providerId: "slack", + userId: USER_ID, + accessToken: "xoxb-join-spec", + }, + update: { accessToken: "xoxb-join-spec" }, + }); +} + +function answers(error: string) { + globalThis.fetch = (async () => + new Response(JSON.stringify({ ok: false, error }), { + headers: { "content-type": "application/json" }, + })) as typeof fetch; +} + +const requested: string[] = []; + +function replies(reply: (url: string) => object) { + globalThis.fetch = (async (input: URL | RequestInfo) => { + const url = String(input instanceof Request ? input.url : input); + requested.push(url); + return new Response(JSON.stringify(reply(url)), { + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +} + +let inventoryTaskIds: string[] = []; + +beforeEach(async () => { + requested.length = 0; + inventoryTaskIds = ( + await db.agentTask.findMany({ + where: { kind: INVENTORY_KIND }, + select: { id: true }, + }) + ).map((task) => task.id); + await db.slackChannel.deleteMany({ where: { id: CHANNEL_ID } }); + await connect(); + await db.slackChannel.create({ + data: { + id: CHANNEL_ID, + name: "join-spec", + isPrivate: false, + isMember: false, + available: true, + }, + }); +}); + +afterEach(async () => { + globalThis.fetch = realFetch; + await db.slackChannel.deleteMany({ where: { id: CHANNEL_ID } }); + await db.slackWorkspaceGrant.deleteMany({ where: { id: GRANT_ID } }); + await db.agentTask.deleteMany({ + where: { kind: INVENTORY_KIND, id: { notIn: inventoryTaskIds } }, + }); + await db.account.deleteMany({ where: { id: ACCOUNT_ID } }); + await db.user.deleteMany({ where: { id: USER_ID } }); +}); + +describe("joining a Slack channel", () => { + it("does not claim membership of an archived channel", async () => { + answers("is_archived"); + + const outcome = await joinSlackChannel(CHANNEL_ID); + + expect(outcome.joined).toBe(false); + expect(outcome).toMatchObject({ needsHuman: true }); + + const row = await db.slackChannel.findUnique({ + where: { id: CHANNEL_ID }, + select: { isMember: true }, + }); + expect(row?.isMember).toBe(false); + }); + + it("reads the channel from Slack rather than a row the migration reset", async () => { + replies((url) => + url.includes("conversations.info") + ? { ok: true, channel: { is_private: true, is_member: true } } + : { ok: false, error: "method_not_supported_for_channel_type" }, + ); + + const outcome = await joinSlackChannel(CHANNEL_ID); + + expect(outcome).toEqual({ joined: true, already: true }); + expect(requested.some((url) => url.includes("conversations.join"))).toBe( + false, + ); + expect( + await db.slackChannel.findUnique({ + where: { id: CHANNEL_ID }, + select: { isPrivate: true, isMember: true }, + }), + ).toEqual({ isPrivate: true, isMember: true }); + expect( + await db.agentTask.count({ + where: { kind: INVENTORY_KIND, id: { notIn: inventoryTaskIds } }, + }), + ).toBe(1); + }); + + it("invites itself to a private channel a stale row calls public", async () => { + await db.slackWorkspaceGrant.create({ + data: { + id: GRANT_ID, + teamId: "T-JOIN-SPEC", + userToken: "xoxp-join-spec", + userScopes: "groups:write", + }, + }); + replies((url) => { + if (url.includes("conversations.info")) { + return { ok: false, error: "channel_not_found" }; + } + if (url.includes("auth.test")) return { ok: true, user_id: "U-JOIN" }; + return { ok: true }; + }); + + const outcome = await joinSlackChannel(CHANNEL_ID); + + expect(outcome).toEqual({ joined: true, already: false }); + expect(requested.some((url) => url.includes("conversations.invite"))).toBe( + true, + ); + expect(requested.some((url) => url.includes("conversations.join"))).toBe( + false, + ); + expect( + await db.slackChannel.findUnique({ + where: { id: CHANNEL_ID }, + select: { isPrivate: true, isMember: true }, + }), + ).toEqual({ isPrivate: true, isMember: true }); + }); + + it("accepts a channel Slack says it is already in", async () => { + answers("already_in_channel"); + + const outcome = await joinSlackChannel(CHANNEL_ID); + + expect(outcome).toEqual({ joined: true, already: true }); + + const row = await db.slackChannel.findUnique({ + where: { id: CHANNEL_ID }, + select: { isMember: true }, + }); + expect(row?.isMember).toBe(true); + }); +}); diff --git a/apps/agent/test/slack-people.integration.spec.ts b/apps/agent/test/slack-people.integration.spec.ts new file mode 100644 index 000000000..ce618b6a4 --- /dev/null +++ b/apps/agent/test/slack-people.integration.spec.ts @@ -0,0 +1,248 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { + persistSlackChannels, + refreshSlackChannels, +} from "../agent/lib/slack-people"; + +const USER_ID = "slack-people-spec-user"; +const ACCOUNT_ID = "slack-people-spec-account"; +const PREFIX = "CSPEC"; + +const realFetch = globalThis.fetch; + +let restore: Array<{ id: string; available: boolean }> = []; + +async function connect() { + await db.user.upsert({ + where: { id: USER_ID }, + create: { + id: USER_ID, + name: "Slack Spec", + email: `${USER_ID}@example.com`, + }, + update: {}, + }); + await db.account.upsert({ + where: { id: ACCOUNT_ID }, + create: { + id: ACCOUNT_ID, + accountId: "T-SPEC", + providerId: "slack", + userId: USER_ID, + accessToken: "xoxb-spec", + }, + update: { accessToken: "xoxb-spec" }, + }); +} + +async function disconnect() { + await db.account.deleteMany({ where: { providerId: "slack" } }); + await db.slackChannel.deleteMany({ where: { id: { startsWith: PREFIX } } }); +} + +beforeEach(async () => { + restore = await db.slackChannel.findMany({ + select: { id: true, available: true }, + }); + await db.slackChannel.deleteMany({ where: { id: { startsWith: PREFIX } } }); + await connect(); +}); + +afterEach(async () => { + globalThis.fetch = realFetch; + await db.slackChannel.deleteMany({ where: { id: { startsWith: PREFIX } } }); + await db.account.deleteMany({ where: { id: ACCOUNT_ID } }); + await db.user.deleteMany({ where: { id: USER_ID } }); + for (const row of restore) { + await db.slackChannel.updateMany({ + where: { id: row.id }, + data: { available: row.available }, + }); + } +}); + +function slackReply(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +describe("persistSlackChannels", () => { + it("creates, updates and retires the inventory in one pass", async () => { + await db.slackChannel.create({ + data: { + id: `${PREFIX}-keep`, + name: "old-name", + memberCount: 1, + available: false, + }, + }); + await db.slackChannel.create({ + data: { id: `${PREFIX}-gone`, name: "gone", available: true }, + }); + + const written = await persistSlackChannels( + [ + { + id: `${PREFIX}-keep`, + name: "keep", + num_members: 12, + is_member: true, + }, + { + id: `${PREFIX}-new`, + name: "new", + num_members: 3, + is_private: true, + is_member: false, + }, + { id: `${PREFIX}-quiet`, name: "quiet", is_member: true }, + { id: `${PREFIX}-archived`, name: "archived", is_archived: true }, + ], + true, + ); + + expect(written).toBe(3); + + const rows = await db.slackChannel.findMany({ + where: { id: { startsWith: PREFIX } }, + orderBy: { id: "asc" }, + select: { + id: true, + name: true, + memberCount: true, + isPrivate: true, + isMember: true, + available: true, + }, + }); + + expect(rows).toEqual([ + { + id: `${PREFIX}-gone`, + name: "gone", + memberCount: null, + isPrivate: false, + isMember: false, + available: false, + }, + { + id: `${PREFIX}-keep`, + name: "keep", + memberCount: 12, + isPrivate: false, + isMember: true, + available: true, + }, + { + id: `${PREFIX}-new`, + name: "new", + memberCount: 3, + isPrivate: true, + isMember: false, + available: true, + }, + { + id: `${PREFIX}-quiet`, + name: "quiet", + memberCount: null, + isPrivate: false, + isMember: true, + available: true, + }, + ]); + }); + + it("retires every channel when nothing is available", async () => { + await db.slackChannel.create({ + data: { id: `${PREFIX}-only`, name: "only", available: true }, + }); + + expect(await persistSlackChannels([], false)).toBe(0); + + const row = await db.slackChannel.findUnique({ + where: { id: `${PREFIX}-only` }, + select: { available: true }, + }); + expect(row?.available).toBe(false); + }); + + it("writes nothing when Slack is disconnected", async () => { + await disconnect(); + + const written = await persistSlackChannels( + [{ id: `${PREFIX}-ghost`, name: "ghost", is_member: true }], + false, + ); + + expect(written).toBe(0); + expect( + await db.slackChannel.count({ where: { id: `${PREFIX}-ghost` } }), + ).toBe(0); + }); +}); + +describe("refreshSlackChannels", () => { + it("aborts a stalled Slack list request", async () => { + let signal: AbortSignal | null = null; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + signal = init?.signal ?? null; + return slackReply({ ok: true, channels: [] }); + }) as typeof fetch; + + await refreshSlackChannels(); + + expect(signal).toBeInstanceOf(AbortSignal); + }); + + it("follows the cursor across pages", async () => { + const seen: string[] = []; + globalThis.fetch = (async (input: URL) => { + const cursor = input.searchParams.get("cursor") ?? ""; + seen.push(cursor); + if (cursor === "") { + return slackReply({ + ok: true, + channels: [ + { id: `${PREFIX}-a`, name: "a", is_member: true, unknown: 1 }, + ], + response_metadata: { next_cursor: "page-2" }, + }); + } + return slackReply({ + ok: true, + channels: [{ id: `${PREFIX}-b`, name: "b", is_member: true }], + response_metadata: { next_cursor: "" }, + }); + }) as unknown as typeof fetch; + + expect(await refreshSlackChannels()).toBe(2); + expect([...new Set(seen)]).toEqual(["", "page-2"]); + }); + + it("does not resurrect the inventory a disconnect removed", async () => { + globalThis.fetch = (async () => { + await disconnect(); + return slackReply({ + ok: true, + channels: [{ id: `${PREFIX}-late`, name: "late", is_member: true }], + }); + }) as typeof fetch; + + expect(await refreshSlackChannels()).toBe(0); + expect( + await db.slackChannel.count({ where: { id: { startsWith: PREFIX } } }), + ).toBe(0); + }); + + it("explains a rejected list", async () => { + globalThis.fetch = (async () => + slackReply({ ok: false, error: "missing_scope" })) as typeof fetch; + + expect(refreshSlackChannels()).rejects.toThrow( + "Slack channel lookup needs an additional permission. Reconnect Slack and retry.", + ); + }); +}); diff --git a/apps/api/.scratch/existing.ts b/apps/api/.scratch/existing.ts deleted file mode 100644 index 3102a5fef..000000000 --- a/apps/api/.scratch/existing.ts +++ /dev/null @@ -1,21 +0,0 @@ -import "@crm/env/load"; -import { db } from "@crm/db"; -import { readContextDevKey } from "@crm/db/settings"; - -// Simulate an existing deployment: key in the environment, nothing in the row. -await db.appSetting.upsert({ - where: { id: "app" }, - create: { id: "app", contextDevApiKey: null }, - update: { contextDevApiKey: null }, -}); - -console.log( - "env var set? ", - Boolean(process.env.CONTEXT_DEV_API_KEY?.trim()), -); -console.log("key the app finds ", await readContextDevKey(db)); -console.log( - "=> researchKey.configured would be false, so the proxy gates everyone.", -); - -await db.$disconnect(); diff --git a/apps/api/.scratch/shape.ts b/apps/api/.scratch/shape.ts deleted file mode 100644 index dae49c71b..000000000 --- a/apps/api/.scratch/shape.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { initTRPC } from "@trpc/server"; -import { setResearchKeyInput } from "../src/settings/settings.contracts"; -import { formatTrpcError } from "../src/trpc/error-formatter"; - -const t = initTRPC.create({ errorFormatter: formatTrpcError }); - -const router = t.router({ - setResearchKey: t.procedure.input(setResearchKeyInput).mutation(() => "ok"), -}); - -const server = Bun.serve({ - port: 4599, - fetch: (req) => - import("@trpc/server/adapters/fetch").then(({ fetchRequestHandler }) => - fetchRequestHandler({ - endpoint: "", - req, - router, - createContext: () => ({}), - }), - ), -}); - -for (const apiKey of ["short", "ctx live key with spaces", ""]) { - const res = await fetch(`http://localhost:4599/setResearchKey`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ apiKey }), - }); - const body = await res.json(); - console.log(JSON.stringify(apiKey).padEnd(28), "->", body.error.message); -} - -server.stop(); diff --git a/apps/api/.scratch/verify.ts b/apps/api/.scratch/verify.ts deleted file mode 100644 index 6b96aa4ec..000000000 --- a/apps/api/.scratch/verify.ts +++ /dev/null @@ -1,19 +0,0 @@ -import "@crm/env/load"; -import { ResearchKeyService } from "../src/agent/research-key.service"; - -const service = new ResearchKeyService(); -const real = process.env.CONTEXT_DEV_API_KEY?.trim() ?? ""; - -console.log( - "bad key ->", - JSON.stringify(await service.verify("ctx_nope_not_real_key")), -); -console.log("real key ->", JSON.stringify(await service.verify(real))); - -const secret = process.env.AGENT_BRIDGE_SECRET; -delete process.env.AGENT_BRIDGE_SECRET; -console.log("no bridge ->", JSON.stringify(await service.verify(real))); -process.env.AGENT_BRIDGE_SECRET = secret; - -process.env.AGENT_URL = "http://127.0.0.1:59999"; -console.log("agent down ->", JSON.stringify(await service.verify(real))); diff --git a/apps/api/package.json b/apps/api/package.json index 9128b2a1d..73076b5f0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,7 +10,8 @@ "scripts": { "build": "bun build src/main.ts --target=bun --outdir dist --packages=external --sourcemap", "check-types": "tsc --noEmit", - "dev": "concurrently -n api,trpc -c blue,magenta \"bun --watch src/main.ts\" \"nestjs-trpc watch -e src/app.module.ts -r '**/*.router.ts' -o src/generated\"", + "dev": "concurrently -n api,trpc -c blue,magenta \"bun --watch src/main.ts\" \"bun run dev:trpc\"", + "dev:trpc": "nestjs-trpc watch -e src/app.module.ts -r \"**/*.router.ts\" -o src/generated", "dev:session": "bun scripts/dev-session.ts", "lint": "biome check .", "postinstall": "node scripts/chmod-trpc-binary.mjs", @@ -26,6 +27,7 @@ "@crm/db": "workspace:*", "@crm/env": "workspace:*", "@crm/telemetry": "workspace:*", + "@crm/validation": "workspace:*", "@keyv/redis": "^5.1.6", "@nestjs/cache-manager": "^3.1.3", "@nestjs/common": "^11.0.1", diff --git a/apps/api/src/agent/agent-access.service.ts b/apps/api/src/agent/agent-access.service.ts index a9dccf24c..9d44cc482 100644 --- a/apps/api/src/agent/agent-access.service.ts +++ b/apps/api/src/agent/agent-access.service.ts @@ -1,8 +1,9 @@ import { isWorkspaceAdmin, - isWorkspaceRole, + toWorkspaceRole, WORKSPACE_ID, type WorkspaceRole, + workspaceRoleOf, } from "@crm/auth"; import type { Db, Prisma } from "@crm/db"; import { @@ -18,18 +19,13 @@ export class AgentAccessService { constructor(@InjectDatabase() private readonly db: Db) {} async assertMember(userId: string): Promise { - const member = await this.db.member.findUnique({ - where: { - organizationId_userId: { organizationId: WORKSPACE_ID, userId }, - }, - select: { role: true }, - }); + const role = await workspaceRoleOf(userId); - if (!member) { + if (!role) { throw new ForbiddenException("You are not a member of this workspace."); } - return isWorkspaceRole(member.role) ? member.role : "member"; + return role; } async assertCanManageInTransaction( @@ -49,7 +45,7 @@ export class AgentAccessService { throw new ForbiddenException("You are not a member of this workspace."); } - const role = isWorkspaceRole(member.role) ? member.role : "member"; + const role = toWorkspaceRole(member.role); const agent = await tx.agentDefinition.findFirst({ where: { id: agentId, status: { not: "DELETED" } }, select: { @@ -79,7 +75,7 @@ export class AgentAccessService { } async assertCanRead(agentId: string, userId: string) { - await this.assertMember(userId); + const role = await this.assertMember(userId); const agent = await this.db.agentDefinition.findFirst({ where: { id: agentId, status: { not: "DELETED" } }, select: { @@ -94,6 +90,10 @@ export class AgentAccessService { throw new NotFoundException(`No agent with id ${agentId}.`); } - return agent; + return { + ...agent, + role, + canManage: agent.createdById === userId || isWorkspaceAdmin(role), + }; } } diff --git a/apps/api/src/agent/agent-definitions.service.ts b/apps/api/src/agent/agent-definitions.service.ts index 89d974666..9d6521fd8 100644 --- a/apps/api/src/agent/agent-definitions.service.ts +++ b/apps/api/src/agent/agent-definitions.service.ts @@ -1,5 +1,6 @@ import type { Db, Prisma } from "@crm/db"; import type { AgentDefinitionStatus } from "@crm/db/enums"; +import { schemas } from "@crm/validation"; import { BadRequestException, Injectable, @@ -7,14 +8,24 @@ import { } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; import { AgentAccessService } from "./agent-access.service"; +import { AgentTriggerService } from "./agent-trigger.service"; import { TEAM_AGENT_STATUSES } from "./agent-visibility"; -import type { AgentDeployInput, AgentUpdateInput } from "./agents.contracts"; +import { + type AgentDeployInput, + type AgentReviseInput, + type AgentSaveFileInput, + type AgentUpdateInput, + agentManifest, +} from "./agents.contracts"; + +const INSTRUCTIONS_PATH = "agent/instructions.md"; @Injectable() export class AgentDefinitionsService { constructor( @InjectDatabase() private readonly db: Db, private readonly access: AgentAccessService, + private readonly trigger: AgentTriggerService, ) {} async list(userId: string) { @@ -140,6 +151,9 @@ export class AgentDefinitionsService { lastRunAt: trigger.lastRunAt?.toISOString() ?? null, })), runCount: agent._count.runs, + capabilities: readCapabilities( + agent.currentVersion?.manifest ?? versions[0]?.manifest, + ), }; } @@ -174,6 +188,304 @@ export class AgentDefinitionsService { return updated; } + async files(id: string, userId: string) { + await this.access.assertCanRead(id, userId); + + const agent = await this.db.agentDefinition.findFirst({ + where: { id, status: { not: "DELETED" } }, + select: { currentVersionId: true }, + }); + if (!agent?.currentVersionId) return { versionId: null, files: [] }; + + const rows = await this.db.agentBuilderArtifact.findMany({ + where: { versionId: agent.currentVersionId }, + orderBy: [{ path: "asc" }, { revision: "desc" }], + select: { + path: true, + language: true, + content: true, + previousContent: true, + revision: true, + }, + }); + + const latest = new Map(); + for (const row of rows) + if (!latest.has(row.path)) latest.set(row.path, row); + + return { + versionId: agent.currentVersionId, + files: [...latest.values()], + }; + } + + async saveFile(input: AgentSaveFileInput, userId: string) { + return this.db.$transaction(async (tx) => { + await this.access.assertCanManageInTransaction(tx, input.id, userId); + const agent = await this.lockAgent(tx, input.id); + + const replay = await tx.agentAuditEvent.findFirst({ + where: { + agentId: input.id, + type: "agent.file.saved", + requestId: input.clientRequestId, + }, + select: { versionId: true }, + }); + if (replay) return { saved: false, versionId: replay.versionId }; + + if (!agent.currentVersionId) { + throw new BadRequestException("This agent has no deployed version."); + } + + const current = await tx.agentVersion.findFirstOrThrow({ + where: { id: agent.currentVersionId }, + select: { + status: true, + instructions: true, + manifest: true, + modelId: true, + modelContextWindowTokens: true, + sandboxPolicy: true, + validation: true, + sourceConversationId: true, + deployedAt: true, + approvedAt: true, + }, + }); + + const file = await tx.agentBuilderArtifact.findFirst({ + where: { versionId: agent.currentVersionId, path: input.path }, + orderBy: { revision: "desc" }, + }); + if (!file) throw new NotFoundException(`No file at ${input.path}.`); + if (file.content === input.content) { + return { saved: false, versionId: agent.currentVersionId }; + } + + const version = await tx.agentVersion.create({ + data: { + agentId: input.id, + number: await nextVersionNumber(tx, input.id), + status: current.status, + instructions: + input.path === INSTRUCTIONS_PATH + ? input.content + : current.instructions, + manifest: current.manifest as Prisma.InputJsonValue, + modelId: current.modelId, + modelContextWindowTokens: current.modelContextWindowTokens, + sandboxPolicy: current.sandboxPolicy as Prisma.InputJsonValue, + validation: current.validation as Prisma.InputJsonValue, + sourceConversationId: current.sourceConversationId, + approvedAt: current.approvedAt, + deployedAt: current.deployedAt, + createdById: userId, + }, + select: { id: true }, + }); + + await carryArtifactsForward(tx, agent.currentVersionId, version.id); + + await tx.agentBuilderArtifact.updateMany({ + where: { versionId: version.id, path: input.path }, + data: { previousContent: file.content, content: input.content }, + }); + + await tx.agentDefinition.update({ + where: { id: input.id }, + data: { currentVersionId: version.id }, + }); + + const repointed = await tx.agentTrigger.updateMany({ + where: { agentId: input.id, versionId: agent.currentVersionId }, + data: { versionId: version.id }, + }); + + await tx.agentAuditEvent.create({ + data: { + agentId: input.id, + versionId: version.id, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "agent.file.saved", + summary: `Edited ${input.path}`, + before: { path: input.path, bytes: file.content.length }, + after: { + path: input.path, + bytes: input.content.length, + triggers: repointed.count, + }, + requestId: input.clientRequestId, + }, + }); + + return { saved: true, versionId: version.id }; + }); + } + + async revise(input: AgentReviseInput, userId: string) { + const versionId = await this.trigger.withTasks(async (tx, queue) => { + await this.access.assertCanManageInTransaction(tx, input.id, userId); + const agent = await this.lockAgent(tx, input.id); + + const replay = await tx.agentAuditEvent.findFirst({ + where: { + agentId: input.id, + type: "agent.revised", + requestId: input.clientRequestId, + }, + select: { versionId: true }, + }); + if (replay) return replay.versionId; + + if (!agent.currentVersionId) { + throw new BadRequestException("This agent has no deployed version."); + } + + const current = await tx.agentVersion.findFirstOrThrow({ + where: { id: agent.currentVersionId }, + select: { + status: true, + instructions: true, + manifest: true, + modelId: true, + modelContextWindowTokens: true, + sandboxPolicy: true, + validation: true, + sourceConversationId: true, + deployedAt: true, + approvedAt: true, + }, + }); + + const parsed = agentManifest.safeParse(current.manifest); + if (!parsed.success) { + throw new BadRequestException( + "This version's manifest cannot be read, so it cannot be changed.", + ); + } + + const manifest = parsed.data; + const before = manifest.actions.find( + (action) => action.destination !== undefined, + ); + + let actions = manifest.actions; + + if (input.actions) { + const keep = new Set(input.actions); + actions = actions.filter((action) => keep.has(action.type)); + if (actions.length === 0) { + throw new BadRequestException("An agent needs at least one action."); + } + } + + const channel = input.channel; + + if (channel) { + if (!actions.some((action) => action.destination !== undefined)) { + throw new BadRequestException( + "None of this agent's actions post to a channel, so its channel cannot be changed.", + ); + } + + actions = actions.map((action) => + action.destination + ? { + ...action, + destination: { + ...action.destination, + id: channel.id, + label: `#${channel.name}`, + }, + } + : action, + ); + } + + const dataScope = input.resources + ? { + ...manifest.dataScope, + mode: input.resources.length > 0 ? "SELECTED" : "WORKSPACE", + resources: input.resources, + } + : manifest.dataScope; + + const version = await tx.agentVersion.create({ + data: { + agentId: input.id, + number: await nextVersionNumber(tx, input.id), + status: current.status, + instructions: current.instructions, + manifest: { + ...manifest, + actions, + dataScope, + } as Prisma.InputJsonValue, + modelId: current.modelId, + modelContextWindowTokens: current.modelContextWindowTokens, + sandboxPolicy: current.sandboxPolicy as Prisma.InputJsonValue, + validation: reviseValidation( + current.validation, + manifest.actions.map((action) => action.type), + actions.map((action) => action.type), + ), + sourceConversationId: current.sourceConversationId, + approvedAt: current.approvedAt, + deployedAt: current.deployedAt, + createdById: userId, + }, + select: { id: true }, + }); + + await carryArtifactsForward(tx, agent.currentVersionId, version.id); + + await tx.agentDefinition.update({ + where: { id: input.id }, + data: { currentVersionId: version.id }, + }); + + const repointed = await tx.agentTrigger.updateMany({ + where: { agentId: input.id, versionId: agent.currentVersionId }, + data: { versionId: version.id }, + }); + + if (channel && channel.id !== before?.destination?.id) { + await queue.slackChannelJoinRequested(channel.id, channel.name); + } + + await tx.agentAuditEvent.create({ + data: { + agentId: input.id, + versionId: version.id, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "agent.revised", + summary: reviseSummary(input), + before: { + channel: before?.destination?.label ?? null, + actions: manifest.actions.map((action) => action.type), + resources: manifest.dataScope.resources.length, + }, + after: { + channel: channel ? `#${channel.name}` : null, + actions: input.actions ?? null, + resources: input.resources?.length ?? null, + triggers: repointed.count, + }, + requestId: input.clientRequestId, + }, + }); + + return version.id; + }); + + return { versionId }; + } + async deploy(input: AgentDeployInput, userId: string) { return this.db.$transaction(async (tx) => { await this.access.assertCanManageInTransaction(tx, input.id, userId); @@ -459,9 +771,10 @@ export class AgentDefinitionsService { status: AgentDefinitionStatus; name: string; description: string | null; + currentVersionId: string | null; }> >` - SELECT id, status, name, description + SELECT id, status, name, description, "currentVersionId" FROM "agentDefinition" WHERE id = ${id} FOR UPDATE @@ -500,3 +813,111 @@ function versionMetadata(manifest: unknown): { ...(description !== undefined ? { description } : {}), }; } + +function readCapabilities(manifest: unknown) { + const parsed = schemas.agents.capabilities.safeParse(manifest); + + if (!parsed.success) { + return { + readable: false as const, + problem: parsed.error.issues + .map( + (issue) => `${issue.path.join(".") || "manifest"} ${issue.message}`, + ) + .join("; "), + actions: [], + dataScope: null, + channel: null, + }; + } + + const slack = parsed.data.actions.find( + (action) => action.destination !== undefined, + ); + + return { + readable: true as const, + problem: null, + actions: parsed.data.actions, + dataScope: parsed.data.dataScope, + channel: slack?.destination ?? null, + }; +} + +function reviseSummary(input: { + channel?: { name: string }; + actions?: string[]; + resources?: unknown[]; +}): string { + const parts: string[] = []; + if (input.channel) parts.push(`moved to #${input.channel.name}`); + if (input.actions) parts.push(`${input.actions.length} actions`); + if (input.resources) parts.push(`${input.resources.length} records`); + return parts.length > 0 ? `Changed ${parts.join(", ")}` : "Changed settings"; +} + +function reviseValidation( + validation: unknown, + before: string[], + after: string[], +): Prisma.InputJsonValue { + const removed = before.filter((type) => !after.includes(type)); + const base = + validation && typeof validation === "object" && !Array.isArray(validation) + ? (validation as Record) + : {}; + + const capabilities = Array.isArray(base.capabilities) + ? base.capabilities.filter( + (entry) => typeof entry === "string" && !removed.includes(entry), + ) + : []; + + return { + ...base, + status: "passed", + checkedAt: new Date().toISOString(), + capabilities, + } as Prisma.InputJsonValue; +} + +async function nextVersionNumber( + tx: Prisma.TransactionClient, + agentId: string, +): Promise { + const latest = await tx.agentVersion.findFirst({ + where: { agentId }, + orderBy: { number: "desc" }, + select: { number: true }, + }); + + return (latest?.number ?? 0) + 1; +} + +async function carryArtifactsForward( + tx: Prisma.TransactionClient, + fromVersionId: string, + toVersionId: string, +): Promise { + const rows = await tx.agentBuilderArtifact.findMany({ + where: { versionId: fromVersionId }, + orderBy: [{ path: "asc" }, { revision: "desc" }], + }); + + const latest = new Map(); + for (const row of rows) if (!latest.has(row.path)) latest.set(row.path, row); + + if (latest.size === 0) return; + + await tx.agentBuilderArtifact.createMany({ + data: [...latest.values()].map((row) => ({ + versionId: toVersionId, + path: row.path, + language: row.language, + content: row.content, + previousContent: row.previousContent, + revision: row.revision, + status: row.status, + })), + }); +} diff --git a/apps/api/src/agent/agent-dispatch.config.ts b/apps/api/src/agent/agent-dispatch.config.ts new file mode 100644 index 000000000..9861a2e66 --- /dev/null +++ b/apps/api/src/agent/agent-dispatch.config.ts @@ -0,0 +1,13 @@ +const SECOND_MS = 1_000; +const MINUTE_MS = 60 * SECOND_MS; + +export const AGENT_DISPATCH = { + poke: { timeoutMs: 2 * SECOND_MS }, + heartbeat: { everyMs: MINUTE_MS }, + cancel: { + errorCode: "CANCELLED_BY_USER", + message: "A workspace member stopped this run.", + redeliverWithinMs: 10 * MINUTE_MS, + redeliverBatch: 20, + }, +} as const; diff --git a/apps/api/src/agent/agent-runs.service.ts b/apps/api/src/agent/agent-runs.service.ts index 7585c801f..6cff770b4 100644 --- a/apps/api/src/agent/agent-runs.service.ts +++ b/apps/api/src/agent/agent-runs.service.ts @@ -1,15 +1,31 @@ import { randomUUID } from "node:crypto"; -import type { Db } from "@crm/db"; +import { type Db, Prisma } from "@crm/db"; +import type { AgentRunStatus } from "@crm/db/enums"; import { lockIdempotencyKey } from "@crm/db/idempotency"; import { BadRequestException, + ConflictException, + ForbiddenException, Injectable, NotFoundException, } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; import { AgentAccessService } from "./agent-access.service"; +import { AGENT_DISPATCH } from "./agent-dispatch.config"; import { AgentTriggerService } from "./agent-trigger.service"; -import type { AgentRunNowInput } from "./agents.contracts"; +import type { + AgentCancelRunInput, + AgentRetryRunInput, + AgentRunNowInput, +} from "./agents.contracts"; + +const CANCELLABLE_STATUSES: readonly AgentRunStatus[] = [ + "QUEUED", + "RUNNING", + "WAITING_FOR_APPROVAL", +]; + +const RUN_EVENT_LIMIT = 200; @Injectable() export class AgentRunsService { @@ -20,7 +36,7 @@ export class AgentRunsService { ) {} async list(agentId: string, limit: number, userId: string) { - await this.readableAgent(agentId, userId); + const agent = await this.readableAgent(agentId, userId); const rows = await this.db.agentRun.findMany({ where: { agentId }, @@ -42,8 +58,10 @@ export class AgentRunsService { finishedAt: true, initiatedBy: { select: { id: true, name: true, image: true } }, version: { select: { id: true, number: true } }, + _count: { select: { events: true } }, events: { orderBy: { sequence: "asc" }, + take: RUN_EVENT_LIMIT, select: { id: true, sequence: true, @@ -75,8 +93,13 @@ export class AgentRunsService { }, }); - return rows.map((run) => ({ + return rows.map(({ _count, ...run }) => ({ ...run, + totalEvents: _count.events, + eventsTruncated: _count.events > run.events.length, + canCancel: + CANCELLABLE_STATUSES.includes(run.status) && + (agent.canManage || run.initiatedBy?.id === userId), costUsd: run.costUsd?.toString() ?? null, createdAt: run.createdAt.toISOString(), startedAt: run.startedAt?.toISOString() ?? null, @@ -167,6 +190,19 @@ export class AgentRunsService { throw new BadRequestException("This agent is not live yet."); } + const active = await tx.agentRun.findFirst({ + where: { + agentId: input.id, + status: { in: [...CANCELLABLE_STATUSES] }, + }, + select: { id: true }, + }); + if (active) { + throw new ConflictException( + "This agent already has an active run. Stop it or wait for it to finish.", + ); + } + const created = await tx.agentRun.create({ data: { agentId: input.id, @@ -202,6 +238,198 @@ export class AgentRunsService { return run; } + async retryRun(input: AgentRetryRunInput, userId: string) { + await this.access.assertMember(userId); + + const run = await this.db.$transaction(async (tx) => { + await lockIdempotencyKey(tx, input.clientRequestId); + const replay = await tx.agentRun.findUnique({ + where: { idempotencyKey: input.clientRequestId }, + select: { id: true, agentId: true }, + }); + if (replay) { + this.assertReplayMatches(replay.agentId, input.id); + return { id: replay.id }; + } + + const previous = await tx.agentRun.findUnique({ + where: { id: input.runId }, + select: { + agentId: true, + status: true, + versionId: true, + triggerId: true, + triggerType: true, + input: true, + }, + }); + if (!previous || previous.agentId !== input.id) { + throw new NotFoundException(`No run with id ${input.runId}.`); + } + if (CANCELLABLE_STATUSES.includes(previous.status)) { + throw new ConflictException("This run has not finished yet."); + } + + const [agent] = await tx.$queryRaw< + Array<{ id: string; status: string; currentVersionId: string | null }> + >` + SELECT id, status, "currentVersionId" + FROM "agentDefinition" + WHERE id = ${input.id} + FOR UPDATE + `; + if (!agent || agent.status === "DELETED") { + throw new NotFoundException(`No agent with id ${input.id}.`); + } + if (agent.status !== "LIVE" || !agent.currentVersionId) { + throw new BadRequestException("This agent is not live yet."); + } + + const active = await tx.agentRun.findFirst({ + where: { agentId: input.id, status: { in: [...CANCELLABLE_STATUSES] } }, + select: { id: true }, + }); + if (active) { + throw new ConflictException( + "This agent already has an active run. Stop it or wait for it to finish.", + ); + } + + const created = await tx.agentRun.create({ + data: { + agentId: input.id, + versionId: previous.versionId, + initiatedById: userId, + triggerId: previous.triggerId, + triggerType: previous.triggerType, + input: previous.input ?? Prisma.DbNull, + idempotencyKey: input.clientRequestId, + correlationId: randomUUID(), + events: { create: { sequence: 0, type: "run.queued", data: {} } }, + }, + select: { id: true }, + }); + + await tx.agentAuditEvent.create({ + data: { + agentId: input.id, + versionId: previous.versionId, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "run.requested", + summary: `Retried run ${input.runId}`, + requestId: input.clientRequestId, + }, + }); + + return created; + }); + + this.trigger.deployedAgentRunQueued(); + return run; + } + + async cancelRun(input: AgentCancelRunInput, userId: string) { + const agent = await this.readableAgent(input.id, userId); + + const outcome = await this.db.$transaction(async (tx) => { + const [run] = await tx.$queryRaw< + Array<{ + id: string; + agentId: string; + versionId: string; + status: AgentRunStatus; + initiatedById: string | null; + nextEventSequence: number; + }> + >` + SELECT id, "agentId", "versionId", status, "initiatedById", "nextEventSequence" + FROM "agentRun" + WHERE id = ${input.runId} + FOR UPDATE + `; + + if (!run || run.agentId !== input.id) { + throw new NotFoundException(`No run with id ${input.runId}.`); + } + + if (!agent.canManage && run.initiatedById !== userId) { + throw new ForbiddenException( + "Only the person who started this run, or a workspace admin, can stop it.", + ); + } + + if (!CANCELLABLE_STATUSES.includes(run.status)) { + return { id: run.id, status: run.status, cancelled: false }; + } + + const sequence = run.nextEventSequence + 1; + const finishedAt = new Date(); + + await tx.agentRun.update({ + where: { id: run.id }, + data: { + status: "CANCELLED", + errorCode: AGENT_DISPATCH.cancel.errorCode, + errorMessage: AGENT_DISPATCH.cancel.message, + finishedAt, + nextEventSequence: sequence, + }, + }); + + await tx.agentAction.updateMany({ + where: { runId: run.id, status: { in: ["PLANNED", "RUNNING"] } }, + data: { + status: "CANCELLED", + errorCode: AGENT_DISPATCH.cancel.errorCode, + errorMessage: AGENT_DISPATCH.cancel.message, + completedAt: finishedAt, + }, + }); + + await tx.agentRunEvent.create({ + data: { + id: `run-terminal:${run.id}:cancelled`, + runId: run.id, + sequence, + type: "run.cancelled", + data: { reason: "user.cancelled" }, + emittedAt: finishedAt, + }, + }); + + await tx.agentAuditEvent.upsert({ + where: { + agentId_type_requestId: { + agentId: run.agentId, + type: "run.cancelled", + requestId: run.id, + }, + }, + create: { + agentId: run.agentId, + versionId: run.versionId, + actorUserId: userId, + actorType: "USER", + actorId: userId, + type: "run.cancelled", + summary: "Stopped a run", + requestId: run.id, + }, + update: {}, + }); + + return { id: run.id, status: "CANCELLED" as const, cancelled: true }; + }); + + if (outcome.cancelled) { + this.trigger.deployedAgentRunCancelled(outcome.id); + } + + return outcome; + } + private async readableAgent(agentId: string, userId: string) { return this.access.assertCanRead(agentId, userId); } diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index 5046a49c7..ea5226eeb 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -1,14 +1,35 @@ -import type { Db, FieldEntity } from "@crm/db"; +import { type Db, type FieldEntity, Prisma } from "@crm/db"; import { PRIORITY } from "@crm/db/agent-tasks"; +import { CRM_EVENT_CATALOG, type CrmEventType } from "@crm/db/crm-events"; +import { lockIdempotencyKey } from "@crm/db/idempotency"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; +import { AGENT_DISPATCH } from "./agent-dispatch.config"; import { bridge } from "./bridge"; -const POKE_TIMEOUT_MS = 2_000; +export type CrmEventInput = { + [Type in CrmEventType]: { + type: Type; + record: { + kind: (typeof CRM_EVENT_CATALOG)[Type]["recordKind"]; + id: string; + }; + occurredAt: Date; + data: Prisma.InputJsonObject; + }; +}[CrmEventType]; + +export type AgentTaskQueue = { + slackChannelJoinRequested: ( + channelId: string, + channelName: string, + ) => Promise; +}; @Injectable() export class AgentTriggerService { private readonly logger = new Logger(AgentTriggerService.name); + private readonly cancellationsDelivered = new Set(); constructor(@InjectDatabase() private readonly db: Db) {} @@ -70,6 +91,101 @@ export class AgentTriggerService { }); } + async slackPeopleRequested(reason: string, required = false): Promise { + await this.enqueue( + { + kind: "slack-people-match", + reason, + priority: PRIORITY.slackPeople, + budget: 1, + }, + required, + ); + } + + async slackChannelJoinRequested( + channelId: string, + channelName: string, + ): Promise { + await this.queueSlackChannelJoin(channelId, channelName); + } + + async withTasks( + work: ( + tx: Prisma.TransactionClient, + queue: AgentTaskQueue, + ) => Promise, + ): Promise { + let queued = false; + + const result = await this.db.$transaction((tx) => + work(tx, { + slackChannelJoinRequested: async (channelId, channelName) => { + const created = await this.queueSlackChannelJoin( + channelId, + channelName, + tx, + ); + queued = queued || created; + }, + }), + ); + + if (queued) this.poke(); + + return result; + } + + private queueSlackChannelJoin( + channelId: string, + channelName: string, + client?: Prisma.TransactionClient, + ): Promise { + return this.enqueue( + { + kind: "slack-channel-join", + reason: `Add Comp AI to #${channelName}`, + priority: PRIORITY.slackJoin, + budget: 1, + subject: { path: ["channelId"], value: channelId }, + payload: { + type: "slack.channel.join", + channelId, + channelName, + }, + }, + true, + client, + ); + } + + async withCrmEvents( + work: ( + tx: Prisma.TransactionClient, + emit: (input: CrmEventInput) => Promise, + ) => Promise, + ): Promise { + const queued: CrmEventInput[] = []; + const result = await this.db.$transaction((tx) => + work(tx, async (input) => { + await this.createEventTask(tx, input); + queued.push(input); + }), + ); + + for (const input of queued) { + this.logger.log({ + message: "Agent event queued", + type: input.type, + recordKind: input.record.kind, + recordId: input.record.id, + }); + } + if (queued.length > 0) this.poke(); + + return result; + } + async fieldBackfill( entity: FieldEntity, key: string, @@ -138,6 +254,49 @@ export class AgentTriggerService { this.pokeRoute("/internal/crm/agent-dispatch"); } + deployedAgentRunCancelled(runId: string): void { + void this.deliverCancellation(runId); + } + + async redeliverCancellations(): Promise { + try { + const since = new Date( + Date.now() - AGENT_DISPATCH.cancel.redeliverWithinMs, + ); + const runs = await this.db.agentRun.findMany({ + where: { + status: "CANCELLED", + errorCode: AGENT_DISPATCH.cancel.errorCode, + startedAt: { not: null }, + finishedAt: { gte: since }, + }, + orderBy: { finishedAt: "desc" }, + take: AGENT_DISPATCH.cancel.redeliverBatch, + select: { id: true }, + }); + + const outstanding = new Set(runs.map((run) => run.id)); + for (const runId of this.cancellationsDelivered) { + if (!outstanding.has(runId)) this.cancellationsDelivered.delete(runId); + } + + for (const run of runs) { + if (this.cancellationsDelivered.has(run.id)) continue; + await this.deliverCancellation(run.id); + } + } catch (error) { + this.logger.error( + { message: "Could not redeliver run cancellations" }, + error instanceof Error ? error.stack : String(error), + ); + } + } + + private async deliverCancellation(runId: string): Promise { + const delivered = await this.post("/internal/crm/cancel-run", { runId }); + if (delivered) this.cancellationsDelivered.add(runId); + } + async backfill(input: { kind: string; reason: string; @@ -201,38 +360,64 @@ export class AgentTriggerService { } } - private async enqueue(task: { - contactId?: string; - companyId?: string; - kind: string; - reason: string; - priority: number; - budget: number; - }): Promise { + private async enqueue( + task: { + contactId?: string; + companyId?: string; + kind: string; + reason: string; + priority: number; + budget: number; + payload?: Prisma.InputJsonValue; + subject?: { path: string[]; value: string }; + }, + required = false, + client?: Prisma.TransactionClient, + ): Promise { try { - const pending = await this.db.agentTask.findFirst({ - where: { - kind: task.kind, - finishedAt: null, - ...(task.contactId ? { contactId: task.contactId } : {}), - ...(task.companyId ? { companyId: task.companyId } : {}), - }, - select: { id: true }, - }); - - if (pending) return; + const write = async (tx: Prisma.TransactionClient) => { + await lockIdempotencyKey( + tx, + `agent-task:${task.kind}:${task.contactId ?? ""}:${task.companyId ?? ""}:${task.subject?.value ?? ""}`, + ); + const pending = await tx.agentTask.findFirst({ + where: { + kind: task.kind, + finishedAt: null, + ...(task.contactId ? { contactId: task.contactId } : {}), + ...(task.companyId ? { companyId: task.companyId } : {}), + ...(task.subject + ? { + payload: { + path: task.subject.path, + equals: task.subject.value, + }, + } + : {}), + }, + select: { id: true }, + }); + if (pending) return false; + + await tx.agentTask.create({ + data: { + contactId: task.contactId ?? null, + companyId: task.companyId ?? null, + kind: task.kind, + reason: task.reason, + priority: task.priority, + budget: task.budget, + dueAt: new Date(), + ...(task.payload ? { payload: task.payload } : {}), + }, + }); + return true; + }; - await this.db.agentTask.create({ - data: { - contactId: task.contactId ?? null, - companyId: task.companyId ?? null, - kind: task.kind, - reason: task.reason, - priority: task.priority, - budget: task.budget, - dueAt: new Date(), - }, - }); + const created = client + ? await write(client) + : await this.db.$transaction(write); + if (!created) return false; this.logger.log({ message: "Agent task queued", @@ -241,44 +426,94 @@ export class AgentTriggerService { companyId: task.companyId, }); - this.poke(); + if (!client) this.poke(); + + return true; } catch (error) { this.logger.error( { message: "Could not queue agent task", kind: task.kind }, error instanceof Error ? error.stack : String(error), ); + if (required) throw error; + return false; } } + private async createEventTask( + tx: Prisma.TransactionClient, + input: CrmEventInput, + ): Promise { + const recordIds = { + contactId: input.record.kind === "contact" ? input.record.id : null, + companyId: input.record.kind === "company" ? input.record.id : null, + dealId: input.record.kind === "deal" ? input.record.id : null, + }; + await tx.agentTask.create({ + data: { + ...recordIds, + kind: "agent-event", + reason: input.type, + payload: { + type: input.type, + record: input.record, + occurredAt: input.occurredAt.toISOString(), + data: input.data, + }, + priority: PRIORITY.event, + budget: 1, + dueAt: new Date(), + }, + }); + } + + canReachAgent(): boolean { + return bridge() !== null; + } + + drainQueues(): void { + this.poke(); + this.deployedAgentRunQueued(); + this.builderConversationQueued(); + void this.redeliverCancellations(); + } + private poke(): void { this.pokeRoute("/internal/crm/dispatch"); } private pokeRoute(path: string): void { + void this.post(path); + } + + private async post( + path: string, + body?: Record, + ): Promise { const agent = bridge(); - if (!agent) return; + if (!agent) return false; - const missed = (error: unknown) => { + try { + const response = await fetch(agent.url(path), { + method: "POST", + headers: { + authorization: `Bearer ${agent.secret}`, + ...(body ? { "content-type": "application/json" } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(AGENT_DISPATCH.poke.timeoutMs), + }); + + if (!response.ok) { + throw new Error(`Agent poke returned ${response.status}.`); + } + + return true; + } catch (error) { this.logger.debug({ message: "Agent poke did not land; the cron will pick this up", reason: error instanceof Error ? error.message : String(error), }); - }; - - try { - void fetch(agent.url(path), { - method: "POST", - headers: { authorization: `Bearer ${agent.secret}` }, - signal: AbortSignal.timeout(POKE_TIMEOUT_MS), - }) - .then((response) => { - if (!response.ok) { - throw new Error(`Agent poke returned ${response.status}.`); - } - }) - .catch(missed); - } catch (error) { - missed(error); + return false; } } } diff --git a/apps/api/src/agent/agent.module.ts b/apps/api/src/agent/agent.module.ts index 2cdae2c24..cc700da83 100644 --- a/apps/api/src/agent/agent.module.ts +++ b/apps/api/src/agent/agent.module.ts @@ -6,6 +6,7 @@ import { AgentQueueService } from "./agent-queue.service"; import { AgentRunsService } from "./agent-runs.service"; import { AgentTriggerService } from "./agent-trigger.service"; import { AgentsRouter } from "./agents.router"; +import { DispatchHeartbeatService } from "./dispatch-heartbeat.service"; import { ResearchKeyService } from "./research-key.service"; @Module({ @@ -17,8 +18,14 @@ import { ResearchKeyService } from "./research-key.service"; AgentRunsService, AgentTriggerService, AgentsRouter, + DispatchHeartbeatService, + ResearchKeyService, + ], + exports: [ + AgentAccessService, + AgentTriggerService, + AgentQueueService, ResearchKeyService, ], - exports: [AgentTriggerService, AgentQueueService, ResearchKeyService], }) export class AgentModule {} diff --git a/apps/api/src/agent/agents.contracts.ts b/apps/api/src/agent/agents.contracts.ts index 967e30462..606904a7b 100644 --- a/apps/api/src/agent/agents.contracts.ts +++ b/apps/api/src/agent/agents.contracts.ts @@ -1,5 +1,8 @@ +import { schemas } from "@crm/validation"; import { z } from "zod"; +export const agentManifest = schemas.agents.capabilities.loose(); + export const agentIdInput = z.object({ id: z.string().min(1) }); export const agentHistoryInput = agentIdInput.extend({ @@ -19,9 +22,53 @@ export const agentRunNowInput = agentIdInput.extend({ export type AgentRunNowInput = z.infer; +export const agentRetryRunInput = agentIdInput.extend({ + runId: z.string().min(1), + clientRequestId: z.uuid(), +}); + +export type AgentRetryRunInput = z.infer; + +export const agentCancelRunInput = agentIdInput.extend({ + runId: z.string().min(1), +}); + +export type AgentCancelRunInput = z.infer; + export const agentDeployInput = agentIdInput.extend({ versionId: z.string().min(1), clientRequestId: z.uuid(), }); export type AgentDeployInput = z.infer; + +export const agentReviseInput = agentIdInput.extend({ + clientRequestId: z.uuid(), + channel: z + .object({ + id: z.string().trim().min(1).max(64), + name: z.string().trim().min(1).max(120), + }) + .optional(), + actions: z.array(z.string().trim().min(1).max(120)).max(20).optional(), + resources: z + .array( + z.object({ + id: z.string().trim().min(1).max(160), + kind: z.enum(["company", "contact", "deal", "integration"]), + label: z.string().trim().min(1).max(160), + }), + ) + .max(50) + .optional(), +}); + +export type AgentReviseInput = z.infer; + +export const agentSaveFileInput = agentIdInput.extend({ + clientRequestId: z.uuid(), + path: z.string().trim().min(1).max(400), + content: z.string().max(500_000), +}); + +export type AgentSaveFileInput = z.infer; diff --git a/apps/api/src/agent/agents.router.ts b/apps/api/src/agent/agents.router.ts index 0dec15a86..6e4f37859 100644 --- a/apps/api/src/agent/agents.router.ts +++ b/apps/api/src/agent/agents.router.ts @@ -13,10 +13,14 @@ import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; import { AgentDefinitionsService } from "./agent-definitions.service"; import { AgentRunsService } from "./agent-runs.service"; import { + agentCancelRunInput, agentDeployInput, agentHistoryInput, agentIdInput, + agentRetryRunInput, + agentReviseInput, agentRunNowInput, + agentSaveFileInput, agentUpdateInput, } from "./agents.contracts"; @@ -35,6 +39,27 @@ export class AgentsRouter { return this.agents.list(ctx.user.id); } + @Mutation({ input: agentReviseInput }) + async revise( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.agents.revise(input, ctx.user.id); + } + + @Query({ input: agentIdInput }) + async files(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { + return this.agents.files(id, ctx.user.id); + } + + @Mutation({ input: agentSaveFileInput }) + async saveFile( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.agents.saveFile(input, ctx.user.id); + } + @Query({ input: agentIdInput }) async byId(@Ctx() ctx: AuthedTrpcContext, @Input("id") id: string) { return this.agents.byId(id, ctx.user.id); @@ -104,4 +129,20 @@ export class AgentsRouter { ) { return this.runs.runNow(input, ctx.user.id); } + + @Mutation({ input: agentRetryRunInput }) + async retryRun( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.runs.retryRun(input, ctx.user.id); + } + + @Mutation({ input: agentCancelRunInput }) + async cancelRun( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.runs.cancelRun(input, ctx.user.id); + } } diff --git a/apps/api/src/agent/dispatch-heartbeat.service.ts b/apps/api/src/agent/dispatch-heartbeat.service.ts new file mode 100644 index 000000000..ffcedaf2d --- /dev/null +++ b/apps/api/src/agent/dispatch-heartbeat.service.ts @@ -0,0 +1,40 @@ +import { + Injectable, + Logger, + type OnApplicationBootstrap, + type OnApplicationShutdown, +} from "@nestjs/common"; +import { AGENT_DISPATCH } from "./agent-dispatch.config"; +import { AgentTriggerService } from "./agent-trigger.service"; + +@Injectable() +export class DispatchHeartbeatService + implements OnApplicationBootstrap, OnApplicationShutdown +{ + private readonly logger = new Logger(DispatchHeartbeatService.name); + private timer: ReturnType | null = null; + + constructor(private readonly trigger: AgentTriggerService) {} + + onApplicationBootstrap(): void { + if (!this.trigger.canReachAgent()) { + this.logger.log({ + message: + "No agent bridge secret, so queued work waits for the agent's own schedule.", + }); + return; + } + + this.trigger.drainQueues(); + this.timer = setInterval( + () => this.trigger.drainQueues(), + AGENT_DISPATCH.heartbeat.everyMs, + ); + this.timer.unref?.(); + } + + onApplicationShutdown(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a6bd22dc6..fb4178fe5 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -25,6 +25,7 @@ import { MailboxModule } from "./mailbox/mailbox.module"; import { MicrosoftModule } from "./microsoft/microsoft.module"; import { SearchModule } from "./search/search.module"; import { SettingsModule } from "./settings/settings.module"; +import { SlackModule } from "./slack/slack.module"; import { SsoModule } from "./sso/sso.module"; import { SyncModule } from "./sync/sync.module"; import { TelemetryModule } from "./telemetry/telemetry.module"; @@ -66,6 +67,7 @@ import { WorkspaceModule } from "./workspace/workspace.module"; SettingsModule, WorkspaceModule, SsoModule, + SlackModule, BackfillModule, TelemetryModule, TrackingModule, diff --git a/apps/api/src/companies/companies.service.ts b/apps/api/src/companies/companies.service.ts index 3f808b0a7..80e79dc7e 100644 --- a/apps/api/src/companies/companies.service.ts +++ b/apps/api/src/companies/companies.service.ts @@ -296,14 +296,23 @@ export class CompaniesService { } } - const company = await this.db.company.create({ - data: { - name: input.name.trim(), - domain, - website: domain ? `https://${domain}` : null, - ownerId: input.ownerId ?? null, - }, - select: { id: true, name: true, domain: true }, + const company = await this.agent.withCrmEvents(async (tx, emit) => { + const created = await tx.company.create({ + data: { + name: input.name.trim(), + domain, + website: domain ? `https://${domain}` : null, + ownerId: input.ownerId ?? null, + }, + select: { id: true, name: true, domain: true, createdAt: true }, + }); + await emit({ + type: "company.created", + record: { kind: "company", id: created.id }, + occurredAt: created.createdAt, + data: { name: created.name, domain: created.domain }, + }); + return created; }); this.logger.log({ @@ -316,7 +325,7 @@ export class CompaniesService { void this.favicon.backfill(company.id, company.domain); - return company; + return { id: company.id, name: company.name, domain: company.domain }; } async update(id: string, input: CompanyUpdateInput) { diff --git a/apps/api/src/companies/company-directory.service.ts b/apps/api/src/companies/company-directory.service.ts index 96f0b79e7..abb3e97e8 100644 --- a/apps/api/src/companies/company-directory.service.ts +++ b/apps/api/src/companies/company-directory.service.ts @@ -1,17 +1,14 @@ -import { type Db, EnrichmentStatus } from "@crm/db"; +import { EnrichmentStatus } from "@crm/db"; +import { lockIdempotencyKey } from "@crm/db/idempotency"; import { Injectable, Logger } from "@nestjs/common"; import { AgentTriggerService } from "../agent/agent-trigger.service"; -import { InjectDatabase } from "../database/database.constants"; import { domainFromEmail } from "./domain"; @Injectable() export class CompanyDirectoryService { private readonly logger = new Logger(CompanyDirectoryService.name); - constructor( - @InjectDatabase() private readonly db: Db, - private readonly agent: AgentTriggerService, - ) {} + constructor(private readonly agent: AgentTriggerService) {} async companyForEmail( email: string, @@ -20,36 +17,45 @@ export class CompanyDirectoryService { const domain = domainFromEmail(email); if (!domain) return null; - const existing = await this.db.company.findUnique({ - where: { domain }, - select: { id: true }, - }); - if (existing) return existing.id; - - const company = await this.db.company.upsert({ - where: { domain }, - create: { - name: domain, - domain, - website: `https://${domain}`, - enrichmentStatus: EnrichmentStatus.PENDING, - ownerId: options.ownerId ?? null, - }, - update: {}, - select: { id: true }, + const outcome = await this.agent.withCrmEvents(async (tx, emit) => { + await lockIdempotencyKey(tx, `company-directory:${domain}`); + const existing = await tx.company.findUnique({ + where: { domain }, + select: { id: true }, + }); + if (existing) return { id: existing.id, created: false as const }; + + const company = await tx.company.create({ + data: { + name: domain, + domain, + website: `https://${domain}`, + enrichmentStatus: EnrichmentStatus.PENDING, + ownerId: options.ownerId ?? null, + }, + select: { id: true, name: true, domain: true, createdAt: true }, + }); + await emit({ + type: "company.created", + record: { kind: "company", id: company.id }, + occurredAt: company.createdAt, + data: { name: company.name, domain: company.domain }, + }); + return { id: company.id, created: true as const }; }); + if (!outcome.created) return outcome.id; await this.agent.companyCreated( - company.id, + outcome.id, `Created from an email domain (${domain}) — it has no name but the domain`, ); this.logger.log({ message: "Company created from an email domain", - companyId: company.id, + companyId: outcome.id, domain, }); - return company.id; + return outcome.id; } } diff --git a/apps/api/src/config/env.validation.ts b/apps/api/src/config/env.validation.ts index 06c76c4bc..2f81dc723 100644 --- a/apps/api/src/config/env.validation.ts +++ b/apps/api/src/config/env.validation.ts @@ -68,6 +68,14 @@ export class EnvironmentVariables { @IsString() MICROSOFT_TENANT_ID?: string; + @IsOptional() + @IsString() + SLACK_CLIENT_ID?: string; + + @IsOptional() + @IsString() + SLACK_CLIENT_SECRET?: string; + @IsOptional() @IsUrl({ require_tld: false }) API_URL?: string; diff --git a/apps/api/src/contacts/contacts.service.ts b/apps/api/src/contacts/contacts.service.ts index 9060cc3bb..6518d060f 100644 --- a/apps/api/src/contacts/contacts.service.ts +++ b/apps/api/src/contacts/contacts.service.ts @@ -295,10 +295,10 @@ export class ContactsService { }) : null); - const contact = await this.db.$transaction(async (tx) => { + const contact = await this.agent.withCrmEvents(async (tx, emit) => { await this.allowAgain(tx, email); - return tx.contact.create({ + const created = await tx.contact.create({ data: { firstName: input.firstName.trim(), lastName: blankToNull(input.lastName ?? ""), @@ -308,8 +308,27 @@ export class ContactsService { companyId, ownerId: input.ownerId ?? null, }, - select: { id: true, firstName: true, lastName: true }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + companyId: true, + createdAt: true, + }, + }); + await emit({ + type: "contact.created", + record: { kind: "contact", id: created.id }, + occurredAt: created.createdAt, + data: { + firstName: created.firstName, + lastName: created.lastName, + email: created.email, + companyId: created.companyId, + }, }); + return created; }); this.logger.log({ message: "Contact created", contactId: contact.id }); @@ -319,7 +338,11 @@ export class ContactsService { "Added by a rep, with nothing on the record yet", ); - return contact; + return { + id: contact.id, + firstName: contact.firstName, + lastName: contact.lastName, + }; } async delete(id: string): Promise<{ id: string; name: string }> { diff --git a/apps/api/src/conversations/conversation-sharing.service.ts b/apps/api/src/conversations/conversation-sharing.service.ts index f63c75d2c..3f742acfa 100644 --- a/apps/api/src/conversations/conversation-sharing.service.ts +++ b/apps/api/src/conversations/conversation-sharing.service.ts @@ -140,14 +140,19 @@ export class ConversationSharingService { } const { conversation } = share; - const events = conversation.sessionId - ? await this.db.agentEvent.findMany({ - where: { sessionId: conversation.sessionId }, - orderBy: [{ emittedAt: "desc" }, { id: "desc" }], - take: 5000, - select: { id: true, type: true, data: true, emittedAt: true }, - }) - : []; + const events = await this.db.agentEvent.findMany({ + where: { + OR: [ + { conversationId: conversation.id }, + ...(conversation.sessionId + ? [{ sessionId: conversation.sessionId }] + : []), + ], + }, + orderBy: [{ emittedAt: "desc" }, { id: "desc" }], + take: 5000, + select: { id: true, type: true, data: true, emittedAt: true }, + }); return { id: conversation.id, diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index 55a2665df..5be1d3ad2 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -179,7 +179,7 @@ export class ConversationsService { ? { contains: search, mode: "insensitive" as const } : undefined; - const [companies, contacts, deals] = await Promise.all([ + const [companies, contacts, deals, slackAccount] = await Promise.all([ this.db.company.findMany({ where: contains ? { name: contains } : undefined, orderBy: { lastActivityAt: { sort: "desc", nulls: "last" } }, @@ -217,9 +217,24 @@ export class ConversationsService { company: { select: { name: true, logoUrl: true } }, }, }), + this.db.account.findFirst({ + where: { providerId: "slack", accessToken: { not: null } }, + select: { id: true }, + }), ]); return [ + ...(slackAccount && (!search || "slack".includes(search.toLowerCase())) + ? [ + { + kind: "integration" as const, + id: "slack:workspace", + label: "Slack", + detail: "Connected workspace", + imageUrl: null, + }, + ] + : []), ...companies.map((company) => ({ kind: "company" as const, id: company.id, @@ -252,6 +267,7 @@ export class ConversationsService { id: true, sessionId: true, continuationToken: true, + pendingInputRequest: true, streamIndex: true, title: true, messageCount: true, @@ -354,8 +370,11 @@ export class ConversationsService { throw new NotFoundException(`No builder conversation with id ${id}.`); } + const { pendingInputRequest, ...conversation } = row; + return { - ...row, + ...conversation, + pendingQuestion: pendingBuilderQuestionOf(pendingInputRequest), lastMessageAt: row.lastMessageAt.toISOString(), lastAssistantAt: row.lastAssistantAt?.toISOString() ?? null, lastReadAt: row.lastReadAt?.toISOString() ?? null, @@ -511,7 +530,12 @@ export class ConversationsService { const conversation = await this.db.agentConversation.findFirst({ where: { id: input.id, userId, kind: "BUILDER" }, - select: { id: true, sessionId: true, continuationToken: true }, + select: { + id: true, + sessionId: true, + continuationToken: true, + pendingInputRequest: true, + }, }); if (!conversation) { @@ -526,42 +550,19 @@ export class ConversationsService { ); } - const boundary = await this.db.agentEvent.findFirst({ - where: { - sessionId: conversation.sessionId, - type: { - in: [ - "input.requested", - "message.received", - "turn.cancelled", - "session.completed", - "session.failed", - ], - }, - }, - orderBy: [{ emittedAt: "desc" }, { id: "desc" }], - select: { type: true, data: true }, - }); - - if (boundary?.type !== "input.requested") { + const question = pendingBuilderQuestionOf(conversation.pendingInputRequest); + if (!question) { throw new BadRequestException( "The agent is no longer waiting for that answer.", ); } - - const requests = arrayOf(recordOf(boundary.data).requests).map(recordOf); - const question = requests.find( - (request) => - request.kind === "question" && request.requestId === input.requestId, - ); - - if (!question) { + if (question.requestId !== input.requestId) { throw new BadRequestException( "That follow-up question is no longer active.", ); } - const options = arrayOf(question.options).map(recordOf); + const options = question.options; const selected = input.optionId ? options.find((option) => option.id === input.optionId) : null; @@ -572,10 +573,7 @@ export class ConversationsService { ); } - const acceptsText = - question.allowFreeform === true || - question.display === "text" || - options.length === 0; + const acceptsText = question.allowFreeform || question.display === "text"; if (input.text && !acceptsText) { throw new BadRequestException( "Choose one of the available answers for this question.", @@ -589,6 +587,9 @@ export class ConversationsService { const displayText = typeof selected?.label === "string" ? selected.label : answer; + const inputResponse = input.optionId + ? { requestId: input.requestId, optionId: input.optionId } + : { requestId: input.requestId, text: input.text }; try { const submission = await this.db.$transaction(async (tx) => { const created = await tx.agentConversationSubmission.create({ @@ -597,12 +598,12 @@ export class ConversationsService { submittedById: userId, clientRequestId: input.clientRequestId, inputRequestId: input.requestId, - commandType: "CHAT", + commandType: "CREATE_AGENT", message: { text: displayText, resources: [], attachments: [], - inputResponse: { requestId: input.requestId, answer }, + inputResponse, }, }, select: { id: true }, @@ -873,10 +874,22 @@ export class ConversationsService { await this.assertWorkspaceMember(userId); } - if (!conversation.sessionId) return []; + const eventWhere: Prisma.AgentEventWhereInput = + conversation.kind === "BUILDER" + ? { + OR: [ + { conversationId: input.id }, + ...(conversation.sessionId + ? [{ sessionId: conversation.sessionId }] + : []), + ], + } + : conversation.sessionId + ? { sessionId: conversation.sessionId } + : { id: { in: [] } }; const events = await this.db.agentEvent.findMany({ - where: { sessionId: conversation.sessionId }, + where: eventWhere, orderBy: [{ emittedAt: "desc" }, { id: "desc" }], take: input.limit, select: { id: true, type: true, data: true, emittedAt: true }, @@ -911,11 +924,16 @@ export class ConversationsService { await tx.agentBuilderArtifact.deleteMany({ where: { conversationId: id, versionId: null }, }); - if (conversation.sessionId) { - await tx.agentEvent.deleteMany({ - where: { sessionId: conversation.sessionId }, - }); - } + await tx.agentEvent.deleteMany({ + where: { + OR: [ + { conversationId: id }, + ...(conversation.sessionId + ? [{ sessionId: conversation.sessionId }] + : []), + ], + }, + }); await tx.agentConversation.delete({ where: { id } }); }); @@ -1107,3 +1125,61 @@ function recordOf(value: unknown): Record { function arrayOf(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } + +function pendingBuilderQuestionOf(value: unknown) { + const request = recordOf(value); + if ( + request.kind !== "question" || + typeof request.requestId !== "string" || + !request.requestId || + typeof request.prompt !== "string" || + !request.prompt + ) { + return null; + } + + const display = ["confirmation", "select", "text"].includes( + String(request.display), + ) + ? (request.display as "confirmation" | "select" | "text") + : undefined; + const options = arrayOf(request.options).flatMap((value) => { + const option = recordOf(value); + if ( + typeof option.id !== "string" || + !option.id || + typeof option.label !== "string" || + !option.label + ) { + return []; + } + const style = ["danger", "default", "primary"].includes( + String(option.style), + ) + ? (option.style as "danger" | "default" | "primary") + : undefined; + + return [ + { + id: option.id, + label: option.label, + ...(typeof option.description === "string" + ? { description: option.description } + : {}), + ...(style ? { style } : {}), + }, + ]; + }); + + return { + kind: "question" as const, + requestId: request.requestId, + prompt: request.prompt, + ...(display ? { display } : {}), + options, + allowFreeform: + request.allowFreeform === true || + display === "text" || + options.length === 0, + }; +} diff --git a/apps/api/src/currency/currency.service.ts b/apps/api/src/currency/currency.service.ts index 408708209..20bd74a34 100644 --- a/apps/api/src/currency/currency.service.ts +++ b/apps/api/src/currency/currency.service.ts @@ -1,9 +1,4 @@ -import { - canManageCurrency, - isWorkspaceRole, - WORKSPACE_ID, - type WorkspaceRole, -} from "@crm/auth"; +import { canManageCurrency, workspaceRoleOf } from "@crm/auth"; import type { Db } from "@crm/db"; import { Prisma, RateSource } from "@crm/db"; import { @@ -133,25 +128,12 @@ export class CurrencyService { ), unconverted, catalog: [...CURRENCIES], - canManage: canManageCurrency(await this.roleOf(actingUserId)), + canManage: canManageCurrency(await workspaceRoleOf(actingUserId)), }; } - private async roleOf(userId: string): Promise { - const member = await this.db.member.findUnique({ - where: { - organizationId_userId: { organizationId: WORKSPACE_ID, userId }, - }, - select: { role: true }, - }); - - if (!member) return null; - - return isWorkspaceRole(member.role) ? member.role : "member"; - } - private async requireManager(userId: string): Promise { - if (!canManageCurrency(await this.roleOf(userId))) { + if (!canManageCurrency(await workspaceRoleOf(userId))) { throw new ForbiddenException( "Only an owner or an admin can change how money is reported.", ); diff --git a/apps/api/src/deals/deals.module.ts b/apps/api/src/deals/deals.module.ts index 97c1ac849..310aed015 100644 --- a/apps/api/src/deals/deals.module.ts +++ b/apps/api/src/deals/deals.module.ts @@ -1,4 +1,5 @@ import { Module } from "@nestjs/common"; +import { AgentModule } from "../agent/agent.module"; import { CurrencyModule } from "../currency/currency.module"; import { FieldsModule } from "../fields/fields.module"; import { TrpcModule } from "../trpc/trpc.module"; @@ -6,7 +7,7 @@ import { DealsRouter } from "./deals.router"; import { DealsService } from "./deals.service"; @Module({ - imports: [FieldsModule, TrpcModule, CurrencyModule], + imports: [AgentModule, FieldsModule, TrpcModule, CurrencyModule], providers: [DealsService, DealsRouter], exports: [DealsService], }) diff --git a/apps/api/src/deals/deals.service.ts b/apps/api/src/deals/deals.service.ts index 2dbcc5a38..5bb09fecf 100644 --- a/apps/api/src/deals/deals.service.ts +++ b/apps/api/src/deals/deals.service.ts @@ -18,6 +18,7 @@ import { Logger, NotFoundException, } from "@nestjs/common"; +import { AgentTriggerService } from "../agent/agent-trigger.service"; import { ActivityStampService, type StampTargets, @@ -102,6 +103,7 @@ export class DealsService { constructor( @InjectDatabase() private readonly db: Db, + private readonly agent: AgentTriggerService, private readonly stamp: ActivityStampService, private readonly conversion: ConversionService, private readonly fields: FieldsService, @@ -246,20 +248,37 @@ export class DealsService { ); try { - const deal = await this.db.deal.create({ - data: { - name: input.name.trim(), - companyId: input.companyId, - ownerId: input.ownerId, - stage, - stageChangedAt: now, - closedAt: closed ? now : null, - amount: fromCents(input.amountCents), - currency, - ...fx, - expectedCloseDate: parseDate(input.expectedCloseDate), - }, - select: { id: true, name: true, companyId: true }, + const deal = await this.agent.withCrmEvents(async (tx, emit) => { + const created = await tx.deal.create({ + data: { + name: input.name.trim(), + companyId: input.companyId, + ownerId: input.ownerId, + stage, + stageChangedAt: now, + closedAt: closed ? now : null, + amount: fromCents(input.amountCents), + currency, + ...fx, + expectedCloseDate: parseDate(input.expectedCloseDate), + }, + select: { id: true, name: true, companyId: true }, + }); + await emit({ + type: "deal.created", + record: { kind: "deal", id: created.id }, + occurredAt: now, + data: { companyId: created.companyId, stage }, + }); + if (closed) { + await emit({ + type: "deal.closed", + record: { kind: "deal", id: created.id }, + occurredAt: now, + data: { companyId: created.companyId, from: null, to: stage }, + }); + } + return created; }); this.logger.log({ message: "Deal created", dealId: deal.id, stage }); @@ -339,6 +358,7 @@ export class DealsService { try { deleted = await this.db.$transaction(async (tx) => { const targets = await this.stamp.targetsOf({ dealId: id }, tx); + await tx.agentTask.deleteMany({ where: { dealId: id } }); const deal = await tx.deal.delete({ where: { id }, @@ -363,31 +383,38 @@ export class DealsService { } async setStage(input: SetStageInput, actingUserId: string) { - const deal = await this.db.deal.findUnique({ - where: { id: input.id }, - select: { id: true, stage: true, companyId: true }, - }); - - if (!deal) { - throw new NotFoundException(`No deal with id ${input.id}.`); - } - - if (deal.stage === input.stage) { - return { id: deal.id, stage: deal.stage, changed: false }; - } - const closedReason = input.closedReason?.trim(); - if (LOSING.has(input.stage) && !closedReason) { - throw new BadRequestException( - "Say why it was lost — a closed-lost deal with no reason teaches nobody anything.", - ); - } - - const now = new Date(); const closed = isClosedStage(input.stage); + const transition = await this.agent.withCrmEvents(async (tx, emit) => { + const [deal] = await tx.$queryRaw< + Array<{ id: string; stage: DealStage; companyId: string }> + >` + SELECT id, stage, "companyId" + FROM deal + WHERE id = ${input.id} + FOR UPDATE + `; + + if (!deal) { + throw new NotFoundException(`No deal with id ${input.id}.`); + } + + if (deal.stage === input.stage) { + return { + changed: false as const, + deal, + updated: { id: deal.id, stage: deal.stage }, + now: null, + }; + } + if (LOSING.has(input.stage) && !closedReason) { + throw new BadRequestException( + "Say why it was lost — a closed-lost deal with no reason teaches nobody anything.", + ); + } - const [updated] = await this.db.$transaction([ - this.db.deal.update({ + const now = new Date(); + const updated = await tx.deal.update({ where: { id: input.id }, data: { stage: input.stage, @@ -396,8 +423,8 @@ export class DealsService { closedReason: closed ? (closedReason ?? null) : null, }, select: { id: true, stage: true }, - }), - this.db.activity.create({ + }); + await tx.activity.create({ data: { type: ActivityType.STAGE_CHANGE, subject: "Stage changed", @@ -408,13 +435,48 @@ export class DealsService { createdById: actingUserId, meta: { from: deal.stage, to: input.stage }, }, - }), - ]); + }); + await emit({ + type: "deal.stage.changed", + record: { kind: "deal", id: deal.id }, + occurredAt: now, + data: { companyId: deal.companyId, from: deal.stage, to: input.stage }, + }); + if (!isClosedStage(deal.stage) && closed) { + await emit({ + type: "deal.closed", + record: { kind: "deal", id: deal.id }, + occurredAt: now, + data: { + companyId: deal.companyId, + from: deal.stage, + to: input.stage, + }, + }); + } + if (isClosedStage(deal.stage) && !closed) { + await emit({ + type: "deal.opened", + record: { kind: "deal", id: deal.id }, + occurredAt: now, + data: { + companyId: deal.companyId, + from: deal.stage, + to: input.stage, + }, + }); + } - await this.stamp.touch( - { companyId: deal.companyId, dealId: deal.id }, - new Date(), - ); + return { changed: true as const, deal, updated, now }; + }); + + if (!transition.changed) { + return { ...transition.updated, changed: false }; + } + + const { deal, updated, now } = transition; + + await this.stamp.touch({ companyId: deal.companyId, dealId: deal.id }, now); this.logger.log({ message: "Deal stage changed", diff --git a/apps/api/src/generated/server.ts b/apps/api/src/generated/server.ts index 8cc4b9503..b42b0326b 100644 --- a/apps/api/src/generated/server.ts +++ b/apps/api/src/generated/server.ts @@ -14,7 +14,7 @@ import { z } from "zod"; const t = initTRPC.create(); const publicProcedure = t.procedure; import { timelineInput, timelineCountsInput, myTasksInput, activityCreateInput, completeInput } from "../activities/activities.contracts"; -import { agentIdInput, agentHistoryInput, agentUpdateInput, agentDeployInput, agentRunNowInput } from "../agent/agents.contracts"; +import { agentReviseInput, agentIdInput, agentSaveFileInput, agentHistoryInput, agentUpdateInput, agentDeployInput, agentRunNowInput, agentRetryRunInput, agentCancelRunInput } from "../agent/agents.contracts"; import { companyListInput, companyIdInput, companyOptionsInput, companyCreateInput, companyUpdateArgs, companyBulkOwnerInput, companyBulkInput, setPrimaryContactInput } from "../companies/companies.contracts"; import { contactListInput, contactIdInput, contactCreateInput, contactUpdateArgs, contactBulkOwnerInput, contactBulkCompanyInput, contactBulkInput, factDecisionInput } from "../contacts/contacts.contracts"; import { conversationListInput, builderResourceSearchInput, conversationIdInput, conversationEventsInput, conversationSaveInput, builderConversationCreateInput, builderConversationSubmitInput, builderQuestionResponseInput, builderResponseRatingInput, sharedConversationInput } from "../conversations/conversations.contracts"; @@ -25,6 +25,7 @@ import { fieldListInput, fieldByKeyInput, fieldIdInput, fieldCreateInput, fieldU import { setAutoCreateInput, suppressDomainInput, threadInput, calendarEventInput } from "../google/google.contracts"; import { setOutlookAutoCreateInput } from "../microsoft/microsoft.contracts"; import { setAgentModelInput, setResearchKeyInput } from "../settings/settings.contracts"; +import { slackChannelsInput, slackJoinChannelInput, slackCreateChannelInput } from "../slack/slack.contracts"; import { ssoProviderListInput, registerSsoProviderInput, deleteSsoProviderInput } from "../sso/sso.contracts"; import { trackingFlagInput, cookieLifetimeInput, addDomainInput, removeDomainInput, verifyInput, companyActivityInput, contactActivityInput } from "../tracking/tracking.contracts"; import { memberListInput, updateWorkspaceInput, setMemberRoleInput } from "../workspace/workspace.contracts"; @@ -41,6 +42,7 @@ import type { GoogleRouter } from "../google/google.router"; import type { MicrosoftRouter } from "../microsoft/microsoft.router"; import type { SearchRouter } from "../search/search.router"; import type { SettingsRouter } from "../settings/settings.router"; +import type { SlackRouter } from "../slack/slack.router"; import type { SsoRouter } from "../sso/sso.router"; import type { TrackingRouter } from "../tracking/tracking.router"; import type { UsersRouter } from "../users/users.router"; @@ -67,6 +69,15 @@ const appRouter = t.router({ agents: t.router({ list: publicProcedure .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + revise: publicProcedure + .input(agentReviseInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + files: publicProcedure + .input(agentIdInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + saveFile: publicProcedure + .input(agentSaveFileInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), byId: publicProcedure .input(agentIdInput) .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), @@ -99,7 +110,13 @@ const appRouter = t.router({ .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), runNow: publicProcedure .input(agentRunNowInput) - .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + retryRun: publicProcedure + .input(agentRetryRunInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + cancelRun: publicProcedure + .input(agentCancelRunInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) }), companies: t.router({ list: publicProcedure @@ -370,6 +387,25 @@ const appRouter = t.router({ .input(setResearchKeyInput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) }), + slack: t.router({ + status: publicProcedure + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + matches: publicProcedure + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + channels: publicProcedure + .input(slackChannelsInput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + joinChannel: publicProcedure + .input(slackJoinChannelInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + refreshPeople: publicProcedure + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + createChannel: publicProcedure + .input(slackCreateChannelInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + disconnect: publicProcedure + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>) + }), sso: t.router({ signInOptions: publicProcedure .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), diff --git a/apps/api/src/mailbox/mailbox-match.service.ts b/apps/api/src/mailbox/mailbox-match.service.ts index eaf77b2af..436e6b5fd 100644 --- a/apps/api/src/mailbox/mailbox-match.service.ts +++ b/apps/api/src/mailbox/mailbox-match.service.ts @@ -1,5 +1,6 @@ import { workspaceDomains } from "@crm/auth/workspace"; import { type Db, RecordSource } from "@crm/db"; +import { lockIdempotencyKey } from "@crm/db/idempotency"; import { Injectable, Logger } from "@nestjs/common"; import { AgentTriggerService } from "../agent/agent-trigger.service"; import { CompanyDirectoryService } from "../companies/company-directory.service"; @@ -213,26 +214,56 @@ export class MailboxMatchService { const { firstName, lastName } = splitName(person.name, person.email); - const existing = await this.db.contact.findUnique({ - where: { email: person.email }, - select: { id: true }, - }); - - const contact = await this.db.contact.upsert({ - where: { email: person.email }, - create: { - firstName, - lastName, - email: person.email, - companyId, - source: request.source, - ownerId: request.ownerId, - }, - update: {}, - select: { id: true, firstName: true, lastName: true }, + const outcome = await this.agent.withCrmEvents(async (tx, emit) => { + await lockIdempotencyKey(tx, `mailbox-contact:${person.email}`); + const existing = await tx.contact.findUnique({ + where: { email: person.email }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + companyId: true, + createdAt: true, + }, + }); + if (existing) return { contact: existing, created: false as const }; + + const contact = await tx.contact.create({ + data: { + firstName, + lastName, + email: person.email, + companyId, + source: request.source, + ownerId: request.ownerId, + }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + companyId: true, + createdAt: true, + }, + }); + await emit({ + type: "contact.created", + record: { kind: "contact", id: contact.id }, + occurredAt: contact.createdAt, + data: { + firstName: contact.firstName, + lastName: contact.lastName, + email: contact.email, + companyId: contact.companyId, + source: request.source, + }, + }); + return { contact, created: true as const }; }); + const { contact } = outcome; - if (!existing) { + if (outcome.created) { await this.log.record({ contactId: contact.id, companyId, diff --git a/apps/api/src/slack/slack-channels.service.ts b/apps/api/src/slack/slack-channels.service.ts new file mode 100644 index 000000000..9c752292e --- /dev/null +++ b/apps/api/src/slack/slack-channels.service.ts @@ -0,0 +1,89 @@ +import { + BadRequestException, + Injectable, + Logger, + ServiceUnavailableException, +} from "@nestjs/common"; +import { bridge } from "../agent/bridge"; +import { slackCreateChannelReply } from "./slack.contracts"; + +const CREATE_TIMEOUT_MS = 20_000; + +const SERVER_ERROR_STATUS = 500; + +@Injectable() +export class SlackChannelsService { + private readonly logger = new Logger(SlackChannelsService.name); + + async create(name: string, isPrivate: boolean) { + const agent = bridge(); + + if (!agent) { + throw new ServiceUnavailableException( + "This install has no AGENT_BRIDGE_SECRET, so nothing can reach Slack.", + ); + } + + let response: Response; + + try { + response = await fetch(agent.url("/internal/crm/slack/create-channel"), { + method: "POST", + headers: { + authorization: `Bearer ${agent.secret}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + type: "slack.channel.create", + channelName: name, + isPrivate, + }), + signal: AbortSignal.timeout(CREATE_TIMEOUT_MS), + }); + } catch (error) { + this.logger.error( + { message: "Could not reach the agent to create a channel", name }, + error instanceof Error ? error.stack : String(error), + ); + throw new ServiceUnavailableException( + "The agent is not answering, so the channel was not created.", + ); + } + + if (response.status >= SERVER_ERROR_STATUS) { + this.logger.error({ + message: "The agent failed while creating a channel", + name, + status: response.status, + }); + throw new ServiceUnavailableException( + "The agent failed, so the channel was not created.", + ); + } + + const reply = slackCreateChannelReply.safeParse( + await response.json().catch(() => null), + ); + + if (!reply.success) { + this.logger.error({ + message: "The agent returned an unreadable channel reply", + name, + status: response.status, + }); + throw new ServiceUnavailableException( + "The agent answered with something unreadable, so the channel was not created.", + ); + } + + if ("error" in reply.data) { + throw new BadRequestException(reply.data.error); + } + + if (!response.ok) { + throw new BadRequestException("Slack refused to create that channel."); + } + + return { channel: reply.data.channel }; + } +} diff --git a/apps/api/src/slack/slack-config.ts b/apps/api/src/slack/slack-config.ts new file mode 100644 index 000000000..324087188 --- /dev/null +++ b/apps/api/src/slack/slack-config.ts @@ -0,0 +1,17 @@ +const SECOND_MS = 1_000; +const MINUTE_MS = 60 * SECOND_MS; + +export const SLACK = { + sync: { + activeMs: 30 * SECOND_MS, + stalledAfterMs: 3 * MINUTE_MS, + }, + channels: { + pageSize: 50, + maxPageSize: 100, + }, +} as const; + +export const SLACK_SYNC_STATES = ["idle", "syncing", "stalled"] as const; + +export type SlackSyncState = (typeof SLACK_SYNC_STATES)[number]; diff --git a/apps/api/src/slack/slack-connection.service.ts b/apps/api/src/slack/slack-connection.service.ts new file mode 100644 index 000000000..b58e3d4d5 --- /dev/null +++ b/apps/api/src/slack/slack-connection.service.ts @@ -0,0 +1,278 @@ +import { + canManageConnections, + isSlackConfigured, + WORKSPACE_ID, +} from "@crm/auth"; +import type { Db } from "@crm/db"; +import { schemas } from "@crm/validation"; +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { AgentAccessService } from "../agent/agent-access.service"; +import { AgentTriggerService } from "../agent/agent-trigger.service"; +import { InjectDatabase } from "../database/database.constants"; +import type { + SlackChannelsInput, + SlackCreateChannelInput, + SlackJoinChannelInput, +} from "./slack.contracts"; +import { SlackChannelsService } from "./slack-channels.service"; +import { SLACK, type SlackSyncState } from "./slack-config"; + +const SLACK_WORKSPACE_RESOURCE_ID = + schemas.agents.CAPABILITY_RESOURCE_IDS.slack; + +@Injectable() +export class SlackConnectionService { + constructor( + @InjectDatabase() private readonly db: Db, + private readonly agent: AgentTriggerService, + private readonly slackChannels: SlackChannelsService, + private readonly access: AgentAccessService, + ) {} + + async status(userId: string) { + const role = await this.access.assertMember(userId); + const [account, agents, matches, memberCount, grant] = await Promise.all([ + this.db.account.findFirst({ + where: { providerId: "slack", accessToken: { not: null } }, + orderBy: { updatedAt: "desc" }, + select: { accountId: true, updatedAt: true, scope: true }, + }), + this.db.agentDefinition.findMany({ + where: { + status: { in: ["LIVE", "PAUSED"] }, + deletedAt: null, + currentVersionId: { not: null }, + currentVersion: { + manifest: { + path: ["dataScope", "resources"], + array_contains: [{ id: SLACK_WORKSPACE_RESOURCE_ID }], + }, + }, + }, + orderBy: { updatedAt: "desc" }, + take: 30, + select: { id: true, name: true, description: true, status: true }, + }), + this.db.slackMemberMatch.findMany({ + where: { + crmUser: { members: { some: { organizationId: WORKSPACE_ID } } }, + }, + select: { slackUserId: true, updatedAt: true }, + }), + this.db.member.count({ where: { organizationId: WORKSPACE_ID } }), + this.db.slackWorkspaceGrant.findFirst({ + select: { id: true, teamName: true }, + }), + ]); + + const matched = matches.filter((match) => match.slackUserId).length; + const reviewed = matches.length; + const inventoryFresh = + account && + reviewed === memberCount && + matches.every((match) => match.updatedAt >= account.updatedAt); + if (account && !inventoryFresh) { + await this.agent.slackPeopleRequested( + "Match workspace members to Slack accounts by exact email", + ); + } + + return { + configured: isSlackConfigured(), + connected: Boolean(account), + workspace: account ? (grant?.teamName ?? null) : null, + lastConnectedAt: account?.updatedAt.toISOString() ?? null, + scopes: (account?.scope ?? "") + .split(",") + .map((scope) => scope.trim()) + .filter(Boolean), + canInviteItself: Boolean(grant), + canManage: canManageConnections(role), + agents, + people: { matched, reviewed }, + }; + } + + async matches(userId: string) { + await this.access.assertMember(userId); + const [members, syncing] = await Promise.all([ + this.db.member.findMany({ + where: { organizationId: WORKSPACE_ID }, + orderBy: { user: { name: "asc" } }, + select: { + user: { + select: { + id: true, + name: true, + email: true, + slackMemberMatch: { + select: { + slackUserId: true, + slackHandle: true, + slackEmail: true, + }, + }, + }, + }, + }, + }), + this.peopleSyncState(), + ]); + + return { + rows: members.map(({ user }) => ({ + crmUserId: user.id, + name: user.name, + email: user.email, + match: user.slackMemberMatch, + })), + sync: syncing, + }; + } + + private async peopleSyncState(): Promise { + const pending = await this.db.agentTask.findFirst({ + where: { kind: "slack-people-match", finishedAt: null }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true, startedAt: true, leasedUntil: true }, + }); + if (!pending) return "idle"; + + const now = Date.now(); + const leaseHeld = pending.leasedUntil + ? pending.leasedUntil.getTime() > now + : false; + if (leaseHeld || pending.startedAt) return "syncing"; + + return now - pending.createdAt.getTime() < SLACK.sync.stalledAfterMs + ? "syncing" + : "stalled"; + } + + async refreshPeople(userId: string) { + await this.access.assertMember(userId); + const account = await this.db.account.findFirst({ + where: { providerId: "slack", accessToken: { not: null } }, + orderBy: { updatedAt: "desc" }, + select: { id: true }, + }); + if (!account) throw new NotFoundException("Slack is not connected."); + + await this.agent.slackPeopleRequested( + "Refresh Slack people and channels from the connection page", + true, + ); + + return { requested: true }; + } + + async channels(input: SlackChannelsInput, userId: string) { + await this.access.assertMember(userId); + + const take = input.limit ?? SLACK.channels.pageSize; + const needle = input.query?.trim() ?? ""; + + const [rows, grant, sync] = await Promise.all([ + this.db.slackChannel.findMany({ + where: { + available: true, + ...(needle + ? { name: { contains: needle, mode: "insensitive" } } + : {}), + }, + orderBy: [{ isMember: "desc" }, { name: "asc" }, { id: "asc" }], + take: take + 1, + ...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}), + select: { + id: true, + name: true, + memberCount: true, + isPrivate: true, + isMember: true, + classifiedAt: true, + inviteRequestedAt: true, + }, + }), + this.db.slackWorkspaceGrant.findFirst({ select: { id: true } }), + this.peopleSyncState(), + ]); + + const page = rows.slice(0, take); + + return { + canInviteItself: Boolean(grant), + sync, + nextCursor: rows.length > take ? (page.at(-1)?.id ?? null) : null, + rows: page.map(({ classifiedAt, ...row }) => ({ + ...row, + classified: classifiedAt !== null, + inviteRequestedAt: row.inviteRequestedAt?.toISOString() ?? null, + })), + }; + } + + async joinChannel(input: SlackJoinChannelInput, userId: string) { + await this.access.assertMember(userId); + const channel = await this.db.slackChannel.findUnique({ + where: { id: input.channelId }, + select: { id: true, name: true, isMember: true, isPrivate: true }, + }); + if (!channel) throw new NotFoundException("No such Slack channel."); + if (channel.isMember) return { queued: false, alreadyJoined: true }; + + const grant = await this.db.slackWorkspaceGrant.findFirst({ + select: { id: true }, + }); + if (channel.isPrivate && !grant) { + await this.db.slackChannel.update({ + where: { id: channel.id }, + data: { inviteRequestedAt: new Date() }, + }); + return { queued: false, alreadyJoined: false }; + } + + await this.agent.slackChannelJoinRequested(channel.id, channel.name); + return { queued: true, alreadyJoined: false }; + } + + async createChannel(input: SlackCreateChannelInput, userId: string) { + await this.access.assertMember(userId); + const existing = await this.db.slackChannel.findFirst({ + where: { name: input.name }, + select: { id: true }, + }); + if (existing) { + throw new BadRequestException("A channel with that name already exists."); + } + + return this.slackChannels.create(input.name, input.isPrivate); + } + + async disconnect(userId: string) { + const role = await this.access.assertMember(userId); + + if (!canManageConnections(role)) { + throw new ForbiddenException( + "Only an owner or an admin can disconnect Slack.", + ); + } + + const removed = await this.db.$transaction(async (tx) => { + const accounts = await tx.account.deleteMany({ + where: { providerId: "slack" }, + }); + await tx.slackChannel.deleteMany({}); + await tx.slackWorkspaceGrant.deleteMany({}); + return accounts.count; + }); + + if (removed === 0) throw new NotFoundException("Slack is not connected."); + + return { disconnected: true }; + } +} diff --git a/apps/api/src/slack/slack.contracts.ts b/apps/api/src/slack/slack.contracts.ts new file mode 100644 index 000000000..4e1ec82ed --- /dev/null +++ b/apps/api/src/slack/slack.contracts.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { SLACK } from "./slack-config"; + +export const slackChannelsInput = z.object({ + cursor: z.string().trim().min(1).max(64).nullish(), + limit: z.number().int().min(1).max(SLACK.channels.maxPageSize).optional(), + query: z.string().trim().max(120).optional(), +}); + +export type SlackChannelsInput = z.infer; + +export const slackJoinChannelInput = z.object({ + channelId: z.string().trim().min(1).max(64), +}); + +export type SlackJoinChannelInput = z.infer; + +export const slackCreateChannelInput = z.object({ + name: z + .string() + .trim() + .min(1) + .max(80) + .regex(/^[a-z0-9-_]+$/, "Use lowercase letters, numbers and dashes."), + isPrivate: z.boolean().default(false), +}); + +export type SlackCreateChannelInput = z.infer; + +export const slackCreateChannelReply = z.union([ + z.object({ + channel: z.object({ + id: z.string().trim().min(1).max(64), + name: z.string().trim().min(1).max(120), + }), + }), + z.object({ error: z.string().trim().min(1).max(500) }), +]); + +export type SlackCreateChannelReply = z.infer; diff --git a/apps/api/src/slack/slack.module.ts b/apps/api/src/slack/slack.module.ts new file mode 100644 index 000000000..8d207e357 --- /dev/null +++ b/apps/api/src/slack/slack.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { AgentModule } from "../agent/agent.module"; +import { TrpcModule } from "../trpc/trpc.module"; +import { SlackRouter } from "./slack.router"; +import { SlackChannelsService } from "./slack-channels.service"; +import { SlackConnectionService } from "./slack-connection.service"; + +@Module({ + imports: [TrpcModule, AgentModule], + providers: [SlackChannelsService, SlackConnectionService, SlackRouter], + exports: [SlackConnectionService], +}) +export class SlackModule {} diff --git a/apps/api/src/slack/slack.router.ts b/apps/api/src/slack/slack.router.ts new file mode 100644 index 000000000..bee3f2af1 --- /dev/null +++ b/apps/api/src/slack/slack.router.ts @@ -0,0 +1,71 @@ +import { Inject } from "@nestjs/common"; +import { + Ctx, + Input, + Mutation, + Query, + Router, + UseMiddlewares, +} from "nestjs-trpc"; +import type { z } from "zod"; +import type { AuthedTrpcContext } from "../trpc/context.types"; +import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { + slackChannelsInput, + slackCreateChannelInput, + slackJoinChannelInput, +} from "./slack.contracts"; +import { SlackConnectionService } from "./slack-connection.service"; + +@Router({ alias: "slack" }) +@UseMiddlewares(AuthMiddleware) +export class SlackRouter { + constructor( + @Inject(SlackConnectionService) + private readonly connection: SlackConnectionService, + ) {} + + @Query() + status(@Ctx() ctx: AuthedTrpcContext) { + return this.connection.status(ctx.user.id); + } + + @Query() + matches(@Ctx() ctx: AuthedTrpcContext) { + return this.connection.matches(ctx.user.id); + } + + @Query({ input: slackChannelsInput }) + channels( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.connection.channels(input, ctx.user.id); + } + + @Mutation({ input: slackJoinChannelInput }) + joinChannel( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.connection.joinChannel(input, ctx.user.id); + } + + @Mutation() + refreshPeople(@Ctx() ctx: AuthedTrpcContext) { + return this.connection.refreshPeople(ctx.user.id); + } + + @Mutation({ input: slackCreateChannelInput }) + createChannel( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.connection.createChannel(input, ctx.user.id); + } + + @Mutation() + disconnect(@Ctx() ctx: AuthedTrpcContext) { + return this.connection.disconnect(ctx.user.id); + } +} diff --git a/apps/api/src/sso/sso.service.ts b/apps/api/src/sso/sso.service.ts index 1cfcd6d71..6537ba08d 100644 --- a/apps/api/src/sso/sso.service.ts +++ b/apps/api/src/sso/sso.service.ts @@ -3,12 +3,11 @@ import { canConfigureSso, isGoogleConfigured, isMicrosoftConfigured, - isWorkspaceRole, ssoCallbackBase, ssoCallbackURL, ssoProviderName, WORKSPACE_ID, - type WorkspaceRole, + workspaceRoleOf, } from "@crm/auth"; import type { Db, Prisma } from "@crm/db"; import { @@ -155,7 +154,7 @@ export class SsoService { async settings(userId: string): Promise { return { - canConfigure: canConfigureSso(await this.roleOf(userId)), + canConfigure: canConfigureSso(await workspaceRoleOf(userId, this.db)), callbackBase: ssoCallbackBase(), }; } @@ -291,23 +290,10 @@ export class SsoService { } private async requireConfigurer(userId: string): Promise { - if (!canConfigureSso(await this.roleOf(userId))) { + if (!canConfigureSso(await workspaceRoleOf(userId, this.db))) { throw new ForbiddenException( "Only an owner or an admin can change how people sign in.", ); } } - - private async roleOf(userId: string): Promise { - const member = await this.db.member.findUnique({ - where: { - organizationId_userId: { organizationId: WORKSPACE_ID, userId }, - }, - select: { role: true }, - }); - - if (!member) return null; - - return isWorkspaceRole(member.role) ? member.role : "member"; - } } diff --git a/apps/api/src/workspace/workspace.service.ts b/apps/api/src/workspace/workspace.service.ts index 505a0a10b..e498f6d85 100644 --- a/apps/api/src/workspace/workspace.service.ts +++ b/apps/api/src/workspace/workspace.service.ts @@ -5,6 +5,7 @@ import { isWorkspaceRole, WORKSPACE_ID, type WorkspaceRole, + workspaceRoleOf, } from "@crm/auth"; import type { Db, Prisma } from "@crm/db"; import { isOnboarded, markOnboarded, workspaceSlug } from "@crm/db/workspace"; @@ -101,7 +102,7 @@ export class WorkspaceService { ); } - const role = await this.roleOf(userId); + const role = await workspaceRoleOf(userId); return { id: row.id, @@ -119,7 +120,7 @@ export class WorkspaceService { userId: string, input: UpdateWorkspaceInput, ): Promise { - const role = await this.roleOf(userId); + const role = await workspaceRoleOf(userId); if (!canRenameWorkspace(role)) { throw new ForbiddenException( @@ -198,7 +199,7 @@ export class WorkspaceService { userId: string, input: SetMemberRoleInput, ): Promise { - const role = await this.roleOf(userId); + const role = await workspaceRoleOf(userId); if (!canChangeRole(role)) { throw new ForbiddenException( @@ -298,15 +299,4 @@ export class WorkspaceService { }, }); } - - private async roleOf(userId: string): Promise { - const member = await this.db.member.findUnique({ - where: { - organizationId_userId: { organizationId: WORKSPACE_ID, userId }, - }, - select: { role: true }, - }); - - return member ? toRole(member.role) : null; - } } diff --git a/apps/api/test/agent-delete.spec.ts b/apps/api/test/agent-delete.spec.ts index ed8979154..9e9d599d7 100644 --- a/apps/api/test/agent-delete.spec.ts +++ b/apps/api/test/agent-delete.spec.ts @@ -4,6 +4,7 @@ import { db } from "@crm/db"; import { workspaceSlug } from "@crm/db/workspace"; import { AgentAccessService } from "../src/agent/agent-access.service"; import { AgentDefinitionsService } from "../src/agent/agent-definitions.service"; +import { AgentTriggerService } from "../src/agent/agent-trigger.service"; const suffix = crypto.randomUUID(); const userId = `agent-delete-user-${suffix}`; @@ -11,7 +12,11 @@ const memberId = `agent-delete-member-${suffix}`; const idempotencyPrefix = `agent-delete-${suffix}`; const access = new AgentAccessService(db); -const agents = new AgentDefinitionsService(db, access); +const agents = new AgentDefinitionsService( + db, + access, + new AgentTriggerService(db), +); let agentId: string; let versionId: string; diff --git a/apps/api/test/agent-events.spec.ts b/apps/api/test/agent-events.spec.ts new file mode 100644 index 000000000..2fe38f520 --- /dev/null +++ b/apps/api/test/agent-events.spec.ts @@ -0,0 +1,257 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; +import { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { ConversionService } from "../src/currency/conversion.service"; +import { DealsService } from "../src/deals/deals.service"; +import { FieldsService } from "../src/fields/fields.service"; + +const suffix = crypto.randomUUID(); +const dealId = `event-deal-${suffix}`; +const contactId = `event-contact-${suffix}`; +const companyId = `event-company-${suffix}`; +const service = new AgentTriggerService(db); +const stamp = new ActivityStampService(db); +const conversion = new ConversionService(db); +const fields = new FieldsService(db, service); +const deals = new DealsService(db, service, stamp, conversion, fields); +const channelId = `event-channel-${suffix}`; +const ownerId = `event-owner-${suffix}`; +const domain = `event-${suffix}.example.test`; +let persistedCompanyId = ""; +let persistedDealId = ""; +let previousBridgeSecret: string | undefined; + +beforeAll(async () => { + previousBridgeSecret = process.env.AGENT_BRIDGE_SECRET; + delete process.env.AGENT_BRIDGE_SECRET; + await db.agentTask.deleteMany({ + where: { OR: [{ dealId }, { contactId }] }, + }); + await db.user.create({ + data: { + id: ownerId, + name: "Event Test Owner", + email: `${ownerId}@example.test`, + }, + }); + const company = await db.company.create({ + data: { name: "Event Test Company", domain }, + select: { id: true }, + }); + persistedCompanyId = company.id; +}); + +afterAll(async () => { + await db.agentTask.deleteMany({ + where: { + OR: [ + { dealId: { in: [dealId, persistedDealId].filter(Boolean) } }, + { contactId }, + ], + }, + }); + await db.agentTask.deleteMany({ + where: { + kind: "slack-channel-join", + payload: { path: ["channelId"], equals: channelId }, + }, + }); + if (persistedDealId) { + await db.activity.deleteMany({ where: { dealId: persistedDealId } }); + await db.deal.deleteMany({ where: { id: persistedDealId } }); + } + if (persistedCompanyId) { + await db.company.deleteMany({ where: { id: persistedCompanyId } }); + } + await db.user.deleteMany({ where: { id: ownerId } }); + if (previousBridgeSecret === undefined) { + delete process.env.AGENT_BRIDGE_SECRET; + } else { + process.env.AGENT_BRIDGE_SECRET = previousBridgeSecret; + } +}); + +describe("CRM agent events", () => { + it("routes every event to its catalog record kind", async () => { + const occurredAt = new Date("2026-08-10T09:00:00.000Z"); + await service.withCrmEvents(async (_tx, emit) => { + await emit({ + type: "contact.created", + record: { kind: "contact", id: contactId }, + occurredAt, + data: { email: "person@example.test" }, + }); + }); + + expect( + await db.agentTask.findFirstOrThrow({ + where: { contactId, kind: "agent-event" }, + select: { + contactId: true, + companyId: true, + dealId: true, + payload: true, + }, + }), + ).toEqual({ + contactId, + companyId: null, + dealId: null, + payload: { + type: "contact.created", + record: { kind: "contact", id: contactId }, + occurredAt: occurredAt.toISOString(), + data: { email: "person@example.test" }, + }, + }); + }); + + it("writes durable created and closed events for the agent worker", async () => { + const createdAt = new Date("2026-08-10T10:00:00.000Z"); + const closedAt = new Date("2026-08-10T11:00:00.000Z"); + + await service.withCrmEvents(async (_tx, emit) => { + await emit({ + type: "deal.created", + record: { kind: "deal", id: dealId }, + occurredAt: createdAt, + data: { companyId, stage: "DEMO_BOOKED" }, + }); + await emit({ + type: "deal.closed", + record: { kind: "deal", id: dealId }, + occurredAt: closedAt, + data: { companyId, from: "NEGOTIATION", to: "CLOSED_WON" }, + }); + }); + + const tasks = await db.agentTask.findMany({ + where: { dealId, kind: "agent-event" }, + select: { + dealId: true, + reason: true, + payload: true, + finishedAt: true, + }, + }); + + expect(tasks).toHaveLength(2); + expect(tasks.find((task) => task.reason === "deal.created")).toEqual({ + dealId, + reason: "deal.created", + payload: { + type: "deal.created", + record: { kind: "deal", id: dealId }, + occurredAt: createdAt.toISOString(), + data: { companyId, stage: "DEMO_BOOKED" }, + }, + finishedAt: null, + }); + expect(tasks.find((task) => task.reason === "deal.closed")).toEqual({ + dealId, + reason: "deal.closed", + payload: { + type: "deal.closed", + record: { kind: "deal", id: dealId }, + occurredAt: closedAt.toISOString(), + data: { companyId, from: "NEGOTIATION", to: "CLOSED_WON" }, + }, + finishedAt: null, + }); + }); + + it("queues one Slack join for a channel that is renamed", async () => { + await service.slackChannelJoinRequested(channelId, "deal-room"); + await service.slackChannelJoinRequested(channelId, "deal-room-renamed"); + + expect( + await db.agentTask.findMany({ + where: { + kind: "slack-channel-join", + payload: { path: ["channelId"], equals: channelId }, + }, + select: { reason: true }, + }), + ).toEqual([{ reason: "Add Comp AI to #deal-room" }]); + }); + + it("rolls back the record when its event cannot commit", async () => { + const rollbackCompanyId = `event-rollback-company-${suffix}`; + let error: Error | null = null; + + try { + await service.withCrmEvents(async (tx, emit) => { + const company = await tx.company.create({ + data: { + id: rollbackCompanyId, + name: "Rollback Event Company", + }, + select: { id: true, createdAt: true }, + }); + await emit({ + type: "company.created", + record: { kind: "company", id: company.id }, + occurredAt: company.createdAt, + data: { name: "Rollback Event Company", domain: null }, + }); + throw new Error("Rollback the record and outbox together."); + }); + } catch (caught) { + error = caught as Error; + } + + expect(error?.message).toBe("Rollback the record and outbox together."); + expect( + await db.company.findUnique({ where: { id: rollbackCompanyId } }), + ).toBeNull(); + expect( + await db.agentTask.count({ + where: { companyId: rollbackCompanyId, kind: "agent-event" }, + }), + ).toBe(0); + }); + + it("emits each real deal lifecycle transition exactly once", async () => { + const deal = await deals.create({ + name: "Event-driven deal", + companyId: persistedCompanyId, + ownerId, + amountCents: 25_000, + currency: "USD", + }); + persistedDealId = deal.id; + + const transitions = await Promise.all([ + deals.setStage({ id: deal.id, stage: "CLOSED_WON" }, ownerId), + deals.setStage({ id: deal.id, stage: "CLOSED_WON" }, ownerId), + ]); + + expect(transitions.map((transition) => transition.changed).sort()).toEqual([ + false, + true, + ]); + await deals.setStage({ id: deal.id, stage: "QUALIFIED_TO_BUY" }, ownerId); + + const reasons = ( + await db.agentTask.findMany({ + where: { dealId: deal.id, kind: "agent-event" }, + select: { reason: true }, + }) + ) + .map((task) => task.reason) + .sort(); + expect(reasons).toEqual([ + "deal.closed", + "deal.created", + "deal.opened", + "deal.stage.changed", + "deal.stage.changed", + ]); + expect( + await db.activity.count({ + where: { dealId: deal.id, type: "STAGE_CHANGE" }, + }), + ).toBe(2); + }); +}); diff --git a/apps/api/test/agent-lifecycle.spec.ts b/apps/api/test/agent-lifecycle.spec.ts index c66d01569..06dace00b 100644 --- a/apps/api/test/agent-lifecycle.spec.ts +++ b/apps/api/test/agent-lifecycle.spec.ts @@ -1,17 +1,24 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth"; -import { db } from "@crm/db"; +import { db, type Prisma } from "@crm/db"; import { workspaceSlug } from "@crm/db/workspace"; import { AgentAccessService } from "../src/agent/agent-access.service"; import { AgentDefinitionsService } from "../src/agent/agent-definitions.service"; +import { AgentTriggerService } from "../src/agent/agent-trigger.service"; const suffix = crypto.randomUUID(); const userId = `agent-lifecycle-user-${suffix}`; const teammateId = `agent-lifecycle-teammate-${suffix}`; const memberId = `agent-lifecycle-member-${suffix}`; const teammateMemberId = `agent-lifecycle-teammate-member-${suffix}`; +const joinChannel = `renewals-${suffix}`; +const joinReason = `Add Comp AI to #${joinChannel}`; const access = new AgentAccessService(db); -const agents = new AgentDefinitionsService(db, access); +const agents = new AgentDefinitionsService( + db, + access, + new AgentTriggerService(db), +); beforeAll(async () => { await db.organization.upsert({ @@ -95,6 +102,9 @@ afterAll(async () => { where: { id: { in: agentIds } }, }); } + await db.agentTask.deleteMany({ + where: { kind: "slack-channel-join", reason: joinReason }, + }); await db.member.deleteMany({ where: { id: { in: [memberId, teammateMemberId] } }, }); @@ -131,6 +141,43 @@ async function createAgent(versionCount = 1, status = "DRAFT" as const) { return { agentId: agent.id, versions }; } +const postAction = { + type: "slack.message.post", + provider: "slack", + summary: "Post the summary", + destination: { kind: "channel", id: "C0001", label: "#renewals" }, +}; + +const noteAction = { + type: "crm.note.write", + provider: "crm", + summary: "Write a note", +}; + +const capableManifest = { + name: "Renewal watcher", + description: "Watch renewals.", + actions: [postAction, noteAction], + dataScope: { mode: "WORKSPACE", summary: "Everything", resources: [] }, +}; + +async function deployedAgent(manifest: Prisma.InputJsonObject) { + const { agentId, versions } = await createAgent(); + const versionId = versions[0]?.id; + if (!versionId) throw new Error("Missing version"); + + await db.agentVersion.update({ + where: { id: versionId }, + data: { manifest }, + }); + await agents.deploy( + { id: agentId, versionId, clientRequestId: crypto.randomUUID() }, + userId, + ); + + return { agentId, versionId }; +} + describe("agent lifecycle", () => { it("returns stable private and team contracts while auditing metadata edits", async () => { const { agentId, versions } = await createAgent(); @@ -176,8 +223,8 @@ describe("agent lifecycle", () => { { id: agentId, versionId, clientRequestId: crypto.randomUUID() }, userId, ); - const nextRunAt = new Date("2026-08-06T12:00:00.000Z"); - const lastRunAt = new Date("2026-08-05T12:00:00.000Z"); + const nextRunAt = new Date("2036-08-06T12:00:00.000Z"); + const lastRunAt = new Date("2036-08-05T12:00:00.000Z"); await db.agentTrigger.create({ data: { agentId, @@ -405,6 +452,227 @@ describe("agent lifecycle", () => { ).toBe(1); }); + it("moves triggers onto the version a revision creates", async () => { + const { agentId, versionId } = await deployedAgent(capableManifest); + const trigger = await db.agentTrigger.create({ + data: { + agentId, + versionId, + type: "SCHEDULE", + name: "Every morning", + config: { intervalMinutes: 1440 }, + createdById: userId, + enabled: true, + nextRunAt: new Date("2036-08-12T06:00:00.000Z"), + }, + select: { id: true }, + }); + + const revised = await agents.revise( + { + id: agentId, + clientRequestId: crypto.randomUUID(), + actions: ["slack.message.post"], + resources: [{ id: "acme", kind: "company", label: "Acme" }], + }, + userId, + ); + const revisedId = revised.versionId; + if (!revisedId) throw new Error("Missing revised version"); + + const [definition, revision, moved] = await Promise.all([ + db.agentDefinition.findUniqueOrThrow({ + where: { id: agentId }, + select: { currentVersionId: true }, + }), + db.agentVersion.findUniqueOrThrow({ + where: { id: revisedId }, + select: { manifest: true }, + }), + db.agentTrigger.findUniqueOrThrow({ + where: { id: trigger.id }, + select: { versionId: true, enabled: true }, + }), + ]); + + expect(revisedId).not.toBe(versionId); + expect(definition.currentVersionId).toBe(revisedId); + expect(moved).toEqual({ versionId: revisedId, enabled: true }); + expect(revision.manifest).toMatchObject({ + name: "Renewal watcher", + description: "Watch renewals.", + actions: [ + { + type: "slack.message.post", + destination: { id: "C0001", label: "#renewals" }, + }, + ], + dataScope: { + mode: "SELECTED", + resources: [{ id: "acme", kind: "company", label: "Acme" }], + }, + }); + }); + + it("moves triggers onto the version a file save creates", async () => { + const { agentId, versionId } = await deployedAgent(capableManifest); + await db.agentBuilderArtifact.create({ + data: { + versionId, + path: "agent/instructions.md", + language: "markdown", + content: "Watch renewals.", + revision: 1, + status: "READY", + }, + }); + const trigger = await db.agentTrigger.create({ + data: { + agentId, + versionId, + type: "SCHEDULE", + name: "Every morning", + config: { intervalMinutes: 1440 }, + createdById: userId, + enabled: true, + nextRunAt: new Date("2036-08-12T06:00:00.000Z"), + }, + select: { id: true }, + }); + + const saved = await agents.saveFile( + { + id: agentId, + clientRequestId: crypto.randomUUID(), + path: "agent/instructions.md", + content: "Watch renewals every morning.", + }, + userId, + ); + const savedId = saved.versionId; + if (!savedId) throw new Error("Missing saved version"); + + const [definition, moved, audit] = await Promise.all([ + db.agentDefinition.findUniqueOrThrow({ + where: { id: agentId }, + select: { currentVersionId: true }, + }), + db.agentTrigger.findUniqueOrThrow({ + where: { id: trigger.id }, + select: { versionId: true, enabled: true }, + }), + db.agentAuditEvent.findFirstOrThrow({ + where: { agentId, type: "agent.file.saved" }, + select: { after: true }, + }), + ]); + + expect(saved.saved).toBe(true); + expect(savedId).not.toBe(versionId); + expect(definition.currentVersionId).toBe(savedId); + expect(moved).toEqual({ versionId: savedId, enabled: true }); + expect(audit.after).toMatchObject({ triggers: 1 }); + }); + + it("numbers a revision above every existing version", async () => { + const { agentId } = await deployedAgent(capableManifest); + await db.agentVersion.create({ + data: { + agentId, + number: 9, + status: "DRAFT", + instructions: "A later draft", + manifest: capableManifest, + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + }, + }); + + const revised = await agents.revise( + { + id: agentId, + clientRequestId: crypto.randomUUID(), + resources: [], + }, + userId, + ); + if (!revised.versionId) throw new Error("Missing revised version"); + + expect( + await db.agentVersion.findUniqueOrThrow({ + where: { id: revised.versionId }, + select: { number: true }, + }), + ).toEqual({ number: 10 }); + }); + + it("queues the channel join with the version that moves the agent", async () => { + const { agentId } = await deployedAgent(capableManifest); + + const revised = await agents.revise( + { + id: agentId, + clientRequestId: crypto.randomUUID(), + channel: { id: "C0009", name: joinChannel }, + }, + userId, + ); + if (!revised.versionId) throw new Error("Missing revised version"); + + const [version, join] = await Promise.all([ + db.agentVersion.findUniqueOrThrow({ + where: { id: revised.versionId }, + select: { manifest: true }, + }), + db.agentTask.findFirst({ + where: { kind: "slack-channel-join", reason: joinReason }, + select: { payload: true }, + }), + ]); + + expect(version.manifest).toMatchObject({ + actions: [ + { + type: "slack.message.post", + destination: { id: "C0009", label: `#${joinChannel}` }, + }, + { type: "crm.note.write" }, + ], + }); + expect(join?.payload).toMatchObject({ + type: "slack.channel.join", + channelId: "C0009", + channelName: joinChannel, + }); + }); + + it("refuses a channel change when no action posts anywhere", async () => { + const { agentId } = await deployedAgent({ + ...capableManifest, + actions: [noteAction], + }); + + let refusal: unknown; + try { + await agents.revise( + { + id: agentId, + clientRequestId: crypto.randomUUID(), + channel: { id: "C0002", name: "wins" }, + }, + userId, + ); + } catch (error) { + refusal = error; + } + + expect((refusal as Error).message).toBe( + "None of this agent's actions post to a channel, so its channel cannot be changed.", + ); + expect(await db.agentVersion.count({ where: { agentId } })).toBe(1); + }); + it("cannot resurrect an agent when deletion races a transition", async () => { const { agentId, versions } = await createAgent(); const versionId = versions[0]?.id; diff --git a/apps/api/test/agent-runs.spec.ts b/apps/api/test/agent-runs.spec.ts index 92ee4c96c..024b59529 100644 --- a/apps/api/test/agent-runs.spec.ts +++ b/apps/api/test/agent-runs.spec.ts @@ -1,10 +1,10 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth"; import { db } from "@crm/db"; import { workspaceSlug } from "@crm/db/workspace"; import { AgentAccessService } from "../src/agent/agent-access.service"; import { AgentRunsService } from "../src/agent/agent-runs.service"; -import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { AgentTriggerService } from "../src/agent/agent-trigger.service"; const suffix = crypto.randomUUID(); const userId = `agent-run-user-${suffix}`; @@ -13,10 +13,14 @@ const memberId = `agent-run-member-${suffix}`; let agentId = ""; let versionId = ""; let pokeCount = 0; +let cancelPokes: string[] = []; const trigger = { deployedAgentRunQueued() { pokeCount += 1; }, + deployedAgentRunCancelled(runId: string) { + cancelPokes.push(runId); + }, } as AgentTriggerService; const service = new AgentRunsService(db, new AgentAccessService(db), trigger); @@ -114,6 +118,22 @@ afterAll(async () => { await db.user.deleteMany({ where: { id: { in: [userId, outsiderId] } } }); }); +afterEach(async () => { + if (!agentId) return; + await db.agentRun.updateMany({ + where: { + agentId, + status: { in: ["QUEUED", "RUNNING", "WAITING_FOR_APPROVAL"] }, + }, + data: { + status: "CANCELLED", + errorCode: "TEST_CLEANUP", + errorMessage: "Settled between tests.", + finishedAt: new Date(), + }, + }); +}); + describe("manual agent runs", () => { it("returns ordered, transport-safe run and activity history", async () => { const clientRequestId = crypto.randomUUID(); @@ -249,6 +269,38 @@ describe("manual agent runs", () => { expect(pokeCount).toBe(beforePokeCount + 4); }); + it("rejects a second manual run while the first is active", async () => { + const first = await service.runNow( + { id: agentId, clientRequestId: crypto.randomUUID() }, + userId, + ); + + let error: Error | null = null; + try { + await service.runNow( + { id: agentId, clientRequestId: crypto.randomUUID() }, + userId, + ); + } catch (caught) { + error = caught as Error; + } + + expect(error?.message).toBe( + "This agent already has an active run. Stop it or wait for it to finish.", + ); + expect( + await db.agentRun.count({ + where: { + agentId, + status: { in: ["QUEUED", "RUNNING", "WAITING_FOR_APPROVAL"] }, + }, + }), + ).toBe(1); + expect( + await db.agentRun.findUniqueOrThrow({ where: { id: first.id } }), + ).toMatchObject({ status: "QUEUED" }); + }); + it("checks workspace membership before replaying an existing request", async () => { const clientRequestId = crypto.randomUUID(); await service.runNow({ id: agentId, clientRequestId }, userId); @@ -262,6 +314,65 @@ describe("manual agent runs", () => { expect(error?.message).toBe("You are not a member of this workspace."); }); + it("retries a failed run against the version that run executed", async () => { + const first = await service.runNow( + { id: agentId, clientRequestId: crypto.randomUUID() }, + userId, + ); + await db.agentRun.update({ + where: { id: first.id }, + data: { + status: "FAILED", + errorCode: "TEST_FAILURE", + errorMessage: "Broke before the retry.", + finishedAt: new Date(), + }, + }); + const redeployed = await db.agentVersion.create({ + data: { + agentId, + number: 2, + status: "DEPLOYED", + instructions: "Run differently.", + manifest: {}, + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + }, + select: { id: true }, + }); + await db.agentDefinition.update({ + where: { id: agentId }, + data: { currentVersionId: redeployed.id }, + }); + + const clientRequestId = crypto.randomUUID(); + try { + const retried = await service.retryRun( + { id: agentId, runId: first.id, clientRequestId }, + userId, + ); + + expect( + await db.agentRun.findUniqueOrThrow({ + where: { id: retried.id }, + select: { versionId: true }, + }), + ).toEqual({ versionId }); + expect( + await db.agentAuditEvent.findFirstOrThrow({ + where: { agentId, type: "run.requested", requestId: clientRequestId }, + select: { versionId: true }, + }), + ).toEqual({ versionId }); + } finally { + await db.agentDefinition.update({ + where: { id: agentId }, + data: { currentVersionId: versionId }, + }); + } + }); + it("allows only one agent to claim a globally reused request id", async () => { const otherAgent = await db.agentDefinition.create({ data: { name: "Other live agent", status: "LIVE", createdById: userId }, @@ -301,3 +412,199 @@ describe("manual agent runs", () => { ).toBe(1); }); }); + +describe("cancelling a run", () => { + async function queuedRun() { + const { id } = await service.runNow( + { id: agentId, clientRequestId: crypto.randomUUID() }, + userId, + ); + cancelPokes = []; + return id; + } + + it("settles a queued run and asks the agent to drop the turn", async () => { + const runId = await queuedRun(); + + const result = await service.cancelRun({ id: agentId, runId }, userId); + + expect(result).toMatchObject({ id: runId, status: "CANCELLED" }); + expect(result.cancelled).toBe(true); + expect(cancelPokes).toEqual([runId]); + + const run = await db.agentRun.findUniqueOrThrow({ + where: { id: runId }, + select: { status: true, errorCode: true, finishedAt: true }, + }); + expect(run.status).toBe("CANCELLED"); + expect(run.errorCode).toBe("CANCELLED_BY_USER"); + expect(run.finishedAt).not.toBeNull(); + }); + + it("records one terminal event and one audit event", async () => { + const runId = await queuedRun(); + await service.cancelRun({ id: agentId, runId }, userId); + + const events = await db.agentRunEvent.findMany({ + where: { runId, type: "run.cancelled" }, + select: { id: true }, + }); + expect(events).toHaveLength(1); + expect(events[0]?.id).toBe(`run-terminal:${runId}:cancelled`); + + expect( + await db.agentAuditEvent.count({ + where: { agentId, type: "run.cancelled", requestId: runId }, + }), + ).toBe(1); + }); + + it("cancels outstanding actions so nothing is left running", async () => { + const runId = await queuedRun(); + await db.agentAction.createMany({ + data: [ + { + agentId, + runId, + type: "crm.activity.create", + provider: "crm", + summary: "Planned note", + status: "PLANNED", + idempotencyKey: `${runId}:planned`, + requestHash: "planned", + }, + { + agentId, + runId, + type: "slack.message.post", + provider: "slack", + summary: "Already posted", + status: "SUCCEEDED", + idempotencyKey: `${runId}:done`, + requestHash: "done", + }, + ], + }); + + await service.cancelRun({ id: agentId, runId }, userId); + + const actions = await db.agentAction.findMany({ + where: { runId }, + orderBy: { idempotencyKey: "asc" }, + select: { status: true }, + }); + expect(actions.map((action) => action.status)).toEqual([ + "SUCCEEDED", + "CANCELLED", + ]); + }); + + it("is idempotent and never pokes twice", async () => { + const runId = await queuedRun(); + await service.cancelRun({ id: agentId, runId }, userId); + cancelPokes = []; + + const again = await service.cancelRun({ id: agentId, runId }, userId); + + expect(again.cancelled).toBe(false); + expect(again.status).toBe("CANCELLED"); + expect(cancelPokes).toEqual([]); + expect( + await db.agentRunEvent.count({ where: { runId, type: "run.cancelled" } }), + ).toBe(1); + }); + + it("leaves a finished run alone", async () => { + const runId = await queuedRun(); + await db.agentRun.update({ + where: { id: runId }, + data: { status: "SUCCEEDED", finishedAt: new Date() }, + }); + + const result = await service.cancelRun({ id: agentId, runId }, userId); + + expect(result).toMatchObject({ status: "SUCCEEDED", cancelled: false }); + expect(cancelPokes).toEqual([]); + }); + + it("refuses a run id that belongs to another agent", async () => { + const runId = await queuedRun(); + const other = await db.agentDefinition.create({ + data: { name: "Unrelated", status: "LIVE", createdById: userId }, + select: { id: true }, + }); + + let error: Error | null = null; + try { + await service.cancelRun({ id: other.id, runId }, userId); + } catch (caught) { + error = caught as Error; + } + expect(error?.message).toBe(`No run with id ${runId}.`); + }); + + it("refuses a caller who is not a workspace member", async () => { + const runId = await queuedRun(); + + let error: Error | null = null; + try { + await service.cancelRun({ id: agentId, runId }, outsiderId); + } catch (caught) { + error = caught as Error; + } + expect(error?.message).toBe("You are not a member of this workspace."); + }); + + it("reports cancellable runs to the caller who may stop them", async () => { + const runId = await queuedRun(); + + const history = await service.list(agentId, 50, userId); + const row = history.find((run) => run.id === runId); + expect(row?.canCancel).toBe(true); + + await service.cancelRun({ id: agentId, runId }, userId); + const settled = await service.list(agentId, 50, userId); + expect(settled.find((run) => run.id === runId)?.canCancel).toBe(false); + }); + + it("asks the agent again until the cancellation lands", async () => { + const runId = await queuedRun(); + await db.agentRun.update({ + where: { id: runId }, + data: { status: "RUNNING", startedAt: new Date() }, + }); + await service.cancelRun({ id: agentId, runId }, userId); + + const trigger = new AgentTriggerService(db); + const asked: string[] = []; + const asksForRun = () => asked.filter((id) => id === runId).length; + let status = 502; + const realFetch = globalThis.fetch; + const realSecret = process.env.AGENT_BRIDGE_SECRET; + process.env.AGENT_BRIDGE_SECRET = "run-cancel-test"; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { runId: string }; + asked.push(body.runId); + return new Response(null, { status }); + }) as typeof fetch; + + try { + await trigger.redeliverCancellations(); + expect(asksForRun()).toBe(1); + + status = 202; + await trigger.redeliverCancellations(); + expect(asksForRun()).toBe(2); + + await trigger.redeliverCancellations(); + expect(asksForRun()).toBe(2); + } finally { + globalThis.fetch = realFetch; + if (realSecret === undefined) { + delete process.env.AGENT_BRIDGE_SECRET; + } else { + process.env.AGENT_BRIDGE_SECRET = realSecret; + } + } + }); +}); diff --git a/apps/api/test/agent-trigger.stub.ts b/apps/api/test/agent-trigger.stub.ts new file mode 100644 index 000000000..6661d7890 --- /dev/null +++ b/apps/api/test/agent-trigger.stub.ts @@ -0,0 +1,11 @@ +import { db, type Prisma } from "@crm/db"; +import type { CrmEventInput } from "../src/agent/agent-trigger.service"; + +export function withDiscardedCrmEvents( + work: ( + tx: Prisma.TransactionClient, + emit: (input: CrmEventInput) => Promise, + ) => Promise, +): Promise { + return db.$transaction((tx) => work(tx, async () => undefined)); +} diff --git a/apps/api/test/bulk.spec.ts b/apps/api/test/bulk.spec.ts index 83e3afd62..5b82245e2 100644 --- a/apps/api/test/bulk.spec.ts +++ b/apps/api/test/bulk.spec.ts @@ -10,6 +10,7 @@ import { ActivityStampService } from "../src/crm/activity-stamp.service"; import { ConversionService } from "../src/currency/conversion.service"; import { DealsService } from "../src/deals/deals.service"; import { FieldsService } from "../src/fields/fields.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; const suffix = process.env.TEST_RUN_ID ?? "bulk-spec"; const domain = `bulk-${suffix}.test`; @@ -21,12 +22,13 @@ const agent = { contactCreated: async () => undefined, companyCreated: async () => undefined, companyRequested: async () => undefined, + withCrmEvents: withDiscardedCrmEvents, } as unknown as AgentTriggerService; const stamp = new ActivityStampService(db); const queue = new AgentQueueService(db); const conversion = new ConversionService(db); -const directory = new CompanyDirectoryService(db, agent); +const directory = new CompanyDirectoryService(agent); const fields = new FieldsService(db, agent); const contacts = new ContactsService( @@ -46,7 +48,7 @@ const companies = new CompaniesService( conversion, fields, ); -const deals = new DealsService(db, stamp, conversion, fields); +const deals = new DealsService(db, agent, stamp, conversion, fields); let companyId: string; diff --git a/apps/api/test/conversations.spec.ts b/apps/api/test/conversations.spec.ts index 9cffe2066..de0d3ab11 100644 --- a/apps/api/test/conversations.spec.ts +++ b/apps/api/test/conversations.spec.ts @@ -285,6 +285,51 @@ describe("ConversationsService", () => { ).toEqual(["event.2", "event.3"]); }); + it("returns builder events from descendant sessions", async () => { + const builder = await service.createBuilder( + { + clientRequestId: crypto.randomUUID(), + commandType: "CREATE_AGENT", + message: "Build an agent that asks one question", + resources: [], + attachments: [], + }, + userId, + ); + const rootSessionId = `builder-question-${suffix}-root`; + await db.agentConversation.update({ + where: { id: builder.id }, + data: { sessionId: rootSessionId }, + }); + const emittedAt = new Date("2026-08-05T13:00:00.000Z"); + await db.agentEvent.createMany({ + data: [ + { + id: `evt_${suffix}_builder_root`, + sessionId: rootSessionId, + conversationId: builder.id, + type: "actions.requested", + data: {}, + emittedAt, + }, + { + id: `evt_${suffix}_builder_child`, + sessionId: `builder-question-${suffix}-child`, + conversationId: builder.id, + type: "input.requested", + data: {}, + emittedAt: new Date(emittedAt.getTime() + 1), + }, + ], + }); + + expect( + (await service.events({ id: builder.id, limit: 10 }, userId)).map( + (event) => event.type, + ), + ).toEqual(["actions.requested", "input.requested"]); + }); + it("forgets a conversation and the events behind it", async () => { const sessionId = `ses_${suffix}_delete`; const conversation = await service.save({ contactId, sessionId }, userId); @@ -642,28 +687,23 @@ describe("ConversationsService", () => { data: { sessionId, continuationToken: `crm:builder:${conversation.id}`, - }, - }); - await db.agentEvent.create({ - data: { - id: `evt_${suffix}_question`, - sessionId, - type: "input.requested", - data: { - requests: [ - { - kind: "question", - requestId: "question-1", - prompt: "Where should this go?", - display: "select", - options: [{ id: "crm-task", label: "Create a CRM task" }], - }, - ], + pendingInputRequest: { + kind: "question", + requestId: "question-1", + prompt: "Where should this go?", + display: "select", + options: [{ id: "crm-task", label: "Create a CRM task" }], }, - emittedAt: new Date(), }, }); + expect( + (await service.builderById(conversation.id, userId)).pendingQuestion, + ).toMatchObject({ + requestId: "question-1", + prompt: "Where should this go?", + }); + const response = await service.answerBuilderQuestion( { id: conversation.id, @@ -684,19 +724,57 @@ describe("ConversationsService", () => { }); expect(submission).toMatchObject({ - commandType: "CHAT", + commandType: "CREATE_AGENT", inputRequestId: "question-1", status: "PENDING", message: { text: "Create a CRM task", inputResponse: { requestId: "question-1", - answer: "crm-task", + optionId: "crm-task", }, }, }); }); + it("rejects an answer when the conversation has no durable question", async () => { + const conversation = await service.createBuilder( + { + clientRequestId: crypto.randomUUID(), + commandType: "CREATE_AGENT", + message: "/Create agent Notify the team", + resources: [], + attachments: [], + }, + userId, + ); + await db.agentConversation.update({ + where: { id: conversation.id }, + data: { + sessionId: `builder-question-${suffix}-recovery`, + continuationToken: `builder:${conversation.id}`, + }, + }); + + let error: Error | null = null; + try { + await service.answerBuilderQuestion( + { + id: conversation.id, + clientRequestId: crypto.randomUUID(), + requestId: "question-only-in-eve", + optionId: "continue-building", + }, + userId, + ); + } catch (caught) { + error = caught as Error; + } + expect(error?.message).toBe( + "The agent is no longer waiting for that answer.", + ); + }); + it("accepts only one concurrent answer to a follow-up request", async () => { const conversation = await service.createBuilder( { @@ -714,28 +792,16 @@ describe("ConversationsService", () => { data: { sessionId, continuationToken: `crm:builder:${conversation.id}`, - }, - }); - await db.agentEvent.create({ - data: { - id: `evt_${suffix}_concurrent_question`, - sessionId, - type: "input.requested", - data: { - requests: [ - { - kind: "question", - requestId: "question-concurrent", - prompt: "Which output?", - display: "select", - options: [ - { id: "note", label: "Create a note" }, - { id: "task", label: "Create a task" }, - ], - }, + pendingInputRequest: { + kind: "question", + requestId: "question-concurrent", + prompt: "Which output?", + display: "select", + options: [ + { id: "note", label: "Create a note" }, + { id: "task", label: "Create a task" }, ], }, - emittedAt: new Date(), }, }); diff --git a/apps/api/test/currency-totals.integration.spec.ts b/apps/api/test/currency-totals.integration.spec.ts index 6f063708f..f51b637e6 100644 --- a/apps/api/test/currency-totals.integration.spec.ts +++ b/apps/api/test/currency-totals.integration.spec.ts @@ -2,19 +2,26 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { DealStage, db, RateSource } from "@crm/db"; import { normalizeCurrency } from "@crm/db/currency"; import { SETTINGS_ID, writeReportingCurrency } from "@crm/db/settings"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; import { ActivityStampService } from "../src/crm/activity-stamp.service"; import { ConversionService } from "../src/currency/conversion.service"; import { DashboardService } from "../src/dashboard/dashboard.service"; import { DealsService } from "../src/deals/deals.service"; import { FieldsService } from "../src/fields/fields.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; const suffix = process.env.TEST_RUN_ID ?? "currency-totals-spec"; const userId = `user-${suffix}`; const domain = `money-${suffix}.test`; +const agent = { + withCrmEvents: withDiscardedCrmEvents, +} as unknown as AgentTriggerService; + const conversion = new ConversionService(db); const deals = new DealsService( db, + agent, new ActivityStampService(db), conversion, new FieldsService(db, { fieldBackfill: async () => undefined } as never), diff --git a/apps/api/test/deal-contacts.spec.ts b/apps/api/test/deal-contacts.spec.ts index 4fb704d8a..ccf9af4e4 100644 --- a/apps/api/test/deal-contacts.spec.ts +++ b/apps/api/test/deal-contacts.spec.ts @@ -1,17 +1,24 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { db } from "@crm/db"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; import { ActivityStampService } from "../src/crm/activity-stamp.service"; import { ConversionService } from "../src/currency/conversion.service"; import { DealsService } from "../src/deals/deals.service"; import { FieldsService } from "../src/fields/fields.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; const suffix = process.env.TEST_RUN_ID ?? "deal-contacts-spec"; const userId = `user-${suffix}`; const domain = `dealpeople-${suffix}.test`; const otherDomain = `elsewhere-${suffix}.test`; +const agent = { + withCrmEvents: withDiscardedCrmEvents, +} as unknown as AgentTriggerService; + const deals = new DealsService( db, + agent, new ActivityStampService(db), new ConversionService(db), new FieldsService(db, { fieldBackfill: async () => undefined } as never), diff --git a/apps/api/test/fields.spec.ts b/apps/api/test/fields.spec.ts index b11997e3e..40faba5dd 100644 --- a/apps/api/test/fields.spec.ts +++ b/apps/api/test/fields.spec.ts @@ -17,6 +17,7 @@ import { ActivityStampService } from "../src/crm/activity-stamp.service"; import { ConversionService } from "../src/currency/conversion.service"; import { DealsService } from "../src/deals/deals.service"; import { FieldsService } from "../src/fields/fields.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; const suffix = process.env.TEST_RUN_ID ?? "fields-spec"; const domain = `fields-${suffix}.test`; @@ -28,6 +29,7 @@ const agent = { contactCreated: async () => undefined, companyCreated: async () => undefined, companyRequested: async () => undefined, + withCrmEvents: withDiscardedCrmEvents, fieldBackfill: async (entity: FieldEntity, key: string, reason: string) => { queued.push({ entity, key, reason }); }, @@ -49,13 +51,13 @@ const companies = new CompaniesService( ); const contacts = new ContactsService( db, - new CompanyDirectoryService(db, agent), + new CompanyDirectoryService(agent), agent, queue, stamp, fields, ); -const deals = new DealsService(db, stamp, conversion, fields); +const deals = new DealsService(db, agent, stamp, conversion, fields); let companyId: string; let bridgeSecret: string | undefined; diff --git a/apps/api/test/mailbox-thread-writer.spec.ts b/apps/api/test/mailbox-thread-writer.spec.ts index ab636ebc6..92ba96b9b 100644 --- a/apps/api/test/mailbox-thread-writer.spec.ts +++ b/apps/api/test/mailbox-thread-writer.spec.ts @@ -9,6 +9,7 @@ import { type IncomingMessage, ThreadWriterService, } from "../src/mailbox/thread-writer.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; const suffix = process.env.TEST_RUN_ID ?? "thread-writer-spec"; const domain = `threads-${suffix}.test`; @@ -21,11 +22,12 @@ const movedRoot = `outlook-conversation:${suffix}`; const agent = { contactCreated: async () => undefined, companyCreated: async () => undefined, + withCrmEvents: withDiscardedCrmEvents, companyRequested: async () => undefined, } as unknown as AgentTriggerService; const stamp = new ActivityStampService(db); -const directory = new CompanyDirectoryService(db, agent); +const directory = new CompanyDirectoryService(agent); const log = new EnrichmentLogService(db, stamp); const match = new MailboxMatchService(db, directory, agent, log); const threads = new ThreadWriterService(db, match, stamp); diff --git a/apps/api/test/record-delete.spec.ts b/apps/api/test/record-delete.spec.ts index 6c361f860..15c03b241 100644 --- a/apps/api/test/record-delete.spec.ts +++ b/apps/api/test/record-delete.spec.ts @@ -11,6 +11,7 @@ import { EnrichmentLogService } from "../src/crm/enrichment-log.service"; import { ConversionService } from "../src/currency/conversion.service"; import { FieldsService } from "../src/fields/fields.service"; import { MailboxMatchService } from "../src/mailbox/mailbox-match.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; const suffix = process.env.TEST_RUN_ID ?? "record-delete-spec"; const domain = `delete-${suffix}.test`; @@ -26,10 +27,11 @@ const stamp = new ActivityStampService(db); const agent = { contactCreated: async () => undefined, companyCreated: async () => undefined, + withCrmEvents: withDiscardedCrmEvents, companyRequested: async () => undefined, } as unknown as AgentTriggerService; -const directory = new CompanyDirectoryService(db, agent); +const directory = new CompanyDirectoryService(agent); const log = new EnrichmentLogService(db, stamp); const queue = new AgentQueueService(db); const conversion = new ConversionService(db); diff --git a/apps/api/test/slack-channels.spec.ts b/apps/api/test/slack-channels.spec.ts new file mode 100644 index 000000000..d9e6ef0e8 --- /dev/null +++ b/apps/api/test/slack-channels.spec.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { SlackChannelsService } from "../src/slack/slack-channels.service"; + +const service = new SlackChannelsService(); +const realFetch = globalThis.fetch; +const realSecret = process.env.AGENT_BRIDGE_SECRET; + +function agentAnswers(status: number, body: string | null) { + globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => + new Response(body, { + status, + headers: { "content-type": "application/json" }, + })) as typeof fetch; +} + +beforeEach(() => { + process.env.AGENT_BRIDGE_SECRET = "slack-channel-test"; +}); + +afterEach(() => { + globalThis.fetch = realFetch; + if (realSecret === undefined) { + delete process.env.AGENT_BRIDGE_SECRET; + } else { + process.env.AGENT_BRIDGE_SECRET = realSecret; + } +}); + +describe("creating a Slack channel", () => { + it("returns the channel the agent created", async () => { + agentAnswers(200, JSON.stringify({ channel: { id: "C1", name: "deals" } })); + + expect(await service.create("deals", false)).toEqual({ + channel: { id: "C1", name: "deals" }, + }); + }); + + it("reports an agent outage as an outage, not as a bad request", async () => { + agentAnswers(502, "Bad Gateway"); + + await expect(service.create("deals", false)).rejects.toThrow( + "The agent failed, so the channel was not created.", + ); + }); + + it("reports an unreadable answer as an outage", async () => { + agentAnswers(200, "not json at all"); + + await expect(service.create("deals", false)).rejects.toThrow( + "The agent answered with something unreadable, so the channel was not created.", + ); + }); + + it("tells the caller what Slack refused", async () => { + agentAnswers(422, JSON.stringify({ error: "That name is taken." })); + + await expect(service.create("deals", false)).rejects.toThrow( + "That name is taken.", + ); + }); + + it("says nothing can reach Slack without a bridge secret", async () => { + delete process.env.AGENT_BRIDGE_SECRET; + + await expect(service.create("deals", false)).rejects.toThrow( + "This install has no AGENT_BRIDGE_SECRET, so nothing can reach Slack.", + ); + }); +}); diff --git a/apps/api/test/slack-connection.spec.ts b/apps/api/test/slack-connection.spec.ts new file mode 100644 index 000000000..74512c305 --- /dev/null +++ b/apps/api/test/slack-connection.spec.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "bun:test"; +import type { WorkspaceRole } from "@crm/auth"; +import type { Db } from "@crm/db"; +import type { AgentAccessService } from "../src/agent/agent-access.service"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import type { SlackChannelsService } from "../src/slack/slack-channels.service"; +import { SlackConnectionService } from "../src/slack/slack-connection.service"; + +const userId = "crm-1"; + +function serviceFor(input: { + accountUpdatedAt?: Date; + matches?: Array<{ slackUserId: string | null; updatedAt: Date }>; + members?: Array<{ + user: { + id: string; + name: string; + email: string; + slackMemberMatch: { + slackUserId: string | null; + slackHandle: string | null; + slackEmail: string | null; + } | null; + }; + }>; + memberCount?: number; + agents?: unknown[]; + syncingTask?: { + createdAt: Date; + startedAt: Date | null; + leasedUntil: Date | null; + }; + grant?: boolean; + role?: WorkspaceRole; +}) { + const requested: Array<{ reason: string; required: boolean | undefined }> = + []; + const deleted: string[] = []; + const tx = { + account: { + deleteMany: async () => { + deleted.push("account"); + return { count: input.accountUpdatedAt ? 1 : 0 }; + }, + }, + slackChannel: { + deleteMany: async () => { + deleted.push("slackChannel"); + return { count: 0 }; + }, + }, + slackWorkspaceGrant: { + deleteMany: async () => { + deleted.push("slackWorkspaceGrant"); + return { count: 0 }; + }, + }, + }; + const db = { + $transaction: async (run: (client: typeof tx) => Promise) => run(tx), + account: { + findFirst: async () => + input.accountUpdatedAt + ? { + id: "account-1", + accountId: "slack-user", + updatedAt: input.accountUpdatedAt, + } + : null, + }, + agentDefinition: { findMany: async () => input.agents ?? [] }, + slackMemberMatch: { findMany: async () => input.matches ?? [] }, + slackWorkspaceGrant: { + findFirst: async () => (input.grant ? { id: "grant-1" } : null), + }, + member: { + count: async () => input.memberCount ?? 0, + findMany: async () => input.members ?? [], + }, + agentTask: { + findFirst: async () => input.syncingTask ?? null, + }, + } as unknown as Db; + const agent = { + slackPeopleRequested: async (reason: string, required?: boolean) => { + requested.push({ reason, required }); + }, + } as AgentTriggerService; + const channels = {} as SlackChannelsService; + const access = { + assertMember: async () => input.role ?? "member", + } as unknown as AgentAccessService; + + return { + service: new SlackConnectionService(db, agent, channels, access), + requested, + deleted, + }; +} + +describe("Slack connection", () => { + it("requests one inventory refresh when the connected account is newer", async () => { + const connectedAt = new Date("2026-08-10T10:00:00.000Z"); + const { service, requested } = serviceFor({ + accountUpdatedAt: connectedAt, + memberCount: 2, + matches: [ + { + slackUserId: "U1", + updatedAt: new Date("2026-08-10T09:00:00.000Z"), + }, + ], + }); + + const status = await service.status(userId); + + expect(status.connected).toBe(true); + expect(status.people).toEqual({ matched: 1, reviewed: 1 }); + expect(requested).toEqual([ + { + reason: "Match workspace members to Slack accounts by exact email", + required: undefined, + }, + ]); + }); + + it("does not refresh a complete inventory that was read after connecting", async () => { + const connectedAt = new Date("2026-08-10T10:00:00.000Z"); + const reviewedAt = new Date("2026-08-10T10:00:01.000Z"); + const { service, requested } = serviceFor({ + accountUpdatedAt: connectedAt, + memberCount: 2, + matches: [ + { slackUserId: "U1", updatedAt: reviewedAt }, + { slackUserId: null, updatedAt: reviewedAt }, + ], + }); + + const status = await service.status(userId); + + expect(status.people).toEqual({ matched: 1, reviewed: 2 }); + expect(requested).toEqual([]); + }); + + it("returns only real CRM members and their stored exact-email matches", async () => { + const { service } = serviceFor({ + members: [ + { + user: { + id: "crm-1", + name: "Grim", + email: "grim@example.test", + slackMemberMatch: { + slackUserId: "U1", + slackHandle: "@grim", + slackEmail: "grim@example.test", + }, + }, + }, + ], + syncingTask: { + createdAt: new Date(), + startedAt: null, + leasedUntil: null, + }, + }); + + expect(await service.matches(userId)).toEqual({ + rows: [ + { + crmUserId: "crm-1", + name: "Grim", + email: "grim@example.test", + match: { + slackUserId: "U1", + slackHandle: "@grim", + slackEmail: "grim@example.test", + }, + }, + ], + sync: "syncing", + }); + }); + + it("reports a stalled sync when nothing picks the task up", async () => { + const { service } = serviceFor({ + syncingTask: { + createdAt: new Date(Date.now() - 10 * 60_000), + startedAt: null, + leasedUntil: null, + }, + }); + + expect((await service.matches(userId)).sync).toBe("stalled"); + }); + + it("reports a running sync while the agent holds the lease", async () => { + const { service } = serviceFor({ + syncingTask: { + createdAt: new Date(Date.now() - 10 * 60_000), + startedAt: null, + leasedUntil: new Date(Date.now() + 60_000), + }, + }); + + expect((await service.matches(userId)).sync).toBe("syncing"); + }); + + it("reports an idle sync when no task waits", async () => { + const { service } = serviceFor({}); + + expect((await service.matches(userId)).sync).toBe("idle"); + }); + + it("refuses to disconnect the workspace for a member", async () => { + const { service, deleted } = serviceFor({ + accountUpdatedAt: new Date("2026-08-10T10:00:00.000Z"), + role: "member", + }); + + await expect(service.disconnect(userId)).rejects.toThrow( + "Only an owner or an admin can disconnect Slack.", + ); + expect(deleted).toEqual([]); + }); + + it("tells a member that they cannot disconnect", async () => { + const { service } = serviceFor({ + accountUpdatedAt: new Date("2026-08-10T10:00:00.000Z"), + role: "member", + }); + + expect((await service.status(userId)).canManage).toBe(false); + }); + + it("disconnects the workspace for an admin", async () => { + const { service, deleted } = serviceFor({ + accountUpdatedAt: new Date("2026-08-10T10:00:00.000Z"), + role: "admin", + }); + + expect(await service.disconnect(userId)).toEqual({ disconnected: true }); + expect(deleted).toEqual(["account", "slackChannel", "slackWorkspaceGrant"]); + }); +}); diff --git a/apps/api/test/tracking-filing.integration.spec.ts b/apps/api/test/tracking-filing.integration.spec.ts index a154d7a3c..2c152cb7d 100644 --- a/apps/api/test/tracking-filing.integration.spec.ts +++ b/apps/api/test/tracking-filing.integration.spec.ts @@ -17,6 +17,7 @@ import { CompanyDirectoryService } from "../src/companies/company-directory.serv import { ActivityStampService } from "../src/crm/activity-stamp.service"; import { TrackingCounterService } from "../src/tracking/tracking-counter.service"; import { TrackingFilingService } from "../src/tracking/tracking-filing.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; const suffix = process.env.TEST_RUN_ID ?? "filing-spec"; const domain = `visitors-${suffix}.test`; @@ -30,11 +31,12 @@ const agent = { }, companyCreated: async () => undefined, companyRequested: async () => undefined, + withCrmEvents: withDiscardedCrmEvents, } as unknown as AgentTriggerService; const stamp = new ActivityStampService(db); const counters = new TrackingCounterService(db); -const directory = new CompanyDirectoryService(db, agent); +const directory = new CompanyDirectoryService(agent); const filing = new TrackingFilingService(db, counters, directory, agent, stamp); let userId: string; diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx b/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx index 741214ce3..baaf9e59d 100644 --- a/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx +++ b/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx @@ -3,8 +3,8 @@ import { notFound } from "next/navigation"; import { Suspense } from "react"; import { TeamAgentDetail } from "@/components/agent-builder/team-agent-detail"; import { PageShellFallback } from "@/components/page-shell"; -import { HydrateClient } from "@/lib/trpc/hydrate"; -import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { getServerTrpcClient } from "@/lib/trpc/server"; +import { nullIfMissing } from "../../missing-record"; export const metadata: Metadata = { title: "Team agent" }; @@ -28,32 +28,26 @@ async function PrefetchedTeamAgent({ const { agentId } = await params; if (agentId === "team") notFound(); - const trpc = getServerTrpc(); - const queryClient = getServerQueryClient(); - const agentQuery = trpc.agents.byId.queryOptions({ id: agentId }); - const runsQuery = trpc.agents.history.queryOptions({ - id: agentId, - limit: 50, - }); - const activityQuery = trpc.agents.activity.queryOptions({ - id: agentId, - limit: 100, - }); + const client = getServerTrpcClient(); const [agent, runs, activity] = await Promise.all([ - queryClient.fetchQuery(agentQuery), - queryClient.fetchQuery(runsQuery), - queryClient.fetchQuery(activityQuery), + client.agents.byId.query({ id: agentId }).catch(nullIfMissing), + client.agents.history + .query({ id: agentId, limit: 50 }) + .catch(nullIfMissing), + client.agents.activity + .query({ id: agentId, limit: 100 }) + .catch(nullIfMissing), ]); + if (!agent || !runs || !activity) notFound(); + return ( - - - + ); } diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/chat/[chatId]/page.tsx b/apps/app/app/(app)/[slug]/(agent-builder)/chat/[chatId]/page.tsx index 23ac83c2b..68014cd94 100644 --- a/apps/app/app/(app)/[slug]/(agent-builder)/chat/[chatId]/page.tsx +++ b/apps/app/app/(app)/[slug]/(agent-builder)/chat/[chatId]/page.tsx @@ -1,9 +1,11 @@ import type { Metadata } from "next"; +import { notFound } from "next/navigation"; import { Suspense } from "react"; import { AgentBuilderChat } from "@/components/agent-builder/agent-builder-chat"; import { AgentBuilderChatFallback } from "@/components/agent-builder/agent-builder-route-fallback"; import { isSharedChatToken } from "@/lib/chat-route"; -import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { getServerTrpcClient } from "@/lib/trpc/server"; +import { nullIfMissing } from "../../missing-record"; export const metadata: Metadata = { title: "Agent chat" }; @@ -25,19 +27,23 @@ async function PrefetchedAgentChat({ params: Promise<{ chatId: string }>; }) { const { chatId } = await params; - const trpc = getServerTrpc(); - const queryClient = getServerQueryClient(); - const sharedChat = isSharedChatToken(chatId); - - const initialData = sharedChat - ? await queryClient - .fetchQuery(trpc.conversations.shared.queryOptions({ token: chatId })) - .catch(() => null) - : await queryClient.fetchQuery( - trpc.conversations.builderById.queryOptions({ - id: chatId, - }), - ); - - return ; + const client = getServerTrpcClient(); + + if (isSharedChatToken(chatId)) { + const shared = await client.conversations.shared + .query({ token: chatId }) + .catch(nullIfMissing); + + return ; + } + + const conversation = await client.conversations.builderById + .query({ id: chatId }) + .catch(nullIfMissing); + + if (!conversation) notFound(); + + return ( + + ); } diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/missing-record.ts b/apps/app/app/(app)/[slug]/(agent-builder)/missing-record.ts new file mode 100644 index 000000000..66d6f58a2 --- /dev/null +++ b/apps/app/app/(app)/[slug]/(agent-builder)/missing-record.ts @@ -0,0 +1,9 @@ +import { TRPCClientError } from "@trpc/client"; + +export function nullIfMissing(error: unknown): null { + if (error instanceof TRPCClientError && error.data?.code === "NOT_FOUND") { + return null; + } + + throw error; +} diff --git a/apps/app/app/(app)/[slug]/settings/connections/add-connection-dialog.tsx b/apps/app/app/(app)/[slug]/settings/connections/add-connection-dialog.tsx new file mode 100644 index 000000000..fd41e8343 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/connections/add-connection-dialog.tsx @@ -0,0 +1,130 @@ +"use client"; + +import Plug from "@carbon/icons-react/es/Plug"; +import DocusignLogo from "@crm/ui/components/brand-logos/docusign"; +import GoogleLogo from "@crm/ui/components/brand-logos/google"; +import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; +import SlackLogo from "@crm/ui/components/brand-logos/slack"; +import StripeLogo from "@crm/ui/components/brand-logos/stripe"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@crm/ui/components/dialog"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +export function AddConnectionDialog({ + slug, + open, + connected, +}: { + slug: string; + open: boolean; + connected: string[]; +}) { + const router = useRouter(); + return ( + { + if (!next) router.replace(`/${slug}/settings/connections`); + }} + > + + + Add a connection + + Nothing moves until you finish setting one up. + + +
+ {!connected.includes("Google Workspace") ? ( + + ) : null} + {!connected.includes("Slack") ? ( + + ) : null} + {!connected.includes("Microsoft 365") ? ( + + ) : null} + + + +
+

+ {connected.length > 0 + ? `${connected.join(", ")} ${connected.length === 1 ? "is" : "are"} already connected.` + : "Nothing is connected yet."} +

+
+
+ ); +} + +function CatalogRow({ + logo: Logo, + name, + description, + href, +}: { + logo: React.ComponentType>; + name: string; + description: string; + href?: string; +}) { + const content = ( + <> + +
+

{name}

+

{description}

+
+ + ); + return href ? ( + + {content} + + ) : ( +
+ {content} +
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/connections/connection-page.tsx b/apps/app/app/(app)/[slug]/settings/connections/connection-page.tsx new file mode 100644 index 000000000..c6114d515 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/connections/connection-page.tsx @@ -0,0 +1,34 @@ +import { Spinner } from "@crm/ui/components/spinner"; +import { cn } from "@crm/ui/lib/utils"; + +export function ConnectionPage({ + centered = false, + className, + children, +}: { + centered?: boolean; + className?: string; + children: React.ReactNode; +}) { + return ( +
+
+ {children} +
+
+ ); +} + +export function ConnectionPageLoading() { + return ( +
+ +
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx b/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx index a124f658f..bc9adb386 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx @@ -128,7 +128,13 @@ const CONNECT_ERRORS: Record = { "That Google account has a different email address to the one you sign in with, so it cannot be attached to your account. Connect the Google account that matches your sign-in address.", }; -function ConnectGoogle({ connectError }: { connectError?: string }) { +function ConnectGoogle({ + slug, + connectError, +}: { + slug: string; + connectError?: string; +}) { const [pending, setPending] = useState(false); function fail(message?: string) { @@ -144,8 +150,8 @@ function ConnectGoogle({ connectError }: { connectError?: string }) { const { error } = await authClient.linkSocial({ provider: "google", scopes: [...SYNC_SCOPES], - callbackURL: `${origin}/settings/connections`, - errorCallbackURL: `${origin}/settings/connections?provider=google`, + callbackURL: `${origin}/${slug}/settings/connections/google`, + errorCallbackURL: `${origin}/${slug}/settings/connections/google?provider=google`, }); if (error) fail(error.message); @@ -200,7 +206,13 @@ function ConnectGoogle({ connectError }: { connectError?: string }) { ); } -export function GoogleConnection({ connectError }: { connectError?: string }) { +export function GoogleConnection({ + slug, + connectError, +}: { + slug: string; + connectError?: string; +}) { const trpc = useTRPC(); const cache = useCrmCache(); const queryClient = useQueryClient(); @@ -227,7 +239,7 @@ export function GoogleConnection({ connectError }: { connectError?: string }) { trpc.google.revokeAccess.mutationOptions({ onSuccess: () => window.location.assign( - status.data?.required ? "/" : "/settings/connections", + status.data?.required ? "/" : `/${slug}/settings/connections`, ), onError: (error) => toast.error(error.message), }), @@ -265,7 +277,7 @@ export function GoogleConnection({ connectError }: { connectError?: string }) { status.data; if (!configured) return ; - if (!linked) return ; + if (!linked) return ; const failing = sources.filter( (source) => source.status === "NEEDS_RECONNECT" || source.lastError, diff --git a/apps/app/app/(app)/[slug]/settings/connections/google/page.tsx b/apps/app/app/(app)/[slug]/settings/connections/google/page.tsx new file mode 100644 index 000000000..57b684479 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/connections/google/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import { GoogleConnection } from "../google-connection"; +import { + type ConnectionQuery, + OAuthConnectionPage, +} from "../oauth-connection-page"; + +export const metadata: Metadata = { title: "Google Workspace" }; + +export default function GoogleConnectionPage(props: { + params: Promise<{ slug: string }>; + searchParams: Promise; +}) { + return ( + + ); +} diff --git a/apps/app/app/(app)/[slug]/settings/connections/intake/page.tsx b/apps/app/app/(app)/[slug]/settings/connections/intake/page.tsx new file mode 100644 index 000000000..ceb3b2335 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/connections/intake/page.tsx @@ -0,0 +1,41 @@ +import { Button } from "@crm/ui/components/button"; +import Link from "next/link"; +import { Suspense } from "react"; +import { requireSession } from "@/lib/session"; +import { ConnectionPage, ConnectionPageLoading } from "../connection-page"; + +export default function IntakeConnectionPage( + props: PageProps<"/[slug]/settings/connections/intake">, +) { + return ( + }> + + + ); +} + +async function IntakeConnectionPageContent({ + params, +}: PageProps<"/[slug]/settings/connections/intake">) { + await requireSession(); + const { slug } = await params; + + return ( + +
+

Intake endpoint

+

+ This connection is not available yet. No endpoint, API key, or intake + activity has been created for this workspace. +

+
+
+ +
+
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx b/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx index 86ad32237..9418fe806 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx @@ -66,7 +66,13 @@ function MicrosoftUnavailable() { ); } -function ConnectMicrosoft({ connectError }: { connectError?: string }) { +function ConnectMicrosoft({ + slug, + connectError, +}: { + slug: string; + connectError?: string; +}) { const [pending, setPending] = useState(false); function fail(message?: string) { @@ -82,8 +88,8 @@ function ConnectMicrosoft({ connectError }: { connectError?: string }) { const { error } = await authClient.linkSocial({ provider: "microsoft", scopes: [...MICROSOFT_SYNC_SCOPES], - callbackURL: `${origin}/settings/connections`, - errorCallbackURL: `${origin}/settings/connections?provider=microsoft`, + callbackURL: `${origin}/${slug}/settings/connections/microsoft`, + errorCallbackURL: `${origin}/${slug}/settings/connections/microsoft?provider=microsoft`, }); if (error) fail(error.message); @@ -139,8 +145,10 @@ function ConnectMicrosoft({ connectError }: { connectError?: string }) { } export function MicrosoftConnection({ + slug, connectError, }: { + slug: string; connectError?: string; }) { const trpc = useTRPC(); @@ -168,7 +176,7 @@ export function MicrosoftConnection({ trpc.microsoft.revokeAccess.mutationOptions({ onSuccess: () => window.location.assign( - status.data?.required ? "/" : "/settings/connections", + status.data?.required ? "/" : `/${slug}/settings/connections`, ), onError: (error) => toast.error(error.message), }), @@ -194,7 +202,9 @@ export function MicrosoftConnection({ status.data; if (!configured) return ; - if (!linked) return ; + if (!linked) { + return ; + } const failing = sources.filter( (source) => source.status === "NEEDS_RECONNECT" || source.lastError, diff --git a/apps/app/app/(app)/[slug]/settings/connections/microsoft/page.tsx b/apps/app/app/(app)/[slug]/settings/connections/microsoft/page.tsx new file mode 100644 index 000000000..a8d389ebd --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/connections/microsoft/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import { MicrosoftConnection } from "../microsoft-connection"; +import { + type ConnectionQuery, + OAuthConnectionPage, +} from "../oauth-connection-page"; + +export const metadata: Metadata = { title: "Microsoft 365" }; + +export default function MicrosoftConnectionPage(props: { + params: Promise<{ slug: string }>; + searchParams: Promise; +}) { + return ( + + ); +} diff --git a/apps/app/app/(app)/[slug]/settings/connections/oauth-connection-page.tsx b/apps/app/app/(app)/[slug]/settings/connections/oauth-connection-page.tsx new file mode 100644 index 000000000..802cebfa1 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/connections/oauth-connection-page.tsx @@ -0,0 +1,42 @@ +import { Suspense } from "react"; +import { ConnectionPage, ConnectionPageLoading } from "./connection-page"; + +export type ConnectionQuery = Record; + +type OAuthConnectionPageProps = { + connection: React.ComponentType<{ slug: string; connectError?: string }>; + params: Promise<{ slug: string }>; + provider: string; + searchParams: Promise; +}; + +export function OAuthConnectionPage(props: OAuthConnectionPageProps) { + return ( + }> + + + ); +} + +export function connectErrorOf(query: ConnectionQuery, provider: string) { + return first(query.provider) === provider ? first(query.error) : undefined; +} + +async function OAuthConnectionPageContent({ + connection: Connection, + params, + provider, + searchParams, +}: OAuthConnectionPageProps) { + const [{ slug }, query] = await Promise.all([params, searchParams]); + + return ( + + + + ); +} + +function first(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} diff --git a/apps/app/app/(app)/[slug]/settings/connections/page.tsx b/apps/app/app/(app)/[slug]/settings/connections/page.tsx index 38075762c..264eb7e97 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/page.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/page.tsx @@ -1,79 +1,236 @@ +import GoogleLogo from "@crm/ui/components/brand-logos/google"; +import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; +import SlackLogo from "@crm/ui/components/brand-logos/slack"; +import { Button } from "@crm/ui/components/button"; +import { Spinner } from "@crm/ui/components/spinner"; import type { Metadata } from "next"; +import Link from "next/link"; import { Suspense } from "react"; -import { - PageShell, - PageShellContent, - PageShellDescription, - PageShellHeader, - PageShellHeading, - PageShellLoading, - PageShellTitle, -} from "@/components/page-shell"; import { requireSession } from "@/lib/session"; -import { HydrateClient } from "@/lib/trpc/hydrate"; import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; -import { GoogleConnection } from "./google-connection"; -import { MicrosoftConnection } from "./microsoft-connection"; +import { AddConnectionDialog } from "./add-connection-dialog"; -export const metadata: Metadata = { - title: "Connections", -}; +export const metadata: Metadata = { title: "Connections" }; -export default function ConnectionsSettingsPage({ - searchParams, -}: PageProps<"/[slug]/settings/connections">) { +export default function ConnectionsSettingsPage( + props: PageProps<"/[slug]/settings/connections">, +) { return ( - - - - Connections - - Your meetings and email, on the companies they belong to. - - - - - - }> - - - - + }> + + ); } -async function Connections({ +async function ConnectionsSettingsPageContent({ + params, searchParams, -}: Pick, "searchParams">) { +}: PageProps<"/[slug]/settings/connections">) { await requireSession(); - - const trpc = getServerTrpc(); + const [{ slug }, query] = await Promise.all([params, searchParams]); const queryClient = getServerQueryClient(); - - const [{ error, provider }] = await Promise.all([ - searchParams, - queryClient.prefetchQuery(trpc.google.status.queryOptions()), - queryClient.prefetchQuery(trpc.microsoft.status.queryOptions()), + const trpc = getServerTrpc(); + const [google, microsoft, slack] = await Promise.all([ + queryClient.fetchQuery(trpc.google.status.queryOptions()), + queryClient.fetchQuery(trpc.microsoft.status.queryOptions()), + queryClient.fetchQuery(trpc.slack.status.queryOptions()), ]); + const rows = [ + ...(google.linked + ? [ + { + name: "Google Workspace", + status: "Connected", + bringsIn: "Emails, meetings and the people on them", + sends: "Nothing yet", + href: `/${slug}/settings/connections/google`, + logo: GoogleLogo, + }, + ] + : []), + ...(slack.connected + ? [ + { + name: "Slack", + status: slack.workspace + ? `Connected to ${slack.workspace}` + : "Connected", + bringsIn: "Workspace members and channels the app has joined", + sends: "Messages to approved channels and people", + href: `/${slug}/settings/connections/slack`, + logo: SlackLogo, + }, + ] + : []), + ...(microsoft.linked + ? [ + { + name: "Microsoft 365", + status: "Connected", + bringsIn: "Outlook email and the people on it", + sends: "Nothing yet", + href: `/${slug}/settings/connections/microsoft`, + logo: MicrosoftLogo, + }, + ] + : []), + ]; + + return ( +
+ {rows.length > 0 ? ( +
+
+
+

+ Connections +

+

+ Where your CRM gets its information, and what it is allowed to + send on your behalf. +

+
+ +
+
+ {rows.map((row) => ( + + ))} +
+
+ ) : ( +
+
+

+ Nothing is connected yet +

+

+ Right now every deal, contact and note has to be typed in by hand. + Connect a tool and the CRM starts filling itself in from the work + your team already does. +

+
+
+ + + +
+

+ Looking for something else?{" "} + + Browse all connections + +

+
+ )} + row.name)} + /> +
+ ); +} - const connectError = first(error); - const failed = first(provider); +function ConnectionsFallback() { + return ( +
+ +
+ ); +} + +function ConnectionCard({ + name, + status, + bringsIn, + sends, + href, + logo: Logo, +}: { + name: string; + status: string; + bringsIn: string; + sends: string; + href: string; + logo: React.ComponentType>; +}) { + return ( +
+
+ +

{name}

+

+ {status} +

+ +
+
+ + +
+
+ ); +} +function CapabilityRow({ label, value }: { label: string; value: string }) { return ( - -
- +
+ {label} + {value} +
+ ); +} - +function StarterRow({ + logo: Logo, + name, + description, + href, +}: { + logo: React.ComponentType>; + name: string; + description: string; + href: string; +}) { + return ( +
+ +
+

{name}

+

{description}

- + +
); } -function first(value: string | string[] | undefined): string | undefined { +function first(value: string | string[] | undefined) { return Array.isArray(value) ? value[0] : value; } diff --git a/apps/app/app/(app)/[slug]/settings/connections/slack/page.tsx b/apps/app/app/(app)/[slug]/settings/connections/slack/page.tsx new file mode 100644 index 000000000..a62e4f8d6 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/connections/slack/page.tsx @@ -0,0 +1,353 @@ +import Close from "@carbon/icons-react/es/Close"; +import Warning from "@carbon/icons-react/es/Warning"; +import { + describeSlackScopes, + SLACK_REQUESTED_SCOPES, + SLACK_SCOPE_GROUPS, + SLACK_USER_GRANT, + type SlackScope, + slackScopeDrift, +} from "@crm/auth"; +import { + Alert, + AlertAction, + AlertDescription, + AlertTitle, +} from "@crm/ui/components/alert"; +import SlackLogo from "@crm/ui/components/brand-logos/slack"; +import { Button } from "@crm/ui/components/button"; +import { Icon } from "@crm/ui/components/icon"; +import Link from "next/link"; +import { Suspense } from "react"; +import { NewAgentDialog } from "@/components/agent-builder/new-agent-dialog"; +import { requireSession } from "@/lib/session"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { ConnectionPage, ConnectionPageLoading } from "../connection-page"; +import { type ConnectionQuery, connectErrorOf } from "../oauth-connection-page"; +import { SlackChannels } from "./slack-channels"; +import { + SlackConnectButton, + SlackReconnectButton, +} from "./slack-connect-button"; +import { SlackDisconnectButton } from "./slack-disconnect-button"; +import { SlackScopeGroups } from "./slack-scope-groups"; + +const PRIVATE_CHANNEL_SCOPES = [ + "groups:read", + "groups:history", + SLACK_USER_GRANT.scope, +]; + +const never = [ + "Send anything at all until you build an automation and switch it on", + "Post anywhere except the destination approved in that automation", + "Read a direct message between two people", +]; + +const suggestions = [ + ["When a deal is created", "Post the deal to an approved sales channel."], + ["When a deal is won", "Tell an approved channel that the deal closed."], + ["When a deal reopens", "Notify one approved channel or teammate."], +]; + +type SlackConnectionPageProps = { + params: Promise<{ slug: string }>; + searchParams: Promise; +}; + +export default function SlackConnectionPage(props: SlackConnectionPageProps) { + return ( + }> + + + ); +} + +async function SlackConnectionPageContent({ + params, + searchParams, +}: SlackConnectionPageProps) { + await requireSession(); + const [{ slug }, query] = await Promise.all([params, searchParams]); + const queryClient = getServerQueryClient(); + const status = await queryClient.fetchQuery( + getServerTrpc().slack.status.queryOptions(), + ); + return status.connected ? ( + + ) : ( + +
+
+ +

Slack

+ + Not connected + +
+

+ Connecting Slack gives the CRM a way in and a way out. What it + actually does with that is up to you afterwards, one automation at a + time. +

+
+ + +
+ +

+ You approve the workspace in Slack. You can disconnect it here at any + time. +

+
+
+
+

+ Afterwards, most teams start with one of these +

+

+ Suggestions, not settings. None of them exist until you pick one and + switch it on. +

+
+
+ {suggestions.map(([name, description]) => ( +
+

{name}

+

+ {description} +

+
+ ))} +
+
+
+ ); +} + +function toLine(entry: SlackScope) { + return { + scope: entry.scope, + grant: entry.grant, + sensitive: entry.sensitive, + }; +} + +function groupScopes(scopes: string[]) { + const held = describeSlackScopes(scopes); + + return SLACK_SCOPE_GROUPS.map((group) => ({ + id: group.id, + label: group.label, + summary: group.summary, + scopes: held.filter((entry) => entry.group === group.id).map(toLine), + })).filter((group) => group.scopes.length > 0); +} + +function ConnectedSlack({ + slug, + status, +}: { + slug: string; + status: { + workspace: string | null; + agents: Array<{ + id: string; + name: string; + description: string | null; + status: string; + }>; + scopes: string[]; + canInviteItself: boolean; + canManage: boolean; + people: { matched: number; reviewed: number }; + }; +}) { + const agents = status.agents; + const drift = slackScopeDrift(status.scopes); + const missing = status.canInviteItself + ? drift.missing + : [...drift.missing, SLACK_USER_GRANT]; + return ( + +
+
+ +

Slack

+ + {status.workspace ?? "Connected"} + + +
+

+ {status.canManage + ? "Here is what Slack gave us. Agents only post where their automation says." + : "Here is what Slack gave us. Only an owner or an admin can disconnect it."} +

+
+ + + +
+
+
+

Agents that use Slack

+

+ Built in chat, not here. Open one to change it. +

+
+ + + +
+
+ {agents.length === 0 ? ( +

+ No deployed agents use Slack yet. +

+ ) : null} + {agents.map( + (agent: { + id: string; + name: string; + description: string | null; + status: string; + }) => ( + +
+

{agent.name}

+

+ {agent.description} +

+
+ + + {agent.status === "LIVE" ? "Running" : "Paused"} + + + ), + )} + + Describe another agent in chat + +
+
+
+

+ {status.people.reviewed === 0 + ? "No workspace people have been reviewed yet." + : `${status.people.matched} of ${status.people.reviewed} reviewed people are matched.`} +

+ +
+
+ ); +} + +function MissingGrant({ + slug, + missing, +}: { + slug: string; + missing: SlackScope[]; +}) { + if (missing.length === 0) return null; + + const privateChannels = missing.some((entry) => + PRIVATE_CHANNEL_SCOPES.includes(entry.scope), + ); + + return ( +
+ + + + {privateChannels + ? "Comp AI cannot reach private channels" + : `Slack held back ${missing.length} permission${missing.length === 1 ? "" : "s"}`} + + + Reconnect to ask again. You lose nothing. +
    + {missing.map((entry) => ( +
  • + + {entry.grant} +
  • + ))} +
+
+ + + +
+
+ ); +} + +function PlainList({ + title, + items, + icon, + tone, +}: { + title: string; + items: string[]; + icon: React.ComponentType; + tone: string; +}) { + return ( +
+

{title}

+
+ {items.map((item) => ( +
+ + {item} +
+ ))} +
+
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/connections/slack/people/page.tsx b/apps/app/app/(app)/[slug]/settings/connections/slack/people/page.tsx new file mode 100644 index 000000000..d50a2b850 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/connections/slack/people/page.tsx @@ -0,0 +1,54 @@ +import SlackLogo from "@crm/ui/components/brand-logos/slack"; +import { Spinner } from "@crm/ui/components/spinner"; +import { redirect } from "next/navigation"; +import { Suspense } from "react"; +import { requireSession } from "@/lib/session"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { ConnectionPage } from "../../connection-page"; +import { SlackPeopleMatches } from "./slack-people-matches"; + +type SlackPeoplePageProps = { + params: Promise<{ slug: string }>; +}; + +export default function SlackPeoplePage(props: SlackPeoplePageProps) { + return ( + + + + } + > + + + ); +} + +async function SlackPeoplePageContent({ params }: SlackPeoplePageProps) { + await requireSession(); + const { slug } = await params; + const queryClient = getServerQueryClient(); + const trpc = getServerTrpc(); + const status = await queryClient.fetchQuery(trpc.slack.status.queryOptions()); + if (!status.connected) redirect(`/${slug}/settings/connections/slack`); + const matches = await queryClient.fetchQuery( + trpc.slack.matches.queryOptions(), + ); + + return ( + +
+ +

+ Slack is connected +

+

+ Match your CRM people to Slack once. Agents use these exact accounts + later instead of guessing from a similar name. +

+
+ +
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/connections/slack/people/slack-people-matches.tsx b/apps/app/app/(app)/[slug]/settings/connections/slack/people/slack-people-matches.tsx new file mode 100644 index 000000000..4ff3b4e02 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/connections/slack/people/slack-people-matches.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { Spinner } from "@crm/ui/components/spinner"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { toast } from "sonner"; +import { SLACK_CHANNELS } from "@/components/slack/use-slack-channels"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; + +type SlackSync = "idle" | "syncing" | "stalled"; + +type MatchRow = { + crmUserId: string; + name: string; + email: string; + match: { + slackUserId: string | null; + slackHandle: string | null; + slackEmail: string | null; + } | null; +}; + +export function SlackPeopleMatches({ + slug, + initialMatches, +}: { + slug: string; + initialMatches: { rows: MatchRow[]; sync: SlackSync }; +}) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const matches = useQuery({ + ...trpc.slack.matches.queryOptions(), + initialData: initialMatches, + refetchInterval: (query) => + query.state.data?.sync === "syncing" ? SLACK_CHANNELS.pollMs : false, + }); + const refresh = useMutation( + trpc.slack.refreshPeople.mutationOptions({ + onSuccess: () => cache.slack(), + onError: (error) => toast.error(error.message), + }), + ); + const rows = matches.data.rows; + const refreshing = refresh.isPending || matches.data.sync === "syncing"; + return ( +
+
+

+ {rows.filter((row) => row.match?.slackUserId).length} of {rows.length}{" "} + matched +

+ +
+ {matches.data.sync === "stalled" ? ( +

+ Comp AI is not reading Slack right now. These matches can be out of + date. +

+ ) : null} +
+ {rows.length === 0 ? ( +

+ No CRM teammates are available to match. +

+ ) : null} + {rows.map((row) => ( +
+
+

{row.name}

+

+ {row.email} +

+
+
+ {row.match?.slackHandle ? ( +
+ {row.match.slackHandle} +
+ ) : ( +

+ No exact email match +

+ )} +
+
+ ))} +
+

+ Refresh after a Slack email changes. The CRM matches exact email + addresses only. Someone with no exact match stays unmatched, and an + agent stops instead of guessing at a similar name. +

+
+ +
+
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/connections/slack/slack-channels.tsx b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-channels.tsx new file mode 100644 index 000000000..bbf9d9ff0 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-channels.tsx @@ -0,0 +1,216 @@ +"use client"; +import Search from "@carbon/icons-react/es/Search"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@crm/ui/components/alert-dialog"; +import { + AsyncButtonContent, + useAsyncAction, +} from "@crm/ui/components/async-action"; +import { Button } from "@crm/ui/components/button"; +import { Icon } from "@crm/ui/components/icon"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@crm/ui/components/input-group"; +import { useMutation } from "@tanstack/react-query"; +import { useDeferredValue, useState } from "react"; +import { toast } from "sonner"; +import { + ChannelPicker, + type PickerChannel, +} from "@/components/slack/channel-picker"; +import { useSlackChannels } from "@/components/slack/use-slack-channels"; +import { useTRPC } from "@/lib/trpc/client"; + +const INVITE_COMMAND = "/invite @Comp AI"; + +export function SlackChannels() { + const trpc = useTRPC(); + const [asking, setAsking] = useState(null); + const [query, setQuery] = useState(""); + const search = useDeferredValue(query); + const channels = useSlackChannels({ query: search }); + const join = useMutation( + trpc.slack.joinChannel.mutationOptions({ + onSuccess: async (result) => { + await channels.reload(); + setAsking(null); + toast.success( + result.alreadyJoined + ? "Comp AI is already in there." + : result.queued + ? "Comp AI is joining." + : "Ask someone inside to invite Comp AI.", + ); + }, + onError: (error) => toast.error(error.message), + }), + ); + const joinAction = useAsyncAction({ + action: async (channelId: string) => join.mutateAsync({ channelId }), + }); + const refresh = useMutation( + trpc.slack.refreshPeople.mutationOptions({ + onSuccess: async () => { + toast.success("Reading the channel list from Slack."); + await channels.reload(); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const refreshing = refresh.isPending || channels.syncing; + const rows = channels.channels; + const canInviteItself = channels.canInviteItself; + + return ( +
+
+
+

Channels Comp AI can reach

+

+ Agents pick from this list. +

+
+ +
+ + {channels.stalled ? ( +

+ Comp AI is not reading Slack right now. The list can be out of date. +

+ ) : null} + + {rows.length > 0 || query ? ( + + + + + setQuery(event.target.value)} + placeholder="Search channels" + value={query} + /> + + ) : null} + + + {channels.pending + ? "Reading the channel list from Slack…" + : query + ? `No channel matches “${query}”.` + : "No channels yet. Comp AI reads the list from Slack after it connects."} +

+ } + onAdd={(channel) => void joinAction.run(channel.id)} + onRequest={(channel) => setAsking(channel)} + pending={joinAction.pending} + /> + + {channels.hasMore ? ( + + ) : null} + + setAsking(null)} + onConfirm={() => asking && void joinAction.run(asking.id)} + status={joinAction.status} + /> +
+ ); +} + +function AskDialog({ + canInviteItself, + channel, + onCancel, + onConfirm, + status, +}: { + canInviteItself: boolean; + channel: PickerChannel | null; + onCancel: () => void; + onConfirm: () => void; + status: "idle" | "pending" | "success" | "error"; +}) { + if (!channel) return null; + + async function copyThenConfirm() { + try { + await navigator.clipboard.writeText(INVITE_COMMAND); + } catch { + toast.error("Copying failed. Copy the command above by hand."); + return; + } + + toast.success("Command copied."); + onConfirm(); + } + + return ( + !open && onCancel()}> + + + + {canInviteItself + ? `Add Comp AI to #${channel.name}?` + : "Ask someone to add Comp AI"} + + + {canInviteItself + ? `It is a private channel, so Comp AI joins as you. Same as typing the invite yourself. Everyone in the channel sees it join. It reads nothing until you turn a permission on.` + : `We cannot add Comp AI to a private channel yet. Someone already in #${channel.name} has to run this.`} + + + + {canInviteItself ? null : ( +
+ {INVITE_COMMAND} +
+ )} + + + + Cancel + + + +
+
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx new file mode 100644 index 000000000..919e79589 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { authClient } from "@crm/auth/client"; +import { Button } from "@crm/ui/components/button"; +import { useState } from "react"; +import { toast } from "sonner"; + +const CONNECT_ERRORS: Record = { + access_denied: "Slack installation was cancelled before access was granted.", + account_already_linked_to_different_user: + "That Slack installer is already linked to another CRM account.", + "email_doesn't_match": + "The Slack installer's email must match the CRM account you are signed in with.", + oauth_code_verification_failed: + "Slack rejected the app credentials or redirect URL. Check the client ID, client secret, and OAuth redirect URL, then try again.", + user_info_is_missing: + "Slack did not return the installer's profile. Confirm the app has users:read and users:read.email, reinstall it, then try again.", +}; + +async function startSlackOAuth(slug: string) { + try { + const { error } = await authClient.oauth2.link({ + providerId: "slack", + callbackURL: `${window.location.origin}/${slug}/settings/connections/slack/people`, + errorCallbackURL: `${window.location.origin}/${slug}/settings/connections/slack?provider=slack`, + }); + if (error) toast.error(error.message || "Could not connect Slack."); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Could not connect Slack.", + ); + } +} + +export function SlackReconnectButton({ slug }: { slug: string }) { + const [pending, setPending] = useState(false); + + return ( + + ); +} + +export function SlackConnectButton({ + slug, + configured, + connectError, +}: { + slug: string; + configured: boolean; + connectError?: string; +}) { + const [pending, setPending] = useState(false); + const connect = async () => { + setPending(true); + await startSlackOAuth(slug); + setPending(false); + }; + return ( +
+ + {connectError ? ( +

+ {CONNECT_ERRORS[connectError] ?? + `Slack could not be connected (${connectError.replaceAll("_", " ")}).`} +

+ ) : null} +
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/connections/slack/slack-disconnect-button.tsx b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-disconnect-button.tsx new file mode 100644 index 000000000..82111a86f --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-disconnect-button.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@crm/ui/components/alert-dialog"; +import { + AsyncButtonContent, + useAsyncAction, +} from "@crm/ui/components/async-action"; +import { Button } from "@crm/ui/components/button"; +import { useMutation } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; + +export function SlackDisconnectButton({ + canManage, + workspace, +}: { + canManage: boolean; + workspace: string | null; +}) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const router = useRouter(); + const [confirming, setConfirming] = useState(false); + const disconnect = useMutation( + trpc.slack.disconnect.mutationOptions({ + onSuccess: async () => { + await cache.slack(); + setConfirming(false); + toast.success("Slack disconnected."); + router.refresh(); + }, + onError: (error) => toast.error(error.message), + }), + ); + const disconnectAction = useAsyncAction({ + action: () => disconnect.mutateAsync(), + }); + + return ( + <> + + + { + if (!disconnectAction.pending) setConfirming(open); + }} + > + + + + Disconnect {workspace ?? "Slack"}? + + + Agents stop sending to Slack immediately, and the cached channel + list is cleared so a new app re-reads it. Who is matched to which + Slack account is kept, so reconnecting the same workspace does not + ask you to match everyone again. + + + + + Cancel + + + + + + + ); +} diff --git a/apps/app/app/(app)/[slug]/settings/connections/slack/slack-scope-groups.tsx b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-scope-groups.tsx new file mode 100644 index 000000000..cd848f750 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-scope-groups.tsx @@ -0,0 +1,108 @@ +"use client"; + +import Checkmark from "@carbon/icons-react/es/Checkmark"; +import Close from "@carbon/icons-react/es/Close"; +import Warning from "@carbon/icons-react/es/Warning"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@crm/ui/components/accordion"; +import { Icon } from "@crm/ui/components/icon"; + +export type ScopeLine = { + scope: string; + grant: string; + sensitive: boolean; +}; + +export type ScopeGroup = { + id: string; + label: string; + summary: string; + scopes: ScopeLine[]; +}; + +export function SlackScopeGroups({ + title, + groups, + withheld, +}: { + title: string; + groups: ScopeGroup[]; + withheld: ScopeLine[]; +}) { + return ( +
+
+

{title}

+

+ Broad means the whole workspace, not one channel. Open a group to see + the details. +

+
+ + + {groups.map((group) => { + const broad = group.scopes.filter((entry) => entry.sensitive).length; + + return ( + + + + {group.label} + + {group.summary} + + + + {broad} broad of {group.scopes.length} + + + + +
    + {group.scopes.map((entry) => ( +
  • + + {entry.grant} +
  • + ))} +
+
+
+ ); + })} + + {withheld.map((entry) => ( +
+ + + + {entry.grant} + + + Slack held this one back, so it is off. + + + + Not granted + +
+ ))} +
+
+ ); +} diff --git a/apps/app/components/agent-builder/agent-builder-chat.tsx b/apps/app/components/agent-builder/agent-builder-chat.tsx index de6e1398e..f377fe5f2 100644 --- a/apps/app/components/agent-builder/agent-builder-chat.tsx +++ b/apps/app/components/agent-builder/agent-builder-chat.tsx @@ -32,6 +32,7 @@ import { MessageScrollerViewport, } from "@crm/ui/components/message-scroller"; import { Reasoning } from "@crm/ui/components/reasoning"; +import { ThinkingIndicator } from "@crm/ui/components/thinking-indicator"; import { useMountEffect } from "@crm/ui/hooks/use-mount-effect"; import { cn } from "@crm/ui/lib/utils"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -50,7 +51,9 @@ import { hasCreateAgentCommand, } from "@/lib/agent-builder"; import { + agentBuilderCallIsActive, builderConversationIsWorking, + builderSessionStreamKey, completedBuilderSteps, displayedArtifactVersionId, latestCompletedArtifactVersionId, @@ -63,7 +66,6 @@ import { eventStreamSettled, latestTurnFailure, messagesFromEvents, - pendingQuestion, splitMarkdownTable, type TranscriptItem, toTranscript, @@ -107,12 +109,20 @@ type DraftVersion = { type BuilderSubmission = { id: string; createdAt: string; + clientRequestId?: string | null; commandType: "CHAT" | "CREATE_AGENT"; message: unknown; status: string; errorMessage: string | null; }; +type PendingSubmission = { + clientRequestId: string; + createdAt: string; + commandType: "CHAT" | "CREATE_AGENT"; + message: unknown; +}; + export function AgentBuilderChat({ conversationId, initialData, @@ -127,6 +137,7 @@ export function AgentBuilderChat({ key: string; events: readonly MessageStreamEvent[]; } | null>(null); + const [sending, setSending] = useState([]); const conversation = useQuery({ ...trpc.conversations.builderById.queryOptions({ id: conversationId }), enabled: !sharedChat, @@ -222,12 +233,20 @@ export function AgentBuilderChat({ const data = conversation.data ?? (initialData as Conversation); const submissions = data.submissions as BuilderSubmission[]; + const confirmedRequestIds = new Set( + submissions + .map((submission) => submission.clientRequestId) + .filter((id): id is string => Boolean(id)), + ); + const pendingSubmissions = sending.filter( + (item) => !confirmedRequestIds.has(item.clientRequestId), + ); const persistedEvents = (events.data ?? []) as unknown as MessageStreamEvent[]; - const streamKey = - data.sessionId && builderConversationIsWorking(data) - ? `${data.sessionId}:${submissions.at(-1)?.id ?? "initial"}` - : null; + const streamKey = builderSessionStreamKey( + data.sessionId, + submissions.at(-1)?.id ?? null, + ); const transcriptEvents = streamKey && liveStream?.key === streamKey ? liveStream.events @@ -239,7 +258,7 @@ export function AgentBuilderChat({ agentMessages, ); const answeredQuestionIds = questionResponseIds(submissions); - const waitingQuestion = pendingQuestion(agentMessages); + const waitingQuestion = data.pendingQuestion; const question = waitingQuestion && !hasQueuedQuestionResponse(submissions, waitingQuestion.requestId) @@ -248,6 +267,9 @@ export function AgentBuilderChat({ const failure = latestTurnFailure(transcriptEvents); const creatingAgent = hasCreateAgentCommand(submissions); const working = builderConversationIsWorking(data) && !failure; + const builderCallActive = agentBuilderCallIsActive(transcriptEvents); + const currentSubmissionCreatesAgent = + submissions.at(-1)?.commandType === "CREATE_AGENT"; const reviewVersion = reviewVersionId(data); const artifactVersion = displayedArtifactVersionId(data, working); const retryPrompt = retryPromptOf(submissions.at(-1)); @@ -255,11 +277,31 @@ export function AgentBuilderChat({ prompt: BuilderPrompt, clientRequestId = crypto.randomUUID(), ) => { - await submit.mutateAsync({ - id: conversationId, - clientRequestId, - ...prompt, - }); + setSending((current) => [ + ...current, + { + clientRequestId, + createdAt: new Date().toISOString(), + commandType: prompt.commandType ?? "CHAT", + message: { + text: prompt.message, + resources: prompt.resources, + attachments: prompt.attachments, + }, + }, + ]); + + try { + await submit.mutateAsync({ + id: conversationId, + clientRequestId, + ...prompt, + }); + } finally { + setSending((current) => + current.filter((item) => item.clientRequestId !== clientRequestId), + ); + } }; return ( @@ -335,7 +377,36 @@ export function AgentBuilderChat({ ))} - {working && creatingAgent ? ( + {pendingSubmissions.map((item) => ( + + + + ))} + + {pendingSubmissions.length > 0 || + (working && !builderCallActive) ? ( + + + + ) : null} + + {working && builderCallActive ? ( @@ -360,30 +431,13 @@ export function AgentBuilderChat({ ) : null} - {!working && - failure && - creatingAgent && - !reviewVersion && - data.agent?.status !== "LIVE" ? ( - - send(retryPrompt) : null} - /> - - ) : null} - - {!working && failure && !creatingAgent ? ( + {!working && failure ? ( send(retryPrompt) : null} /> @@ -411,7 +465,7 @@ export function AgentBuilderChat({ conversation={data} onFollowUp={(message) => send({ - commandType: "CHAT", + commandType: "CREATE_AGENT", message, resources: [], attachments: [], @@ -684,10 +738,12 @@ function UserSubmission({ submission, failed, error, + sending = false, }: { submission: BuilderSubmission; failed: boolean; error: string | null; + sending?: boolean; }) { const message = builderMessageOf(submission.message); const command = @@ -698,7 +754,12 @@ function UserSubmission({ const response = inputResponseOf(submission.message); return ( -
+
trigger.enabled)?.nextRunAt; + const enabledTriggers = agent.triggers.filter((trigger) => trigger.enabled); + const nextRun = + enabledTriggers.length === 1 ? enabledTriggers[0]?.nextRunAt : null; + const triggerSummary = + enabledTriggers.map((trigger) => trigger.name).join(" · ") || "Manual only"; return (
@@ -1263,13 +1328,13 @@ function DeployedAgentCard({

{agent.name} is live.

I created the Eve agent, applied its bounded CRM and integration - access, and scheduled its first run. + access, and made it live for the team.

) : ( - "Manual only" + triggerSummary ) } /> @@ -1322,24 +1387,19 @@ function DeployedAgentCard({

Suggested follow-ups

- {["Run it once now", "Add another teammate to the notification"].map( - (suggestion) => ( - - ), - )} + {["Add another teammate to the notification"].map((suggestion) => ( + + ))}
); @@ -1452,7 +1512,9 @@ function sharedConversationNeedsPolling( function manifestOf(value: unknown) { const manifest = recordOf(value); - const trigger = recordOf(manifest.trigger); + const triggers = Array.isArray(manifest.triggers) + ? manifest.triggers.map(recordOf) + : []; const dataScope = recordOf(manifest.dataScope); const actions = Array.isArray(manifest.actions) ? manifest.actions.map(recordOf) @@ -1471,9 +1533,16 @@ function manifestOf(value: unknown) { ? manifest.description.trim() : null, trigger: - trigger.type === "MANUAL" - ? "On demand" - : compactSummary(trigger.summary, "On schedule"), + triggers + .map((trigger) => + trigger.type === "MANUAL" + ? "On demand" + : compactSummary( + trigger.summary, + trigger.type === "EVENT" ? "On CRM event" : "On schedule", + ), + ) + .join(" · ") || "On demand", looksAt: textOf(dataScope.summary, "CRM records in the approved scope"), action: compactSummary( actions[0]?.summary, diff --git a/apps/app/components/agent-builder/agent-capabilities.tsx b/apps/app/components/agent-builder/agent-capabilities.tsx new file mode 100644 index 000000000..9fe8fa03b --- /dev/null +++ b/apps/app/components/agent-builder/agent-capabilities.tsx @@ -0,0 +1,419 @@ +"use client"; + +import Add from "@carbon/icons-react/es/Add"; +import Close from "@carbon/icons-react/es/Close"; +import Warning from "@carbon/icons-react/es/Warning"; +import { Alert, AlertDescription, AlertTitle } from "@crm/ui/components/alert"; +import { Button } from "@crm/ui/components/button"; +import { Icon } from "@crm/ui/components/icon"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@crm/ui/components/popover"; +import { SaveBar } from "@crm/ui/components/save-bar"; +import { Switch } from "@crm/ui/components/switch"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + ChannelPicker, + type PickerChannel, +} from "@/components/slack/channel-picker"; +import { useSlackChannels } from "@/components/slack/use-slack-channels"; +import { useTRPC } from "@/lib/trpc/client"; +import { CreateChannelDialog } from "./create-channel-dialog"; + +export type Resource = { id: string; kind: string; label: string }; + +export type Capabilities = { + readable: boolean; + problem: string | null; + channel: { kind: "channel" | "user"; id: string; label: string } | null; + actions: Array<{ type: string; provider: string; summary: string }>; + dataScope: { + mode: "SELECTED" | "WORKSPACE"; + summary: string; + resources: Resource[]; + } | null; +}; + +const ACTION_LABELS: Record = { + "slack.message.post": "Post a message", + "crm.activity.create": "Write a note or task on the record", + "run.summary": "Write a summary of the run", +}; + +export function AgentCapabilities({ + agentId, + canManage, + capabilities, +}: { + agentId: string; + canManage: boolean; + capabilities: Capabilities; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [picked, setPicked] = useState(null); + const [off, setOff] = useState([]); + const [resources, setResources] = useState(null); + + const channels = useSlackChannels({ + enabled: capabilities.channel !== null, + }); + const rows = channels.channels; + const canInviteItself = channels.canInviteItself; + + const reset = () => { + setPicked(null); + setOff([]); + setResources(null); + }; + + const revise = useMutation( + trpc.agents.revise.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.agents.byId.pathKey(), + }); + reset(); + toast.success("Saved. A new version is live."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const join = useMutation( + trpc.slack.joinChannel.mutationOptions({ + onSuccess: async () => { + await channels.reload(); + toast.success("Asked someone to invite Comp AI."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + if (!capabilities.readable) { + return ( + + + This version's manifest cannot be read + + {capabilities.problem ?? "The manifest is not in a shape we know."} + + + ); + } + + const current = capabilities.channel; + const from = current?.label.replace(/^#/, "") ?? null; + const to = picked?.name ?? null; + const shownResources = resources ?? capabilities.dataScope?.resources ?? []; + + const channelChanged = to !== null && to !== from; + const actionsChanged = off.length > 0; + const scopeChanged = resources !== null; + const dirty = channelChanged || actionsChanged || scopeChanged; + + const everyActionOff = + capabilities.actions.length > 0 && + off.length === capabilities.actions.length; + const scopeEmptied = + scopeChanged && + shownResources.length === 0 && + capabilities.dataScope?.mode !== "WORKSPACE"; + const blocked = everyActionOff + ? "Leave one action on. An agent that does nothing cannot be saved." + : scopeEmptied + ? "Add one record. An empty list opens every record in the workspace." + : null; + + const save = () => { + if (blocked) return; + + revise.mutate({ + id: agentId, + clientRequestId: crypto.randomUUID(), + ...(channelChanged && picked + ? { channel: { id: picked.id, name: picked.name } } + : {}), + ...(actionsChanged + ? { + actions: capabilities.actions + .map((action) => action.type) + .filter((type) => !off.includes(type)), + } + : {}), + ...(scopeChanged + ? { + resources: shownResources.map((resource) => ({ + id: resource.id, + kind: resource.kind as + | "company" + | "contact" + | "deal" + | "integration", + label: resource.label, + })), + } + : {}), + }); + }; + + return ( +
+ {current ? ( +
{ + await channels.reload(); + }} + > + + + ) : null + } + summary="One channel. Comp AI joins it when you save." + title="Lives in" + > + join.mutate({ channelId: channel.id })} + onSelect={(channel) => { + if (canManage) setPicked(channel); + }} + pending={revise.isPending} + value={picked?.id ?? current.id} + /> +
+ ) : null} + +
+
+ {capabilities.actions.map((action) => ( +
+
+

+ {ACTION_LABELS[action.type] ?? action.type} +

+

+ {action.summary || action.provider} +

+
+ + setOff((current) => + on + ? current.filter((type) => type !== action.type) + : [...current, action.type], + ) + } + /> +
+ ))} + {capabilities.actions.length === 0 ? ( +

+ Nothing outside the CRM. +

+ ) : null} +
+
+ +
+
+ {shownResources.length === 0 && + capabilities.dataScope?.mode === "WORKSPACE" ? ( + + Every record in the workspace + + ) : null} + + {shownResources.map((resource) => ( + + {resource.label} + {canManage ? ( + + ) : null} + + ))} + + {canManage ? ( + + setResources([ + ...shownResources.filter( + (entry) => + !( + entry.id === resource.id && entry.kind === resource.kind + ), + ), + resource, + ]) + } + /> + ) : null} +
+
+ + + + + +
+ ); +} + +function ResourcePicker({ onPick }: { onPick: (resource: Resource) => void }) { + const trpc = useTRPC(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const results = useQuery({ + ...trpc.conversations.builderResources.queryOptions({ q: query }), + enabled: open, + }); + + return ( + + + + + + + setQuery(event.target.value)} + placeholder="Search records and integrations" + value={query} + /> +
+ {(results.data ?? []).map((resource) => ( + + ))} + {(results.data ?? []).length === 0 ? ( +

+ Nothing matches. +

+ ) : null} +
+
+
+ ); +} + +function Section({ + action, + children, + summary, + title, +}: { + action?: React.ReactNode; + children: React.ReactNode; + summary: string; + title: string; +}) { + return ( +
+
+
+

{title}

+

{summary}

+
+ {action} +
+ {children} +
+ ); +} diff --git a/apps/app/components/agent-builder/agent-code.tsx b/apps/app/components/agent-builder/agent-code.tsx new file mode 100644 index 000000000..a41f1617a --- /dev/null +++ b/apps/app/components/agent-builder/agent-code.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { SaveBar } from "@crm/ui/components/save-bar"; +import { + type FileContents, + type FileOptions, + parseDiffFromFile, +} from "@pierre/diffs"; +import { Editor, type EditorOptions } from "@pierre/diffs/edit"; +import { EditProvider, File, FileDiff, Virtualizer } from "@pierre/diffs/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useTRPC } from "@/lib/trpc/client"; + +const FILE_OPTIONS: FileOptions = { + theme: { dark: "pierre-dark-soft", light: "pierre-light-soft" }, + stickyHeader: true, +}; + +const DIFF_OPTIONS = { + theme: { dark: "pierre-dark-soft", light: "pierre-light-soft" }, + diffStyle: "unified", + stickyHeader: true, +} as const; + +const VIRTUALIZER_STYLE = { + maxHeight: "32rem", + overflow: "auto", +} as const; + +function createEditor(options: EditorOptions) { + return new Editor(options); +} + +export function AgentCode({ + agentId, + canManage, +}: { + agentId: string; + canManage: boolean; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [active, setActive] = useState(null); + const [editing, setEditing] = useState(false); + const [showDiff, setShowDiff] = useState(false); + const [changed, setChanged] = useState([]); + const [saving, setSaving] = useState(false); + const draft = useRef(new Map()); + const editorRef = useRef | null>(null); + + const code = useQuery(trpc.agents.files.queryOptions({ id: agentId })); + const files = code.data?.files ?? []; + const path = active ?? files[0]?.path ?? null; + const file = files.find((entry) => entry.path === path); + + const save = useMutation( + trpc.agents.saveFile.mutationOptions({ + onError: (error) => toast.error(error.message), + }), + ); + + const saveAll = useCallback(async () => { + setSaving(true); + let failed = false; + + for (const [entry, contents] of [...draft.current]) { + const written = await save + .mutateAsync({ + id: agentId, + clientRequestId: crypto.randomUUID(), + path: entry, + content: contents, + }) + .then( + () => true, + () => false, + ); + if (!written) { + failed = true; + break; + } + if (draft.current.get(entry) === contents) draft.current.delete(entry); + } + + await queryClient.invalidateQueries({ + queryKey: trpc.agents.files.pathKey(), + }); + const remaining = [...draft.current.keys()]; + setChanged(remaining); + setSaving(false); + if (failed || remaining.length > 0) return; + + setEditing(false); + toast.success("Saved."); + }, [agentId, queryClient, save, trpc]); + + const editorOptions = useMemo>( + () => ({ + persistState: true, + onAttach(editor) { + editorRef.current = editor; + }, + onChange(next) { + draft.current.set(next.name, next.contents); + setChanged((paths) => + paths.includes(next.name) ? paths : [...paths, next.name], + ); + }, + }), + [], + ); + + const surface = useMemo(() => { + if (!file) return null; + + return { + name: file.path, + contents: draft.current.get(file.path) ?? file.content, + cacheKey: `${file.path}:${file.revision}`, + }; + }, [file]); + + const diff = useMemo(() => { + if (!file?.previousContent || !surface) return null; + + return parseDiffFromFile( + { + name: file.path, + contents: file.previousContent, + cacheKey: `${file.path}:${file.revision - 1}`, + }, + surface, + ); + }, [file, surface]); + + const discard = useCallback(() => { + draft.current.clear(); + setChanged([]); + setEditing(false); + }, []); + + if (files.length === 0) { + return ( +

+ {code.isPending + ? "Reading the agent's files…" + : "This agent has no files yet. The builder writes them when it deploys."} +

+ ); + } + + return ( + +
+
+
+

Code

+

+ What the agent actually runs. +

+
+ +
+ {file?.previousContent ? ( + + ) : null} + {canManage ? ( + + ) : null} +
+
+ +
+
+ {files.map((entry) => ( + + ))} +
+ + {surface ? ( + + {showDiff && diff ? ( + + ) : ( + + )} + + ) : null} +
+
+ + 0} + title="Unsaved code" + > + + + +
+ ); +} diff --git a/apps/app/components/agent-builder/agent-history.tsx b/apps/app/components/agent-builder/agent-history.tsx new file mode 100644 index 000000000..089032f69 --- /dev/null +++ b/apps/app/components/agent-builder/agent-history.tsx @@ -0,0 +1,458 @@ +"use client"; + +import ChevronDown from "@carbon/icons-react/es/ChevronDown"; +import ChevronUp from "@carbon/icons-react/es/ChevronUp"; +import Download from "@carbon/icons-react/es/Download"; +import Renew from "@carbon/icons-react/es/Renew"; +import WarningAlt from "@carbon/icons-react/es/WarningAlt"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@crm/ui/components/alert-dialog"; +import { Button } from "@crm/ui/components/button"; +import { Icon } from "@crm/ui/components/icon"; +import { cn } from "@crm/ui/lib/utils"; +import { useState } from "react"; +import { runFailureReason } from "@/lib/agent-run-failure"; +import type { RouterOutputs } from "@/lib/trpc/types"; + +type Runs = RouterOutputs["agents"]["history"]; +type Activity = RouterOutputs["agents"]["activity"]; +type RunRow = Omit & { + events: Array<{ + id: string; + type: string; + data: unknown; + emittedAt: string; + }>; +}; +type ActivityRow = Omit & { + before: unknown; + after: unknown; +}; + +const DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + timeZone: "UTC", + timeZoneName: "short", +}); +const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZone: "UTC", +}); + +export function AgentRuns({ + runs, + onCancel, + cancelling, + onRetry, + retryingRunId, +}: { + runs: Runs; + onCancel: (runId: string) => void; + cancelling: boolean; + onRetry: (runId: string) => void; + retryingRunId?: string; +}) { + const [outcome, setOutcome] = useState("ALL"); + const [expanded, setExpanded] = useState(null); + const [confirming, setConfirming] = useState(null); + const visible = runs.filter( + (run) => outcome === "ALL" || run.status === outcome, + ); + const runNumbers = new Map( + runs.map((run, index) => [run.id, runs.length - index]), + ); + + return ( +
+
+ +
+ + {visible.map((run) => ( +
+
+ + + {run.status === "FAILED" || run.status === "CANCELLED" ? ( + + + + ) : null} + + {run.canCancel ? ( + + + + ) : null} +
+ + {expanded === run.id ? ( + + ) : null} +
+ ))} + + setConfirming(open ? confirming : null)} + > + + + Stop this run? + + The agent stops where it is and the run is recorded as cancelled. + Anything it has already done — a note, a task, a Slack message — + stays done. + + + + + Keep running + { + if (confirming) onCancel(confirming); + setConfirming(null); + }} + > + Stop run + + + + + + {visible.length === 0 ? ( +

+ No runs match this outcome. +

+ ) : null} +
+ ); +} + +function ExpandedRun({ run }: { run: RunRow }) { + const timeline = [ + ...run.events.map((event) => ({ + kind: "event" as const, + at: event.emittedAt, + event, + })), + ...run.actions.map((action) => ({ + kind: "action" as const, + at: action.completedAt ?? action.startedAt ?? action.plannedAt, + action, + })), + ].sort((first, second) => Date.parse(first.at) - Date.parse(second.at)); + + return ( +
+
+ + + + +
+ +
+ {timeline.map((entry) => + entry.kind === "event" ? ( +
+ + {formatTime(entry.at)} + + + {eventLabel(entry.event.type, entry.event.data)} + + + event + +
+ ) : ( +
+ + {formatTime(entry.at)} + + + + {entry.action.summary} + + + {entry.action.provider} · {humanStatus(entry.action.status)} + {entry.action.targetLabel + ? ` · ${entry.action.targetLabel}` + : ""} + + + + {entry.action.externalId ?? entry.action.id.slice(0, 12)} + +
+ ), + )} + {run.eventsTruncated ? ( +
+ Showing the first {run.events.length} of {run.totalEvents} steps. + This run is too long to display in full. +
+ ) : null} +
+
+ ); +} + +function RunMeta({ + label, + value, + last = false, +}: { + label: string; + value: string; + last?: boolean; +}) { + return ( + + {label} + {value} + + ); +} + +export function AgentActivity({ activity }: { activity: Activity }) { + const [kind, setKind] = useState("ALL"); + const rows = activity as unknown as ActivityRow[]; + const visible = rows.filter( + (event) => kind === "ALL" || event.type.startsWith(kind), + ); + + return ( +
+
+ + +
+ +
+
+ Time + Change + Actor + Request +
+ {visible.map((event) => ( +
+ + {formatDate(event.emittedAt)} + + + + {event.summary} + + {changeDetail(event.before, event.after) ? ( + + {changeDetail(event.before, event.after)} + + ) : null} + + + Actor · + {event.actorUser?.name ?? event.actorId ?? event.actorType} + + + Request · + {event.requestId?.slice(0, 12) ?? "—"} + +
+ ))} + {visible.length === 0 ? ( +

+ No changes match this filter. +

+ ) : null} +
+
+ ); +} + +function recordOf(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function textOf(value: unknown, fallback: string): string { + return typeof value === "string" && value.trim() ? value : fallback; +} + +function humanStatus(value: string): string { + return value + .toLowerCase() + .replace(/_/g, " ") + .replace(/^./, (character) => character.toUpperCase()); +} + +function formatDate(value: string): string { + return DATE_FORMATTER.format(new Date(value)); +} + +function formatTime(value: string): string { + return TIME_FORMATTER.format(new Date(value)); +} + +function duration(startedAt: string | null, finishedAt: string | null): string { + if (!startedAt) return "—"; + if (!finishedAt) return "In progress"; + + const milliseconds = + new Date(finishedAt).getTime() - new Date(startedAt).getTime(); + return `${Math.max(0, milliseconds / 1000).toFixed(1)}s`; +} + +function eventLabel(type: string, data: unknown): string { + const payload = recordOf(data); + return textOf(payload.summary, humanStatus(type.replace(/\./g, " "))); +} + +function changeDetail(before: unknown, after: unknown): string | null { + if (!before && !after) return null; + const previous = JSON.stringify(before); + const next = JSON.stringify(after); + return previous && next ? `${previous} → ${next}` : next || previous; +} + +function exportJson(name: string, value: unknown) { + const url = URL.createObjectURL( + new Blob([JSON.stringify(value, null, 2)], { type: "application/json" }), + ); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = name; + anchor.click(); + URL.revokeObjectURL(url); +} diff --git a/apps/app/components/agent-builder/agent-runs-drawer.tsx b/apps/app/components/agent-builder/agent-runs-drawer.tsx new file mode 100644 index 000000000..a22faa882 --- /dev/null +++ b/apps/app/components/agent-builder/agent-runs-drawer.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@crm/ui/components/sheet"; +import { useState } from "react"; +import type { RouterOutputs } from "@/lib/trpc/types"; +import { AgentActivity, AgentRuns } from "./agent-history"; + +type Runs = RouterOutputs["agents"]["history"]; +type Activity = RouterOutputs["agents"]["activity"]; + +const VIEWS = [ + { id: "runs", label: "Runs" }, + { id: "activity", label: "Activity" }, +] as const; + +type View = (typeof VIEWS)[number]["id"]; + +export function AgentRunsDrawer({ + activity, + cancelling, + onCancel, + onOpenChange, + onRetry, + open, + retryingRunId, + runs, +}: { + activity: Activity; + agentId: string; + cancelling: boolean; + onCancel: (runId: string) => void; + onOpenChange: (open: boolean) => void; + onRetry: (runId: string) => void; + open: boolean; + retryingRunId?: string; + runs: Runs; +}) { + const [view, setView] = useState("runs"); + const [wasOpen, setWasOpen] = useState(open); + + if (wasOpen !== open) { + setWasOpen(open); + if (open) setView("runs"); + } + + return ( + + + + History + + Every run and every change, newest first. + + + +
+ {VIEWS.map((entry) => ( + + ))} +
+ +
+ {view === "runs" ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/app/components/agent-builder/create-channel-dialog.tsx b/apps/app/components/agent-builder/create-channel-dialog.tsx new file mode 100644 index 000000000..2e9d5556f --- /dev/null +++ b/apps/app/components/agent-builder/create-channel-dialog.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@crm/ui/components/dialog"; +import { Input } from "@crm/ui/components/input"; +import { Label } from "@crm/ui/components/label"; +import { Switch } from "@crm/ui/components/switch"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useTRPC } from "@/lib/trpc/client"; + +export function CreateChannelDialog({ + children, + onCreated, +}: { + children: React.ReactNode; + onCreated: () => Promise | void; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [isPrivate, setIsPrivate] = useState(false); + + const create = useMutation( + trpc.slack.createChannel.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.slack.channels.queryKey(), + }); + await onCreated(); + setOpen(false); + setName(""); + toast.success("Creating the channel in Slack."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const slug = name.trim().toLowerCase().replace(/\s+/g, "-"); + const valid = /^[a-z0-9-_]+$/.test(slug); + + return ( + + {children} + + + + Create a channel + + Comp AI makes it in Slack and joins it. You can put the agent in it + straight after. + + + +
+
+ + setName(event.target.value)} + placeholder="renewals" + value={name} + /> +

+ {slug && !valid + ? "Use lowercase letters, numbers and dashes." + : `Slack will call it #${slug || "renewals"}.`} +

+
+ +
+ + +
+
+ + + + + +
+
+ ); +} diff --git a/apps/app/components/agent-builder/new-agent-dialog.tsx b/apps/app/components/agent-builder/new-agent-dialog.tsx new file mode 100644 index 000000000..22c6bc598 --- /dev/null +++ b/apps/app/components/agent-builder/new-agent-dialog.tsx @@ -0,0 +1,214 @@ +"use client"; + +import Checkmark from "@carbon/icons-react/es/Checkmark"; +import { Button } from "@crm/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@crm/ui/components/dialog"; +import { Icon } from "@crm/ui/components/icon"; +import { Input } from "@crm/ui/components/input"; +import { Label } from "@crm/ui/components/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@crm/ui/components/select"; +import { Textarea } from "@crm/ui/components/textarea"; +import { InvalidInput, type Permission, parse, schemas } from "@crm/validation"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useSlackChannels } from "@/components/slack/use-slack-channels"; +import { handoffBrief, handoffResources } from "@/lib/agent-handoff"; +import { useTRPC } from "@/lib/trpc/client"; +import { useWorkspaceUrl } from "@/lib/use-workspace-url"; + +export function NewAgentDialog({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const workspaceUrl = useWorkspaceUrl(); + + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [job, setJob] = useState(""); + const [channelId, setChannelId] = useState(""); + const [allowed, setAllowed] = useState( + schemas.agents.defaultPermissions, + ); + + const channels = useSlackChannels({ enabled: open }); + const rows = channels.channels; + const channel = rows.find((row) => row.id === channelId); + + const create = useMutation( + trpc.conversations.createBuilder.mutationOptions({ + onSuccess: async ({ id }) => { + await queryClient.invalidateQueries({ + queryKey: trpc.conversations.builderList.pathKey(), + }); + setOpen(false); + router.push(workspaceUrl(`/chat/${id}`)); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const ready = name.trim().length > 0 && job.trim().length > 0; + + const hand = () => { + try { + const handoff = parse( + schemas.agents.handoff, + { + name, + job, + channel: channel + ? { + id: channel.id, + name: channel.name, + isMember: channel.isMember, + } + : null, + allowed, + }, + "This agent", + ); + + create.mutate({ + clientRequestId: crypto.randomUUID(), + commandType: "CREATE_AGENT", + message: handoffBrief(handoff), + resources: handoffResources(handoff), + attachments: [], + }); + } catch (error) { + toast.error( + error instanceof InvalidInput + ? error.message + : "Could not hand this to the builder.", + ); + } + }; + + return ( + + {children} + + + + New agent + + Say what it is and where it lives. The builder writes the rest. You + can change all of this later. + + + +
+
+ + setName(event.target.value)} + placeholder="Renewal prep brief" + value={name} + /> +
+ +
+ +