From ae6f95ed7a777069717b90230d651e7ed1060d35 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:53:46 -0500 Subject: [PATCH] feat: add personal primary agents (#15470) Add a personal primary agent per company and user. Initialize it from the first human-created agent, expose profile-only switching with confirmation, and use it after recent choices for task and Chat defaults. Persist authenticated preferences, preserve lifecycle and membership rules, keep selections out of shared audit events, and document the API contract. Include the reviewed Storybook surfaces and regression coverage for concurrent choices, onboarding, cross-device updates, and browser journeys. Co-Authored-By: Paperclip --- doc/PRODUCT.md | 9 + doc/SPEC-implementation.md | 26 + doc/SPEC.md | 7 + .../2026-10-07-personal-primary-agent.md | 180 ++ .../src/migrations/0315_rapid_emma_frost.sql | 44 + ...{0310_snapshot.json => 0315_snapshot.json} | 2304 ++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/index.ts | 2 + .../db/src/schema/user_company_preferences.ts | 16 + packages/shared/src/index.ts | 2 + packages/shared/src/primary-agent.ts | 14 + .../agent-permissions-routes.test.ts | 13 +- .../src/__tests__/agent-skills-routes.test.ts | 5 +- .../src/__tests__/approvals-service.test.ts | 9 +- .../src/__tests__/company-portability.test.ts | 22 +- server/src/__tests__/openapi-routes.test.ts | 19 + server/src/__tests__/primary-agent.test.ts | 206 ++ server/src/app.ts | 2 + server/src/routes/agents.ts | 2 + server/src/routes/openapi.ts | 32 + server/src/routes/primary-agent.ts | 51 + server/src/services/agents.ts | 8 + server/src/services/approvals.ts | 2 +- server/src/services/company-portability.ts | 2 +- server/src/services/onboarding-seed.ts | 5 +- server/src/services/primary-agent.ts | 82 + server/src/services/resource-memberships.ts | 9 + tests/e2e/agent-chat-sessions.spec.ts | 9 +- ui/src/api/primaryAgent.ts | 7 + ui/src/components/EntityRow.tsx | 3 + ui/src/components/NewIssueDialog.test.tsx | 22 + ui/src/components/NewIssueDialog.tsx | 9 +- ui/src/components/Sidebar.tsx | 2 +- .../components/SidebarAgents.production.tsx | 13 +- ui/src/components/SidebarAgents.tsx | 13 +- .../PrimaryAgentPresentation.tsx | 94 + ui/src/context/LiveUpdatesProvider.test.ts | 12 + ui/src/context/LiveUpdatesProvider.tsx | 6 +- ui/src/context/PrimaryAgentProvider.test.tsx | 129 + ui/src/context/PrimaryAgentProvider.tsx | 54 + ui/src/hooks/usePrimaryAgent.ts | 21 + ui/src/hooks/useResourceMemberships.ts | 1 + ui/src/lib/queryKeys.ts | 3 + ui/src/main.tsx | 3 + ui/src/pages/AgentChats.test.tsx | 29 + ui/src/pages/AgentChats.tsx | 13 +- ui/src/pages/AgentDetail.tsx | 4 +- ui/src/pages/Agents.production.tsx | 2 + ui/src/pages/Agents.tsx | 2 + .../primary-agent/00-review-guide.stories.tsx | 54 + .../primary-agent/01-components.stories.tsx | 20 + .../primary-agent/02-sidebar.stories.tsx | 21 + .../primary-agent/03-agents.stories.tsx | 19 + .../primary-agent/04-new-task.stories.tsx | 14 + .../stories/primary-agent/05-chat.stories.tsx | 13 + .../primary-agent/06-journeys.stories.tsx | 108 + .../primary-agent/07-confirmation.stories.tsx | 20 + .../primary-agent/PrimaryAgentStory.tsx | 269 ++ 58 files changed, 4017 insertions(+), 52 deletions(-) create mode 100644 doc/plans/2026-10-07-personal-primary-agent.md create mode 100644 packages/db/src/migrations/0315_rapid_emma_frost.sql rename packages/db/src/migrations/meta/{0310_snapshot.json => 0315_snapshot.json} (95%) create mode 100644 packages/db/src/schema/user_company_preferences.ts create mode 100644 packages/shared/src/primary-agent.ts create mode 100644 server/src/__tests__/primary-agent.test.ts create mode 100644 server/src/routes/primary-agent.ts create mode 100644 server/src/services/primary-agent.ts create mode 100644 ui/src/api/primaryAgent.ts create mode 100644 ui/src/components/primary-agent/PrimaryAgentPresentation.tsx create mode 100644 ui/src/context/PrimaryAgentProvider.test.tsx create mode 100644 ui/src/context/PrimaryAgentProvider.tsx create mode 100644 ui/src/hooks/usePrimaryAgent.ts create mode 100644 ui/storybook/stories/primary-agent/00-review-guide.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/01-components.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/02-sidebar.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/03-agents.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/04-new-task.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/05-chat.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/06-journeys.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/07-confirmation.stories.tsx create mode 100644 ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index b47d36a50b4..e53dcafe773 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -30,6 +30,15 @@ Example: A CEO agent's adapter config tells it to "review what your executives a Then you define who reports to the CEO: a CTO managing programmers, a CMO managing the marketing team, and so on. Every agent in the tree gets their own adapter configuration. +Each person also has a personal primary agent per company. Their first human-created +agent becomes primary automatically; they can choose another from its profile. +An existing primary is replaced only after confirmation. The crown appears on the +profile and roster, while the primary stays first in the Agents sidebar without a +sidebar crown. Stars remain independent. Task drafts, explicit assignments, and +recent choices take precedence over the primary; Chat similarly reopens a valid +recent conversation before falling back to the primary. Opening Chat does not +start execution. This preference does not change the org chart or permissions. + ### Agent Execution Paperclip supports several ways to run an agent's heartbeat: diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 9fb9c80d7f1..f50252a776d 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -543,6 +543,32 @@ Decision-desk triage uses company-scoped sidecars rather than adding queue field - `decision_archive_notification_outbox` records one retry-safe origin-agent notification per source/archive version. The 90-day internal sweeper archives only unkept rows and coalesces delivery per origin agent. - Queue membership never grants source visibility. Item writes re-authorize the referenced source, and queue reads re-authorize every member before returning rows or counts. +## 7.17 Personal primary agent (2026-10-07) + +`user_company_preferences` has a unique company/user key, nullable `primary_agent_id`, +and sticky `primary_agent_initialized` flag. GET/PUT +`/api/companies/:companyId/primary-agent/me` derives the human identity from auth, +checks company access and agent visibility, rejects agent actors, and audits changes. +PUT accepts an approved, non-terminated agent; choosing a previously left agent +rejoins it under the existing membership rules. There is no explicit removal UI or +nullable PUT. Leaving, termination, and deletion clear the reference without +allowing a later creation to initialize it again. Pausing and errors retain it. + +Initialization runs in the human-attributed creation transaction, including +onboarding, with uniqueness arbitrating concurrent creation. System provisioning +and agent-authored hires cannot initialize a human preference. Migration backfill +uses the earliest attributable human creation, preserving empty initialized state +when the original is gone, terminated, or left; unknown ownership stays unset. + +The profile is the only setting surface. Replacing a primary requires the reviewed +avatar-to-avatar confirmation. A first choice has no modal. Only the profile and +roster show the accessible crown; the Agents sidebar pins the primary first without +duplication. Tasks preserve explicit/draft assignments, then choose a recent +eligible assignee, the eligible primary, or the existing fallback. Chat preserves +a valid recent conversation, then opens the primary, then retains its chooser. +Navigation alone does not create an execution. Failed preference mutations restore +the previous state and offer retry. Preferences and caches are company/user scoped. + ## 8. State Machines ## 8.1 Agent Status diff --git a/doc/SPEC.md b/doc/SPEC.md index a346d7abcdb..10dd0c809de 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -91,6 +91,13 @@ The Board sets Company-level budgets. The CEO can set budgets for Agents below t ## 2. Agent Model [DRAFT] +A human's personal primary agent is a company-scoped navigation and assignment +preference, independent of the org chart, roles, stars, and authority. First human +creation initializes it automatically. Later explicit choices persist across +devices; recent task/chat choices take precedence. The profile owns the setting +and replacement confirmation. See the personal-primary addendum in +`SPEC-implementation.md` for persistence and lifecycle rules. + Every employee is an agent. Agents are the workforce. ### Cryptographic identity diff --git a/doc/plans/2026-10-07-personal-primary-agent.md b/doc/plans/2026-10-07-personal-primary-agent.md new file mode 100644 index 00000000000..9ce1100de55 --- /dev/null +++ b/doc/plans/2026-10-07-personal-primary-agent.md @@ -0,0 +1,180 @@ +# Personal primary agent + +## Current boundary + +The user approved the revised Storybook design on 2026-10-07 and requested full +implementation plus acceptance testing in a real isolated test drive. This branch +now includes persistence, API wiring, and production defaults. + +- Branch: `codex/personal-primary-agent-storybook`. +- Preview: http://localhost:6017/?path=/story/primary-agent-00-review-guide--start-here +- Source: `ui/storybook/stories/primary-agent/` (one feature folder). +- Shared presentation: `ui/src/components/primary-agent/PrimaryAgentPresentation.tsx`. +- Stories keep simulated state as the approved reference. The application now + mounts a separate provider backed by a user/company preference endpoint. +- Delivery includes real test-drive acceptance on desktop/mobile and required + repository checks. After reviewing the working feature, the user requested a + pull request with passing checks and merge into master. + +## Agreed product contract + +Each person has one primary agent per company, marked with an accessible +"My primary agent" crown on the profile and agent list. Neither the Agents +sidebar nor the Chat sidebar shows a crown. Different people can choose different agents; the +preference will persist across devices. Crowning does not change roles, +reporting lines, permissions, tasks, routines, or shared Board Operations. + +- The first human-attributed creation silently initializes the primary, including + onboarding. Later creations, system provisioning, and agent-authored hires do + not replace or establish a person's preference. +- Tasks preserve explicit assignments and restored drafts, then prefer a recent + eligible assignee, then the primary, then the existing fallback. +- Chat reopens a valid recent conversation, otherwise the primary's conversation, + otherwise its existing chooser. Opening Chat does not start execution. +- The individual agent page offers Set as my primary beneath the agent details. + Kebab menus have no primary action, and there is no explicit removal option. + Standalone component examples do not offer this action. The current primary + shows its crown with no redundant set action. When a primary already exists, + switching requires a modal with the two agents' avatars and an arrow above. + The incoming primary has an outlined avatar and a crown beside its name: + "This will make [new name] your primary agent, do you want to do this?" + Cancel leaves the current primary unchanged. When none exists, + choosing one applies immediately without the modal. Stars remain independent. + Failures roll back and offer an actionable retry. These review decisions + supersede the original menu, removal, and immediate-switching design. +- The primary stays first in the Agents sidebar, even when idle; other ordering + is preserved and the primary appears only once in that section. +- Pausing and errors retain the crown and existing execution restrictions. + Leaving or terminating clears it without resetting initialization. Crowning a + previously left agent rejoins it under existing membership rules. + +## Review coverage and implementation notes + +Stories use the actual streamlined and classic Layout components, roster/profile +pages, composer, Chat pages/navigation, existing menus, and shared crown components. +Fixtures include idle/active, first-created result, absent/cleared/unavailable, +paused/error, independent stars, long names, loading, failed mutations, desktop, +mobile, and light theme. Interactive stories cover setting from the profile, +absence of primary menu actions, leaving, starring, rollback/retry, and opening +a task or Chat after switching, cancellation, and first choice without confirmation. + +The streamlined Sidebar now includes the reviewed Agents section by default. +Stories can still supply it through Layout's existing sidebar slot for review. +The icon rail is retired in SidebarContext; collapsed coverage uses the supported +collapsible Agents section plus the mobile drawer. No rail is reintroduced. + +Reloading resets the fixture preference. Task fallback is simulated through the +existing composer's defaults/recent-assignee input. Chat entry routing and the +first-created result are also simulations. These stories are design references, +not evidence of authorization, persistence, concurrency, or real execution. + +## Verification at review handoff + +- UI TypeScript build (`tsc -b ui`). +- 61 existing tests across SidebarAgents, AgentActionButtons, EntityRow, Agents, + and AgentOverview. +- Token gates and palette/character synchronization checks. +- Full static Storybook build. +- Browser walkthroughs: switch crown and sidebar order, independent stars, + rollback and Retry, leave/rejoin, direct profile action, task assignment/draft + precedence, recent Chat history, Chat fallback/chooser, and mobile drawer, + composer, and profile at 390 by 844. + +## Implementation delivered + +1. Added a company/user preference with nullable primaryAgentId and initialization + state; enforce uniqueness. Synchronize db/shared/server/ui contracts. +2. Added authenticated GET/PUT `/api/companies/:companyId/primary-agent/me`, deriving + the user from authentication, enforcing company access and agent visibility, + rejecting agent actors, and recording mutation activity. +3. Initialized transactionally during human-attributed creation. Concurrent + creations establish one primary and cannot overwrite an explicit choice or + lifecycle-cleared preference. Backfill from the earliest attributable human creation; + if that original agent is gone or terminated, leave the crown empty. Unknown + ownership remains unset. +4. Added lifecycle cleanup and existing membership rejoin behavior. Connected a + user/company-scoped query hook to the reviewed presentation and defaults. +5. Verified concurrency, backfill, lifecycle cleanup, user/company isolation + (including local trusted mode), drafts, recent selections, unavailable agents, + and real desktop/mobile task and Chat journeys. + +## Real test-drive acceptance (2026-10-07) + +- Isolated CLI test drive: `/tmp/paperclip-primary-agent-test-drive`, company + **Primary Agent Test Drive**, prefix `PRI`, at http://localhost:3104. +- Maia was crowned automatically through the real creation API. Creating Alex + preserved Maia. The simplified creation wizard's model-connection step rejected + the disposable placeholder credential; Alex was created through the real API + with the existing local Codex login instead. No creation-wizard success is claimed. +- Desktop: profile-only setting, cancel/confirm, persistence after reload, roster + crown, sidebar ordering, absent sidebar crowns and menu actions, leave/clear, + first choice without a modal, rejoin, and paused crown retention passed. +- Fresh browser-origin task composer selected Alex from the primary. After switching + to Maia, the composer preserved recent Alex and Chat reopened Alex's recent + conversation. An existing draft on the other origin remained intact. +- Opening Chat used the primary with no new execution. Sending a message produced + **“Alex received this primary-agent chat.”** from a successful real Codex run. +- Real task **PRI-3, Acknowledge Alex’s task receipt**, completed with + **“Task received by Alex”** and status `done`. The initial probe used an unsupported + `gpt-5.4` subscription model; both agents were changed to `gpt-6.1-sol`, the successful + checks were repeated, and the obsolete probe task was cancelled. +- Mobile at 390 × 844: the reviewed modal fits, confirmation switches the crown, + and the page has no horizontal overflow. Viewport override reset afterward. +- Focused tests cover initialization, simultaneous creation/selection, audit order, + leave races, backfill, visibility boundaries, viewer self-service, auth-derived + user/company isolation, optimistic rollback/retry, session retry, task defaults, + and recent Chat precedence. + +## Verification results + +- Repository typecheck passed with `pnpm -r --workspace-concurrency=1 typecheck`. + Final server/UI typechecks also passed after the last feature refinements. +- `pnpm build`, `pnpm check:token-gates`, and `git diff --check` passed. +- Entire UI suite: **698 files, 7,760 tests passed**. +- Entire DB suite, run serially: **47 files, 166 tests passed**. Parallel attempts + failed to initialize embedded PostgreSQL before migrations; the serial run + resolves that migration-verification concern. +- Focused primary/default/provider checks passed: **106 tests** across final + runs (9 database/API tests, plus provider, composer, and Chat tests). +- Existing onboarding, hire idempotency, resource membership, and company import + suites passed: **125 tests**. Legacy approval plus terminalization checks: + **15 tests passed**. Import/approval expectations now assert original creator + attribution rather than dropping the new creation option. +- **Repository-wide tests are not green.** `pnpm test:run` was stopped after + roughly an hour with failures, rather than being represented as a complete pass. + `adapter-cost-failure-paths.test.ts` still has OpenCode timeout failures in a + fresh rerun. `workspace-git-snapshot-streaming.test.ts` timed out after 300 seconds. + Heartbeat database-setup timeouts passed isolated reruns. The broad run began + before the final edits and also reported primary/approval failures; those suites + passed in fresh processes against final source. Later broad-run groups did not + complete. These local failures remain disclosed; PR checks must pass on the + current head before merge. + +The final-code test drive remains running at http://localhost:3104 with **Alex** +primary and **Maia's profile** open. Choose **Set as my primary**, confirm, and +reload to try persistence. New Task and Chat retain recent Alex choices by design; +the primary is their fallback when no eligible recent choice or draft exists. + +## Pull request verification + +- PR: https://github.com/paperclipai/paperclip/pull/15470. The user authorized + passing PR checks and merging into master. +- The rebased branch passes repository typecheck and build, token gates, and + 114 focused tests. Migration replay now runs twice without replacing choices. +- The earlier OpenCode timeouts pass all 31 tests with an isolated XDG config + directory; the local config copy caused the delay. The large Git streaming test + also passes when run alone (296 seconds). +- Initial CI identified stale creation-option assertions and app remounts on + company changes. Preserve mounted app children and bind each pending mutation + to its original user/company. Refresh membership caches for primary changes + received from other devices. +- Security review identified agent mappings in company activity. Preference + events now record only the affected user and action, with no selected agent IDs. + An authenticated cross-user activity endpoint test covers this boundary. +- Review fixes pass 96 database/permission-route tests and 127 provider, + live-update, and skill-route tests. Final merge requires fresh-head CI and review. +- The affected browser suites pass all 10 tests, including onboarding, archived + company navigation, Chat fallback without execution, and slow-CPU reloads. +- The second PR run passed 52 checks and Greptile 5/5, with one OpenAPI inventory + failure. Both personal endpoints now have board-only request/response contracts + in OpenAPI. Its 14 tests and the remaining 28 tests in that shard pass locally. diff --git a/packages/db/src/migrations/0315_rapid_emma_frost.sql b/packages/db/src/migrations/0315_rapid_emma_frost.sql new file mode 100644 index 00000000000..95330211675 --- /dev/null +++ b/packages/db/src/migrations/0315_rapid_emma_frost.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS "user_company_preferences" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "user_id" text NOT NULL, + "primary_agent_id" uuid, + "primary_agent_initialized" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_company_preferences" ADD CONSTRAINT "user_company_preferences_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_company_preferences" ADD CONSTRAINT "user_company_preferences_primary_agent_id_agents_id_fk" FOREIGN KEY ("primary_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "user_company_preferences_company_user_uq" ON "user_company_preferences" USING btree ("company_id","user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_company_preferences_primary_agent_idx" ON "user_company_preferences" USING btree ("primary_agent_id");--> statement-breakpoint +-- Attribute only explicit human creation events. Select the original event +-- before joining the agent: deletion/termination must not promote a later hire. +WITH first_creations AS ( + SELECT DISTINCT ON (company_id, actor_id) + company_id, actor_id AS user_id, entity_id AS agent_id + FROM activity_log + WHERE actor_type = 'user' + AND actor_id NOT IN ('', 'board', 'system', 'unknown', 'unknown-user') + AND entity_type = 'agent' + AND action IN ('agent.created', 'agent.hire_created') + ORDER BY company_id, actor_id, created_at, id +) +INSERT INTO user_company_preferences (company_id, user_id, primary_agent_id, primary_agent_initialized) +SELECT c.company_id, c.user_id, + CASE WHEN a.status != 'terminated' AND m.state IS DISTINCT FROM 'left' + THEN a.id ELSE NULL END, + true +FROM first_creations c +LEFT JOIN agents a ON a.id::text = c.agent_id AND a.company_id = c.company_id +LEFT JOIN agent_memberships m ON m.company_id = c.company_id AND m.user_id = c.user_id AND m.agent_id = a.id +WHERE a.metadata->'paperclipBuiltInAgent' IS NULL +ON CONFLICT (company_id, user_id) DO NOTHING; diff --git a/packages/db/src/migrations/meta/0310_snapshot.json b/packages/db/src/migrations/meta/0315_snapshot.json similarity index 95% rename from packages/db/src/migrations/meta/0310_snapshot.json rename to packages/db/src/migrations/meta/0315_snapshot.json index 8adbf57d9bb..69131296242 100644 --- a/packages/db/src/migrations/meta/0310_snapshot.json +++ b/packages/db/src/migrations/meta/0315_snapshot.json @@ -1,9 +1,1079 @@ { - "id": "8a60ce2b-6ab6-435d-90b9-7dc7fc67d53f", - "prevId": "593431cc-d6a8-4daf-a3e4-9e3c892d9419", + "id": "26691ae0-39a8-42ac-868d-e36ab7219073", + "prevId": "a1aefefe-8911-4583-9c52-7c658e46e9b4", "version": "7", "dialect": "postgresql", "tables": { + "public.accounting_runtime_baselines": { + "name": "accounting_runtime_baselines", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cost_cents": { + "name": "cost_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": true + }, + "cached_input_tokens": { + "name": "cached_input_tokens", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounting_runtime_baselines_company_idx": { + "name": "accounting_runtime_baselines_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounting_runtime_baselines_agent_id_agents_id_fk": { + "name": "accounting_runtime_baselines_agent_id_agents_id_fk", + "tableFrom": "accounting_runtime_baselines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "accounting_runtime_baselines_company_id_companies_id_fk": { + "name": "accounting_runtime_baselines_company_id_companies_id_fk", + "tableFrom": "accounting_runtime_baselines", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_invoice_lines": { + "name": "billing_invoice_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricing": { + "name": "pricing", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "billing_invoice_lines_identity_idx": { + "name": "billing_invoice_lines_identity_idx", + "columns": [ + { + "expression": "invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_invoice_lines_company_idx": { + "name": "billing_invoice_lines_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_invoice_lines_company_id_companies_id_fk": { + "name": "billing_invoice_lines_company_id_companies_id_fk", + "tableFrom": "billing_invoice_lines", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "billing_invoice_lines_invoice_id_billing_invoices_id_fk": { + "name": "billing_invoice_lines_invoice_id_billing_invoices_id_fk", + "tableFrom": "billing_invoice_lines", + "tableTo": "billing_invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "billing_invoice_lines_cost_event_id_cost_events_id_fk": { + "name": "billing_invoice_lines_cost_event_id_cost_events_id_fk", + "tableFrom": "billing_invoice_lines", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_invoice_lines_amount_check": { + "name": "billing_invoice_lines_amount_check", + "value": "\"billing_invoice_lines\".\"amount_cents\" >= 0" + }, + "billing_invoice_lines_kind_check": { + "name": "billing_invoice_lines_kind_check", + "value": "\"billing_invoice_lines\".\"kind\" in ('inference', 'fee', 'credit')" + } + }, + "isRLSEnabled": false + }, + "public.billing_invoices": { + "name": "billing_invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imported_by": { + "name": "imported_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_invoices_identity_idx": { + "name": "billing_invoices_identity_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_invoices_company_id_companies_id_fk": { + "name": "billing_invoices_company_id_companies_id_fk", + "tableFrom": "billing_invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_reservations": { + "name": "budget_reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'held'" + }, + "provider_started_at": { + "name": "provider_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_reservations_run_idx": { + "name": "budget_reservations_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_reservations_active_idx": { + "name": "budget_reservations_active_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"budget_reservations\".\"state\" = 'held'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_reservations_company_id_companies_id_fk": { + "name": "budget_reservations_company_id_companies_id_fk", + "tableFrom": "budget_reservations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "budget_reservations_run_id_heartbeat_runs_id_fk": { + "name": "budget_reservations_run_id_heartbeat_runs_id_fk", + "tableFrom": "budget_reservations", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "budget_reservations_agent_id_agents_id_fk": { + "name": "budget_reservations_agent_id_agents_id_fk", + "tableFrom": "budget_reservations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "budget_reservations_state_check": { + "name": "budget_reservations_state_check", + "value": "\"budget_reservations\".\"state\" in ('held', 'settled', 'released')" + }, + "budget_reservations_amount_check": { + "name": "budget_reservations_amount_check", + "value": "\"budget_reservations\".\"amount_cents\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.cost_adjustments": { + "name": "cost_adjustments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invoice_line_id": { + "name": "invoice_line_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "previous_cents": { + "name": "previous_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "corrected_cents": { + "name": "corrected_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "previous_status": { + "name": "previous_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "previous_pricing": { + "name": "previous_pricing", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pricing": { + "name": "pricing", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_adjustments_receipt_idx": { + "name": "cost_adjustments_receipt_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_adjustments_event_idx": { + "name": "cost_adjustments_event_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_adjustments_invoice_line_idx": { + "name": "cost_adjustments_invoice_line_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_line_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_adjustments_company_id_companies_id_fk": { + "name": "cost_adjustments_company_id_companies_id_fk", + "tableFrom": "cost_adjustments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cost_adjustments_cost_event_id_cost_events_id_fk": { + "name": "cost_adjustments_cost_event_id_cost_events_id_fk", + "tableFrom": "cost_adjustments", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cost_adjustments_invoice_line_id_billing_invoice_lines_id_fk": { + "name": "cost_adjustments_invoice_line_id_billing_invoice_lines_id_fk", + "tableFrom": "cost_adjustments", + "tableTo": "billing_invoice_lines", + "columnsFrom": [ + "invoice_line_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cost_adjustments_amount_check": { + "name": "cost_adjustments_amount_check", + "value": "\"cost_adjustments\".\"previous_cents\" >= 0 and \"cost_adjustments\".\"corrected_cents\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.provider_billing_snapshots": { + "name": "provider_billing_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_billing_snapshots_identity_idx": { + "name": "provider_billing_snapshots_identity_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_billing_snapshots_company_id_companies_id_fk": { + "name": "provider_billing_snapshots_company_id_companies_id_fk", + "tableFrom": "provider_billing_snapshots", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "provider_billing_snapshots_amount_check": { + "name": "provider_billing_snapshots_amount_check", + "value": "\"provider_billing_snapshots\".\"amount_cents\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.run_usage_receipts": { + "name": "run_usage_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_json": { + "name": "receipt_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "complete": { + "name": "complete", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "run_usage_receipts_source_sequence_idx": { + "name": "run_usage_receipts_source_sequence_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "run_usage_receipts_run_latest_idx": { + "name": "run_usage_receipts_run_latest_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_usage_receipts_company_id_companies_id_fk": { + "name": "run_usage_receipts_company_id_companies_id_fk", + "tableFrom": "run_usage_receipts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "run_usage_receipts_run_id_heartbeat_runs_id_fk": { + "name": "run_usage_receipts_run_id_heartbeat_runs_id_fk", + "tableFrom": "run_usage_receipts", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "run_usage_receipts_positive_sequence": { + "name": "run_usage_receipts_positive_sequence", + "value": "\"run_usage_receipts\".\"sequence\" > 0" + } + }, + "isRLSEnabled": false + }, "public.activity_log": { "name": "activity_log", "schema": "", @@ -1820,7 +2890,7 @@ }, "total_cost_cents": { "name": "total_cost_cents", - "type": "bigint", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true, "default": 0 @@ -2859,9 +3929,15 @@ "notNull": true, "default": 0 }, + "spend_month_utc": { + "name": "spend_month_utc", + "type": "text", + "primaryKey": false, + "notNull": false + }, "spent_monthly_cents": { "name": "spent_monthly_cents", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true, "default": 0 @@ -5262,7 +6338,7 @@ }, "amount_observed": { "name": "amount_observed", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true }, @@ -5378,7 +6454,7 @@ } ], "isUnique": true, - "where": "\"budget_incidents\".\"status\" <> 'dismissed'", + "where": "\"budget_incidents\".\"status\" = 'open'", "concurrently": false, "method": "btree", "with": {} @@ -5480,6 +6556,13 @@ "notNull": true, "default": 0 }, + "reservation_cents": { + "name": "reservation_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, "warn_percent": { "name": "warn_percent", "type": "integer", @@ -5494,6 +6577,13 @@ "notNull": true, "default": true }, + "unpriced_usage_policy": { + "name": "unpriced_usage_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'block'" + }, "notify_enabled": { "name": "notify_enabled", "type": "boolean", @@ -5508,6 +6598,20 @@ "notNull": true, "default": true }, + "enforcement_version": { + "name": "enforcement_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "enforcement_delivered_version": { + "name": "enforcement_delivered_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, "created_by_user_id": { "name": "created_by_user_id", "type": "text", @@ -5654,7 +6758,12 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "budget_policies_reservation_nonnegative": { + "name": "budget_policies_reservation_nonnegative", + "value": "\"budget_policies\".\"reservation_cents\" >= 0" + } + }, "isRLSEnabled": false }, "public.built_in_managed_resources": { @@ -11306,9 +12415,15 @@ "notNull": true, "default": 0 }, + "spend_month_utc": { + "name": "spend_month_utc", + "type": "text", + "primaryKey": false, + "notNull": false + }, "spent_monthly_cents": { "name": "spent_monthly_cents", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true, "default": 0 @@ -16201,6 +17316,36 @@ "primaryKey": false, "notNull": false }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reported_cost_cents": { + "name": "reported_cost_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": false + }, + "pricing_provenance": { + "name": "pricing_provenance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "provider": { "name": "provider", "type": "text", @@ -16257,7 +17402,7 @@ }, "cost_cents": { "name": "cost_cents", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true }, @@ -16276,6 +17421,109 @@ } }, "indexes": { + "cost_events_company_receipt_idx": { + "name": "cost_events_company_receipt_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_provider_request_idx": { + "name": "cost_events_provider_request_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_project_occurred_idx": { + "name": "cost_events_company_project_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_unpriced_idx": { + "name": "cost_events_unpriced_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cost_events\".\"cost_status\" = 'unpriced' and \"cost_events\".\"billing_type\" <> 'subscription_included'", + "concurrently": false, + "method": "btree", + "with": {} + }, "cost_events_company_occurred_idx": { "name": "cost_events_company_occurred_idx", "columns": [ @@ -16483,7 +17731,12 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "cost_events_nonnegative_amounts": { + "name": "cost_events_nonnegative_amounts", + "value": "\"cost_events\".\"cost_cents\" >= 0 and \"cost_events\".\"input_tokens\" >= 0 and \"cost_events\".\"cached_input_tokens\" >= 0 and \"cost_events\".\"output_tokens\" >= 0" + } + }, "isRLSEnabled": false }, "public.decision_archive_notification_outbox": { @@ -23721,6 +24974,18 @@ "primaryKey": false, "notNull": false }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, "billing_code": { "name": "billing_code", "type": "text", @@ -23796,7 +25061,7 @@ }, "amount_cents": { "name": "amount_cents", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true }, @@ -23841,6 +25106,27 @@ } }, "indexes": { + "finance_events_company_receipt_idx": { + "name": "finance_events_company_receipt_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, "finance_events_company_occurred_idx": { "name": "finance_events_company_occurred_idx", "columns": [ @@ -24987,6 +26273,19 @@ "primaryKey": false, "notNull": true }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company'" + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "invocation_source": { "name": "invocation_source", "type": "text", @@ -25067,6 +26366,44 @@ "primaryKey": false, "notNull": false }, + "cost_accounting_pending": { + "name": "cost_accounting_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_accounted_at": { + "name": "cost_accounted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accounting_projection_version": { + "name": "accounting_projection_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accounting_last_attempt_at": { + "name": "accounting_last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accounting_last_error": { + "name": "accounting_last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accounting_attempt_count": { + "name": "accounting_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, "usage_json": { "name": "usage_json", "type": "jsonb", @@ -25403,6 +26740,28 @@ } }, "indexes": { + "heartbeat_runs_cost_accounting_pending_idx": { + "name": "heartbeat_runs_cost_accounting_pending_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"heartbeat_runs\".\"cost_accounting_pending\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, "heartbeat_runs_execution_status_delivery_idx": { "name": "heartbeat_runs_execution_status_delivery_idx", "columns": [ @@ -25484,6 +26843,33 @@ "method": "btree", "with": {} }, + "heartbeat_runs_company_issue_created_idx": { + "name": "heartbeat_runs_company_issue_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "heartbeat_runs_company_responsible_user_idx": { "name": "heartbeat_runs_company_responsible_user_idx", "columns": [ @@ -25722,6 +27108,19 @@ "onDelete": "no action", "onUpdate": "no action" }, + "heartbeat_runs_issue_id_issues_id_fk": { + "name": "heartbeat_runs_issue_id_issues_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": { "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk", "tableFrom": "heartbeat_runs", @@ -25772,7 +27171,12 @@ } }, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "heartbeat_runs_scope_binding_check": { + "name": "heartbeat_runs_scope_binding_check", + "value": "(\"heartbeat_runs\".\"scope_kind\" = 'company' AND \"heartbeat_runs\".\"issue_id\" IS NULL)\n OR \"heartbeat_runs\".\"scope_kind\" = 'issue'" + } + }, "isRLSEnabled": false }, "public.inbox_dismissals": { @@ -27215,6 +28619,166 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.issue_access_grants": { + "name": "issue_access_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_by_agent_id": { + "name": "granted_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "issue_access_grants_subject_active_issue_idx": { + "name": "issue_access_grants_subject_active_issue_idx", + "columns": [ + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_access_grants_issue_idx": { + "name": "issue_access_grants_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_access_grants_issue_id_issues_id_fk": { + "name": "issue_access_grants_issue_id_issues_id_fk", + "tableFrom": "issue_access_grants", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_access_grants_granted_by_agent_id_agents_id_fk": { + "name": "issue_access_grants_granted_by_agent_id_agents_id_fk", + "tableFrom": "issue_access_grants", + "tableTo": "agents", + "columnsFrom": [ + "granted_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "issue_access_grants_subject_type_check": { + "name": "issue_access_grants_subject_type_check", + "value": "\"issue_access_grants\".\"subject_type\" in ('user', 'agent')" + }, + "issue_access_grants_source_check": { + "name": "issue_access_grants_source_check", + "value": "\"issue_access_grants\".\"source\" in ('explicit', 'assignment', 'project')" + } + }, + "isRLSEnabled": false + }, "public.issue_approvals": { "name": "issue_approvals", "schema": "", @@ -32087,6 +33651,25 @@ "primaryKey": false, "notNull": false }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "privacy_root_issue_id": { + "name": "privacy_root_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "privacy_parent_issue_id": { + "name": "privacy_parent_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "title": { "name": "title", "type": "text", @@ -32571,6 +34154,48 @@ "method": "btree", "with": {} }, + "issues_company_privacy_parent_idx": { + "name": "issues_company_privacy_parent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "privacy_parent_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_privacy_root_idx": { + "name": "issues_company_privacy_root_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "privacy_root_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "issues_company_project_idx": { "name": "issues_company_project_idx", "columns": [ @@ -33140,6 +34765,32 @@ "onDelete": "no action", "onUpdate": "no action" }, + "issues_privacy_root_issue_id_issues_id_fk": { + "name": "issues_privacy_root_issue_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "privacy_root_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_privacy_parent_issue_id_issues_id_fk": { + "name": "issues_privacy_parent_issue_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "privacy_parent_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, "issues_assignee_agent_id_agents_id_fk": { "name": "issues_assignee_agent_id_agents_id_fk", "tableFrom": "issues", @@ -33219,6 +34870,10 @@ }, "policies": {}, "checkConstraints": { + "issues_visibility_check": { + "name": "issues_visibility_check", + "value": "\"issues\".\"visibility\" in ('open', 'private')" + }, "issues_conversation_identity_check": { "name": "issues_conversation_identity_check", "value": "(\n \"issues\".\"conversation_agent_id\" is null and \"issues\".\"conversation_user_id\" is null and \"issues\".\"conversation_state\" is null\n ) or (\n \"issues\".\"conversation_agent_id\" is not null and \"issues\".\"conversation_user_id\" is not null\n and \"issues\".\"assignee_agent_id\" = \"issues\".\"conversation_agent_id\" and \"issues\".\"assignee_agent_id\" is not null\n and \"issues\".\"assignee_user_id\" is null and \"issues\".\"conversation_state\" is not null\n and \"issues\".\"conversation_state\" in ('active', 'waiting')\n and \"issues\".\"status\" not in ('done', 'cancelled')\n )" @@ -38508,6 +40163,150 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.project_access_members": { + "name": "project_access_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_access_members_project_subject_uq": { + "name": "project_access_members_project_subject_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_access_members_subject_lookup_idx": { + "name": "project_access_members_subject_lookup_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_access_members_company_id_companies_id_fk": { + "name": "project_access_members_company_id_companies_id_fk", + "tableFrom": "project_access_members", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_access_members_project_id_projects_id_fk": { + "name": "project_access_members_project_id_projects_id_fk", + "tableFrom": "project_access_members", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_access_members_subject_type_check": { + "name": "project_access_members_subject_type_check", + "value": "\"project_access_members\".\"subject_type\" in ('user', 'agent')" + } + }, + "isRLSEnabled": false + }, "public.project_goals": { "name": "project_goals", "schema": "", @@ -39137,6 +40936,25 @@ "primaryKey": false, "notNull": false }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "privacy_owner_user_id": { + "name": "privacy_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "personal_owner_user_id": { + "name": "personal_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, "status": { "name": "status", "type": "text", @@ -39228,6 +41046,28 @@ "concurrently": false, "method": "btree", "with": {} + }, + "projects_company_personal_owner_uq": { + "name": "projects_company_personal_owner_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "personal_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"projects\".\"personal_owner_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -39274,7 +41114,12 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "checkConstraints": { + "projects_visibility_check": { + "name": "projects_visibility_check", + "value": "\"projects\".\"visibility\" in ('open', 'private')" + } + }, "isRLSEnabled": false }, "public.provider_trace_records": { @@ -42390,7 +44235,7 @@ }, "cost_cents": { "name": "cost_cents", - "type": "integer", + "type": "numeric(24, 7)", "primaryKey": false, "notNull": true, "default": 0 @@ -49809,6 +51654,129 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.user_company_preferences": { + "name": "user_company_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_agent_id": { + "name": "primary_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "primary_agent_initialized": { + "name": "primary_agent_initialized", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_company_preferences_company_user_uq": { + "name": "user_company_preferences_company_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_company_preferences_primary_agent_idx": { + "name": "user_company_preferences_primary_agent_idx", + "columns": [ + { + "expression": "primary_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_company_preferences_company_id_companies_id_fk": { + "name": "user_company_preferences_company_id_companies_id_fk", + "tableFrom": "user_company_preferences", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_company_preferences_primary_agent_id_agents_id_fk": { + "name": "user_company_preferences_primary_agent_id_agents_id_fk", + "tableFrom": "user_company_preferences", + "tableTo": "agents", + "columnsFrom": [ + "primary_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.user_inbox_agent_policies": { "name": "user_inbox_agent_policies", "schema": "", @@ -51493,6 +53461,187 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mcp_attachment_uploads": { + "name": "mcp_attachment_uploads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "arguments_hash": { + "name": "arguments_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cleaned_at": { + "name": "cleaned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_attachment_upload_request_uq": { + "name": "mcp_attachment_upload_request_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_attachment_upload_expiry_idx": { + "name": "mcp_attachment_upload_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_attachment_uploads_company_id_companies_id_fk": { + "name": "mcp_attachment_uploads_company_id_companies_id_fk", + "tableFrom": "mcp_attachment_uploads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_attachment_uploads_user_id_user_id_fk": { + "name": "mcp_attachment_uploads_user_id_user_id_fk", + "tableFrom": "mcp_attachment_uploads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.mcp_event_admissions": { "name": "mcp_event_admissions", "schema": "", @@ -51889,6 +54038,133 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mcp_file_tickets": { + "name": "mcp_file_tickets", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "upload_id": { + "name": "upload_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "attachment_id": { + "name": "attachment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_file_ticket_expiry_idx": { + "name": "mcp_file_ticket_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_file_ticket_grant_idx": { + "name": "mcp_file_ticket_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_file_tickets_company_id_companies_id_fk": { + "name": "mcp_file_tickets_company_id_companies_id_fk", + "tableFrom": "mcp_file_tickets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_file_tickets_grant_id_mcp_oauth_grants_id_fk": { + "name": "mcp_file_tickets_grant_id_mcp_oauth_grants_id_fk", + "tableFrom": "mcp_file_tickets", + "tableTo": "mcp_oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_file_tickets_upload_id_mcp_attachment_uploads_id_fk": { + "name": "mcp_file_tickets_upload_id_mcp_attachment_uploads_id_fk", + "tableFrom": "mcp_file_tickets", + "tableTo": "mcp_attachment_uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.mcp_mutation_receipts": { "name": "mcp_mutation_receipts", "schema": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 4319e115911..945a790a400 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2192,6 +2192,13 @@ "when": 1791371819805, "tag": "0314_private_task_draft_assets", "breakpoints": true + }, + { + "idx": 315, + "version": "7", + "when": 1791390905429, + "tag": "0315_rapid_emma_frost", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 466ea7321f1..1750dec1efd 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -233,3 +233,5 @@ export { agentCommentary } from "./agent_commentary.js"; export { agentIdentityKeys } from "./agent_identity_keys.js"; + +export { userCompanyPreferences } from "./user_company_preferences.js"; diff --git a/packages/db/src/schema/user_company_preferences.ts b/packages/db/src/schema/user_company_preferences.ts new file mode 100644 index 00000000000..40c6a679fdc --- /dev/null +++ b/packages/db/src/schema/user_company_preferences.ts @@ -0,0 +1,16 @@ +import { boolean, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; + +export const userCompanyPreferences = pgTable("user_company_preferences", { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + userId: text("user_id").notNull(), + primaryAgentId: uuid("primary_agent_id").references(() => agents.id, { onDelete: "set null" }), + primaryAgentInitialized: boolean("primary_agent_initialized").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, table => ({ + companyUserUq: uniqueIndex("user_company_preferences_company_user_uq").on(table.companyId, table.userId), + primaryAgentIdx: index("user_company_preferences_primary_agent_idx").on(table.primaryAgentId), +})); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 232f447caa0..b1e36a5aae3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2850,3 +2850,5 @@ export { isAppAggregator, aggregatorManagementUrl, aggregatorAppsSyncSchema, agg export * from "./connection-instructions.js"; export * from "./customer-success.js"; + +export { updatePrimaryAgentSchema, type UpdatePrimaryAgent, type PrimaryAgentPreference } from "./primary-agent.js"; diff --git a/packages/shared/src/primary-agent.ts b/packages/shared/src/primary-agent.ts new file mode 100644 index 00000000000..63d7841ed62 --- /dev/null +++ b/packages/shared/src/primary-agent.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export interface PrimaryAgentPreference { + companyId: string; + userId: string; + primaryAgentId: string | null; + initialized: boolean; +} + +export const updatePrimaryAgentSchema = z.object({ + primaryAgentId: z.string().uuid(), +}).strict(); + +export type UpdatePrimaryAgent = z.infer; diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 8428ab4e6f5..72570a9e9dc 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1251,7 +1251,7 @@ describe("agent permission routes", () => { expect.objectContaining({ status: "idle", }), - { claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } }, + { createdByUserId: "agent-admin-user", claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } }, ); expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith( companyId, @@ -1381,7 +1381,7 @@ describe("agent permission routes", () => { }, }, }), - { claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -1417,7 +1417,7 @@ describe("agent permission routes", () => { model: DEFAULT_OPENCODE_LOCAL_MODEL, }), }), - { claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -1455,7 +1455,7 @@ describe("agent permission routes", () => { model: "anthropic/claude-sonnet-4-5", }), }), - { claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -1495,6 +1495,7 @@ describe("agent permission routes", () => { }, }), { + createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", @@ -1725,7 +1726,7 @@ describe("agent permission routes", () => { expect.objectContaining({ defaultEnvironmentId: environmentId, }), - { claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); @@ -1811,7 +1812,7 @@ describe("agent permission routes", () => { adapterType: adapterCase.adapterType, defaultEnvironmentId: environmentId, }), - { claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, + { createdByUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); }); } diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 23722b3abf5..ae267e9fd43 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -1005,7 +1005,7 @@ describe("agent skill routes", () => { }), }), }), - { claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, + { createdByUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, ); expect(mockTrackAgentCreated).toHaveBeenCalledWith( expect.anything(), @@ -1055,7 +1055,7 @@ describe("agent skill routes", () => { expect.objectContaining({ role: "security", }), - { claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, + { createdByUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } }, ); expect(mockTrackAgentCreated).toHaveBeenCalledWith( expect.anything(), @@ -1491,6 +1491,7 @@ describe("agent skill routes", () => { }), }), { + createdByUserId: "local-board", claudeLogin: { storedSessionId: null, ownerUserId: "local-board", diff --git a/server/src/__tests__/approvals-service.test.ts b/server/src/__tests__/approvals-service.test.ts index d7c6193bbbd..47aedf91695 100644 --- a/server/src/__tests__/approvals-service.test.ts +++ b/server/src/__tests__/approvals-service.test.ts @@ -30,6 +30,7 @@ type ApprovalRecord = { status: string; payload: Record; requestedByAgentId: string | null; + requestedByUserId?: string | null; }; function createApproval(status: string): ApprovalRecord { @@ -128,9 +129,14 @@ describe("approvalService resolution idempotency", () => { expect(mockNotifyHireApproved).not.toHaveBeenCalled(); }); - it("creates the agent from payload when approval does not reference a pending agent", async () => { + it.each([ + { requestedByAgentId: "requester-1", requestedByUserId: "on-behalf-user", expectedCreator: null }, + { requestedByAgentId: null, requestedByUserId: "original-creator", expectedCreator: "original-creator" }, + ])("creates a legacy approved hire with its original human attribution ($expectedCreator)", async ({ requestedByAgentId, requestedByUserId, expectedCreator }) => { const approved = { ...createApproval("approved"), + requestedByAgentId, + requestedByUserId, payload: { name: "New Agent", adapterConfig: { @@ -155,6 +161,7 @@ describe("approvalService resolution idempotency", () => { expect.objectContaining({ adapterConfig: approved.payload.adapterConfig, }), + { createdByUserId: expectedCreator }, ); }); }); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 70141793f4f..bc6bd49760c 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -1897,7 +1897,7 @@ describe("company portability", () => { }, }, }), - })); + }), { createdByUserId: "user-1" }); expect(secretSvc.syncEnvBindingsForTarget).toHaveBeenCalledWith( "company-1", { targetType: "agent", targetId: "agent-imported" }, @@ -2889,7 +2889,7 @@ describe("company portability", () => { // a scoped resume; "system" stays for platform-managed pauses. pauseReason: "import", pausedAt: expect.any(Date), - })); + }), { createdByUserId: "user-1" }); expect(routineSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ title: "Monday Review", status: "paused", @@ -3204,7 +3204,7 @@ describe("company portability", () => { expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ name: "ClaudeCoder", adapterType: "process", - })); + }), { createdByUserId: "user-1" }); }); it("preserves agent role from frontmatter when extension block omits it", async () => { @@ -3383,7 +3383,7 @@ describe("company portability", () => { desiredSkills: [paperclipKey], }, }), - })); + }), { createdByUserId: "user-1" }); }); it("imports a packaged company logo and attaches it to the target company", async () => { @@ -3677,7 +3677,7 @@ describe("company portability", () => { maxConcurrentRuns: 20, }, }, - })); + }), { createdByUserId: "user-1" }); expect(result.company.action).toBe("unchanged"); expect(result.agents).toEqual([ { @@ -3748,13 +3748,13 @@ describe("company portability", () => { adapterConfig: expect.objectContaining({ dangerouslyBypassApprovalsAndSandbox: true, }), - })); + }), { createdByUserId: "user-1" }); expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ adapterConfig: expect.not.objectContaining({ instructionsFilePath: expect.anything(), promptTemplate: expect.anything(), }), - })); + }), { createdByUserId: "user-1" }); expect(agentInstructionsSvc.materializeManagedBundle).toHaveBeenCalledWith( expect.objectContaining({ name: "ClaudeCoder" }), expect.objectContaining({ @@ -3856,7 +3856,7 @@ describe("company portability", () => { extraArgs: ["--skip-git-repo-check"], args: ["--legacy-arg"], }), - })); + }), { createdByUserId: "user-1" }); const lastCreateInput = agentSvc.create.mock.calls.at(-1)?.[1] as Record; expect(lastCreateInput?.adapterConfig).toBeTruthy(); expect(lastCreateInput.adapterConfig?.dangerouslyBypassApprovalsAndSandbox).toBeUndefined(); @@ -5785,7 +5785,7 @@ describe("company portability", () => { normalized: true, }), status: "idle", - })); + }), { createdByUserId: "user-1" }); expect(companySvc.create).toHaveBeenCalledWith(expect.objectContaining({ requireBoardApprovalForNewAgents: false, })); @@ -6080,13 +6080,13 @@ describe("company portability", () => { expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: "paperclip_runner", adapterConfig: expect.objectContaining({ provider: "opencode" }), - })); + }), { createdByUserId: "user-1" }); await portability.importBundle(request, "user-1"); expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: "paperclip_runner", adapterConfig: expect.objectContaining({ provider: "codex" }), - })); + }), { createdByUserId: "user-1" }); await portability.importBundle({ ...request, diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index e20f4895274..0910d2448f0 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -63,6 +63,7 @@ const apiPrefixes: Record = { "plugin-ui-static.ts": "/api", "plugins.ts": "/api", "projects.ts": "/api", + "primary-agent.ts": "/api", "public-mcp.ts": "/api", "project-tools.ts": "/api", "resource-memberships.ts": "/api", @@ -809,6 +810,24 @@ describe("openapi routes", () => { }); }); + it("documents the authenticated personal primary-agent contract", () => { + const { spec } = loadSpecRoutes(); + const path = spec.paths["/api/companies/{companyId}/primary-agent/me"]; + for (const operation of [path.get, path.put]) { + expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" }); + expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]); + expect(operation.responses["200"].content["application/json"].schema.required).toEqual([ + "companyId", "userId", "primaryAgentId", "initialized", + ]); + } + expect(path.put.requestBody.content["application/json"].schema).toMatchObject({ + additionalProperties: false, + required: ["primaryAgentId"], + properties: { primaryAgentId: { type: "string", format: "uuid" } }, + }); + expect(path.put.responses["422"]).toBeDefined(); + }); + it("documents board-only repository discovery and selection", () => { const { spec } = loadSpecRoutes(); const discovery = spec.paths["/api/companies/{companyId}/project-repositories"].get; diff --git a/server/src/__tests__/primary-agent.test.ts b/server/src/__tests__/primary-agent.test.ts new file mode 100644 index 00000000000..1f4f6f6a514 --- /dev/null +++ b/server/src/__tests__/primary-agent.test.ts @@ -0,0 +1,206 @@ +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import express from "express"; +import request from "supertest"; +import { and, eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { activityLog, agents, companies, companyMemberships, createDb, userCompanyPreferences } from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { primaryAgentRoutes } from "../routes/primary-agent.js"; +import { agentService } from "../services/agents.js"; +import { primaryAgentService } from "../services/primary-agent.js"; +import { resourceMembershipService } from "../services/resource-memberships.js"; +import { activityRoutes } from "../routes/activity.js"; + +const support = await getEmbeddedPostgresTestSupport(); +if (!support.supported) console.warn(`Primary agent database tests unavailable: ${support.reason}`); + +(support.supported ? describe : describe.skip)("personal primary agent", () => { + let db: ReturnType; + let temp: Awaited>; + beforeAll(async () => { + temp = await startEmbeddedPostgresTestDatabase("paperclip-primary-agent-"); + db = createDb(temp.connectionString); + }, 30_000); + afterAll(async () => { await temp?.cleanup(); }); + + const actor = (companyId: string, userId = "user-a"): Express.Request["actor"] => ({ + type: "board", source: "session", userId, companyIds: [companyId], + memberships: [{ companyId, membershipRole: "admin", status: "active" }], + }); + const app = (identity: Express.Request["actor"]) => { + const result = express(); result.use(express.json()); + result.use((req, _res, next) => { req.actor = identity; next(); }); + result.use("/api", primaryAgentRoutes(db)); + result.use("/api", activityRoutes(db)); result.use(errorHandler); + return result; + }; + async function company() { + const id = randomUUID(); + await db.insert(companies).values({ id, name: "Primary test", issuePrefix: `P${id.slice(0, 7)}` }); + await db.insert(companyMemberships).values(["user-a", "user-b", "explicit"].map(principalId => ({ companyId: id, principalType: "user", principalId, status: "active", membershipRole: "admin" }))); + return id; + } + const create = (companyId: string, name: string, userId?: string) => agentService(db).create(companyId, { + name, adapterType: "process", status: "idle", + }, { createdByUserId: userId }); + const get = (companyId: string, userId = "user-a") => primaryAgentService(db).get(companyId, userId); + const url = (companyId: string) => `/api/companies/${companyId}/primary-agent/me`; + + it("automatically crowns the first human creation, ignoring provisioned and agent-authored creations", async () => { + const c = await company(); + await create(c, "System"); await create(c, "Agent hire"); + expect(await get(c)).toMatchObject({ primaryAgentId: null, initialized: false }); + const first = await create(c, "Maia", "user-a"); + await create(c, "Alex", "user-a"); + expect(await get(c)).toMatchObject({ primaryAgentId: first.id, initialized: true }); + }); + + it("arbitrates simultaneous human creations and cannot overwrite an explicit choice", async () => { + const c = await company(); + const created = await Promise.all([create(c, "Maia", "user-a"), create(c, "Alex", "user-a")]); + const preference = await get(c); + expect(created.map(a => a.id)).toContain(preference.primaryAgentId); + expect(await db.select().from(userCompanyPreferences).where(and(eq(userCompanyPreferences.companyId, c), eq(userCompanyPreferences.userId, "user-a")))).toHaveLength(1); + const selected = created.find(a => a.id !== preference.primaryAgentId)!; + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: selected.id }).expect(200); + await create(c, "Later", "user-a"); + expect((await get(c)).primaryAgentId).toBe(selected.id); + }); + + it("isolates company/user preferences, including trusted local board, and rejects untrusted identity fields", async () => { + const c = await company(), other = await company(); + const a = await create(c, "Maia", "user-a"), b = await create(c, "Alex", "user-b"); + const elsewhere = await create(other, "Elsewhere", "user-a"); + expect((await request(app(actor(c))).get(url(c)).expect(200)).body.primaryAgentId).toBe(a.id); + expect((await request(app(actor(c, "user-b"))).get(url(c)).expect(200)).body.primaryAgentId).toBe(b.id); + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: elsewhere.id }).expect(404); + await request(app(actor(c))).get(url(other)).expect(403); + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: b.id, userId: "user-b" }).expect(400); + await request(app({ type: "agent", source: "agent_key", agentId: a.id, companyId: c })).put(url(c)).send({ primaryAgentId: b.id }).expect(403); + const local: Express.Request["actor"] = { type: "board", source: "local_implicit", userId: "local-board", isInstanceAdmin: true }; + await request(app(local)).put(url(c)).send({ primaryAgentId: b.id }).expect(200); + expect((await get(c, "local-board")).primaryAgentId).toBe(b.id); + expect((await get(other, "local-board")).primaryAgentId).toBeNull(); + expect((await get(c)).primaryAgentId).toBe(a.id); + }); + + it("serializes competing explicit choices and leave races", async () => { + const c = await company(); + await create(c, "Maia", "user-a"); + const a = await create(c, "Alex"), b = await create(c, "River"); + const service = primaryAgentService(db); + await Promise.all([service.set(c, "user-a", a.id, actor(c)), service.set(c, "user-a", b.id, actor(c))]); + const current = (await get(c)).primaryAgentId; + const updates = await db.select().from(activityLog).where(and(eq(activityLog.companyId, c), eq(activityLog.action, "primary_agent.updated"))); + expect(updates).toHaveLength(2); + expect([a.id, b.id]).toContain(current); + await Promise.all([ + service.set(c, "user-a", a.id, actor(c)), + resourceMembershipService(db).updateAgent({ companyId: c, userId: "user-a", agentId: a.id, state: "left", actor: actor(c) }), + ]); + const memberships = await resourceMembershipService(db).listForUser(c, "user-a", actor(c)); + expect((await get(c)).primaryAgentId).toBe(memberships.agentMemberships[a.id] === "left" ? null : a.id); + }); + + it("audits preference changes without exposing the selection through company activity", async () => { + const c = await company(); + const first = await create(c, "Maia", "user-a"), next = await create(c, "Alex"); + await primaryAgentService(db).set(c, "user-a", next.id, actor(c)); + await resourceMembershipService(db).updateAgent({ companyId: c, userId: "user-a", agentId: next.id, state: "left", actor: actor(c) }); + const response = await request(app(actor(c, "user-b"))).get(`/api/companies/${c}/activity`).expect(200); + const events = response.body.filter((event: { action: string }) => event.action.startsWith("primary_agent.")); + expect(events.map((event: { action: string }) => event.action).sort()).toEqual([ + "primary_agent.cleared", "primary_agent.initialized", "primary_agent.updated", + ]); + for (const event of events) { + expect(event).toMatchObject({ entityType: "user_preference", entityId: "user-a", agentId: null, details: null }); + expect(JSON.stringify(event)).not.toContain(first.id); + expect(JSON.stringify(event)).not.toContain(next.id); + } + }); + + it("retains paused/error primaries, clears on leave, rejoins when chosen, and preserves stars", async () => { + const c = await company(); const a = await create(c, "Maia", "user-a"); + await agentService(db).pause(a.id); + expect((await get(c)).primaryAgentId).toBe(a.id); + await agentService(db).update(a.id, { status: "error" }); + expect((await get(c)).primaryAgentId).toBe(a.id); + const memberships = resourceMembershipService(db); + await memberships.updateAgent({ companyId: c, userId: "user-a", agentId: a.id, state: "left", actor: actor(c) }); + expect(await get(c)).toMatchObject({ primaryAgentId: null, initialized: true }); + await create(c, "Later", "user-a"); + expect((await get(c)).primaryAgentId).toBeNull(); + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: a.id }).expect(200); + expect((await memberships.listForUser(c, "user-a", actor(c))).agentMemberships[a.id]).toBe("joined"); + await memberships.updateAgent({ companyId: c, userId: "user-a", agentId: a.id, starred: true, actor: actor(c) }); + const b = await create(c, "Alex"); + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: b.id }).expect(200); + expect((await memberships.listForUser(c, "user-a", actor(c))).starredAgentIds).toContain(a.id); + }); + + it("allows viewers to choose their own primary without granting company write permissions", async () => { + const c = await company(); const a = await create(c, "Maia"); + await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(and(eq(companyMemberships.companyId, c), eq(companyMemberships.principalId, "user-a"))); + const viewer = { ...actor(c), memberships: [{ companyId: c, membershipRole: "viewer" as const, status: "active" as const }] }; + await request(app(viewer)).put(url(c)).send({ primaryAgentId: a.id }).expect(200); + expect((await get(c)).primaryAgentId).toBe(a.id); + const [membership] = await db.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, c), eq(companyMemberships.principalId, "user-a"))); + expect(membership.membershipRole).toBe("viewer"); + await request(app({ ...viewer, memberships: [] })).get(url(c)).expect(403); + }); + + it("clears terminated/deleted primaries and rejects terminated targets and explicit removal", async () => { + const c = await company(); const a = await create(c, "Maia", "user-a"); + await agentService(db).terminate(a.id); + expect(await get(c)).toMatchObject({ primaryAgentId: null, initialized: true }); + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: a.id }).expect(422); + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: null }).expect(400); + const b = await create(c, "Alex", "user-a"); + expect((await get(c)).primaryAgentId).toBeNull(); + await request(app(actor(c))).put(url(c)).send({ primaryAgentId: b.id }).expect(200); + await agentService(db).remove(b.id); + expect(await get(c)).toMatchObject({ primaryAgentId: null, initialized: true }); + }); + + it("rolls creation and initialization back together", async () => { + const c = await company(); + await expect(db.transaction(async tx => { + await agentService(tx as unknown as typeof db).create(c, { name: "Rolled back", adapterType: "process" }, { createdByUserId: "user-a" }); + throw new Error("rollback"); + })).rejects.toThrow("rollback"); + expect(await get(c)).toMatchObject({ primaryAgentId: null, initialized: false }); + expect(await db.select().from(agents).where(eq(agents.companyId, c))).toHaveLength(0); + }); + + it("backfills the earliest human event, leaves gone/terminated originals empty, and preserves explicit preferences", async () => { + const c = await company(); const first = await create(c, "First"), later = await create(c, "Later"); + const terminated = await create(c, "Terminated"); + await agentService(db).terminate(terminated.id); + const events = [ + ["human", first.id, "user", 1], ["human", later.id, "user", 2], + ["gone", randomUUID(), "user", 1], ["gone", later.id, "user", 2], + ["retired", terminated.id, "user", 1], ["retired", later.id, "user", 2], + ["system", later.id, "system", 0], [first.id, later.id, "agent", 0], + ["board", later.id, "user", 0], ["explicit", first.id, "user", 0], + ] as const; + for (const [actorId, entityId, actorType, day] of events) await db.insert(activityLog).values({ + companyId: c, actorId, actorType, entityId, entityType: "agent", action: "agent.created", createdAt: new Date(2020, 0, day + 1), + }); + await request(app(actor(c, "explicit"))).put(url(c)).send({ primaryAgentId: later.id }).expect(200); + const migration = await readFile(new URL("../../../packages/db/src/migrations/0315_rapid_emma_frost.sql", import.meta.url), "utf8"); + // Replay the whole migration twice: an already migrated preview instance + // must retain its explicit preferences when upgrading to the merged build. + for (let replay = 0; replay < 2; replay++) { + for (const statement of migration.split("--> statement-breakpoint")) { + if (statement.trim()) await db.execute(sql.raw(statement)); + } + } + expect((await get(c, "human")).primaryAgentId).toBe(first.id); + expect(await get(c, "gone")).toMatchObject({ primaryAgentId: null, initialized: true }); + expect(await get(c, "retired")).toMatchObject({ primaryAgentId: null, initialized: true }); + expect((await get(c, "explicit")).primaryAgentId).toBe(later.id); + for (const user of ["system", "board", first.id]) expect((await get(c, user)).initialized).toBe(false); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index b9054137ce9..ec71459ef2b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -107,6 +107,7 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; import { announcementRoutes } from "./routes/announcements.js"; import { serverVersion } from "./version.js"; +import { primaryAgentRoutes } from "./routes/primary-agent.js"; import { resourceMembershipRoutes } from "./routes/resource-memberships.js"; import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; @@ -824,6 +825,7 @@ export async function createApp( api.use(sidebarPreferenceRoutes(db)); api.use(announcementRoutes(db, { ...opts.announcements, version: opts.hostVersion ?? serverVersion })); api.use(resourceMembershipRoutes(db)); + api.use(primaryAgentRoutes(db)); api.use(inboxDismissalRoutes(db)); api.use(instanceSettingsRoutes(db)); if (opts.databaseBackupService) { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 96bba2754a8..8ec590b8c74 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -4808,6 +4808,7 @@ export function agentRoutes( lastHeartbeatAt: null, }, { + createdByUserId: req.actor.type === "board" ? req.actor.userId : null, aiConnectionInstall: managedHireConnection ? { ...managedHireConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined, claudeLogin: { storedSessionId: hireStoredSessionId ?? null, @@ -5062,6 +5063,7 @@ export function agentRoutes( lastHeartbeatAt: null, }, { + createdByUserId: req.actor.type === "board" ? req.actor.userId : null, aiConnectionInstall: managedConnection ? { ...managedConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined, claudeLogin: { storedSessionId: createStoredSessionId ?? null, diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 5d7c8744b7c..ed3e304ee7b 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -31,6 +31,7 @@ import { createAgentSchema, createAgentHireSchema, updateAgentSchema, + updatePrimaryAgentSchema, updateAgentPermissionsSchema, updateAgentInstructionsPathSchema, updateAgentInstructionsBundleSchema, @@ -1406,6 +1407,8 @@ const BOARD_ONLY_OPERATIONS = new Set([ "DELETE /api/board-api-keys/{keyId}", "POST /api/bootstrap/claim", "GET /api/companies/{companyId}/resource-memberships/me", + "GET /api/companies/{companyId}/primary-agent/me", + "PUT /api/companies/{companyId}/primary-agent/me", "PUT /api/companies/{companyId}/resource-memberships/me/agents/{agentId}", "PUT /api/companies/{companyId}/resource-memberships/me/documents/{documentId}", "PUT /api/companies/{companyId}/resource-memberships/me/projects/{projectId}", @@ -6362,6 +6365,35 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +const primaryAgentPreferenceResponse = z.object({ + companyId: z.string().uuid(), + userId: z.string(), + primaryAgentId: z.string().uuid().nullable(), + initialized: z.boolean(), +}); + +for (const method of ["get", "put"] as const) { + registry.registerPath({ + method, + path: "/api/companies/{companyId}/primary-agent/me", + tags: ["agents"], + summary: method === "get" ? "Get my primary agent" : "Set my primary agent", + description: "Uses the authenticated board user's personal company preference. Active company viewers may manage their own preference. Agent credentials cannot access it. Setting a primary requires a visible, approved, non-terminated agent and rejoins that agent under existing membership rules. A cleared preference retains its initialization state.", + request: { + params: z.object({ companyId: z.string().uuid() }), + ...(method === "put" ? { body: jsonBody(updatePrimaryAgentSchema) } : {}), + }, + responses: { + 200: r.ok(primaryAgentPreferenceResponse), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + ...(method === "put" ? { 422: r.unprocessable } : {}), + }, + }); +} + // ─── Announcements ─────────────────────────────────────────────────────────── const announcementResponseHeaders = { diff --git a/server/src/routes/primary-agent.ts b/server/src/routes/primary-agent.ts new file mode 100644 index 00000000000..77d29f9abf7 --- /dev/null +++ b/server/src/routes/primary-agent.ts @@ -0,0 +1,51 @@ +import { Router } from "express"; +import { agents, companies, type Db } from "@paperclipai/db"; +import { and, eq } from "drizzle-orm"; +import { updatePrimaryAgentSchema } from "@paperclipai/shared"; +import { forbidden, notFound } from "../errors.js"; +import { validate } from "../middleware/validate.js"; +import { accessService } from "../services/access.js"; +import { primaryAgentService } from "../services/primary-agent.js"; +import { assertBoard, hasCompanyAccess } from "./authz.js"; + +export function primaryAgentRoutes(db: Db) { + const router = Router(); + const svc = primaryAgentService(db); + const access = accessService(db); + router.use("/companies/:companyId/primary-agent/me", async (req, _res, next) => { + assertBoard(req); + const companyId = req.params.companyId as string; + if (!hasCompanyAccess(req, companyId)) throw forbidden("User does not have access to this company"); + if (!req.actor.userId) throw forbidden("Board user access required"); + // Personal preferences follow the same self-service rules as stars and + // resource memberships: viewers may choose their own primary too. + if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin + && !req.actor.memberships?.some(membership => membership.companyId === companyId && membership.status === "active")) { + throw forbidden("User does not have active company access"); + } + if (!await db.query.companies.findFirst({ where: eq(companies.id, req.params.companyId as string) })) { + throw notFound("Company not found"); + } + next(); + }); + router.get("/companies/:companyId/primary-agent/me", async (req, res) => { + const preference = await svc.get(req.params.companyId as string, req.actor.userId!); + if (preference.primaryAgentId) { + const decision = await access.decide({ actor: req.actor, action: "agent:read", + resource: { type: "agent", companyId: preference.companyId, agentId: preference.primaryAgentId } }); + if (!decision.allowed) preference.primaryAgentId = null; + } + res.json(preference); + }); + router.put("/companies/:companyId/primary-agent/me", validate(updatePrimaryAgentSchema), async (req, res) => { + const companyId = req.params.companyId as string; + const agentId = req.body.primaryAgentId as string; + const agent = await db.query.agents.findFirst({ where: and(eq(agents.companyId, companyId), eq(agents.id, agentId)) }); + if (!agent) throw notFound("Agent not found"); + const decision = await access.decide({ actor: req.actor, action: "agent:read", + resource: { type: "agent", companyId, agentId } }); + if (!decision.allowed) throw forbidden("Agent is outside this actor's authorization boundary"); + res.json(await svc.set(companyId, req.actor.userId!, agentId, req.actor)); + }); + return router; +} diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 38ad38a81a6..208b3c78c6d 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -61,6 +61,7 @@ import { } from "./built-in-agent-metadata.js"; import { issueThreadInteractionService } from "./issue-thread-interactions.js"; +import { clearPrimaryAgent, initializePrimaryAgent } from "./primary-agent.js"; import { agentIdentityService } from "./agent-identity.js"; function hashToken(token: string) { @@ -133,6 +134,7 @@ interface UpdateAgentOptions { } interface CreateAgentOptions { + createdByUserId?: string | null; aiConnectionInstall?: { connectionId: string; memberConnectionIds?: string[]; createdByUserId: string | null }; allowBuiltInAgentMetadata?: boolean; claudeLogin?: ClaudeLoginContext; @@ -812,6 +814,9 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) { .returning() .then((rows) => rows[0] ?? null); if (!updated) return null; + if (updated.status === "terminated") { + await clearPrimaryAgent(txDb, updated.companyId, id); + } if (data.status !== undefined) { await recordAgentStatusEvent(txDb, updated.companyId, id, current.status, updated.status); } @@ -986,6 +991,9 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) { }))).onConflictDoNothing(); } await syncAgentSecretBindings(created, txDb); + if (options?.createdByUserId && !readBuiltInAgentMarker(created.metadata)) { + await initializePrimaryAgent(txDb, companyId, options.createdByUserId, created.id); + } if (created.status !== "pending_approval" && created.status !== "terminated") { await recordResourceCreationEvent(txDb, companyId, "agent", created.id); } diff --git a/server/src/services/approvals.ts b/server/src/services/approvals.ts index d6ec34cf880..f90542846c9 100644 --- a/server/src/services/approvals.ts +++ b/server/src/services/approvals.ts @@ -189,7 +189,7 @@ export function approvalService(db: Db) { spentMonthlyCents: 0, permissions: undefined, lastHeartbeatAt: null, - }); + }, { createdByUserId: updated.requestedByAgentId ? null : updated.requestedByUserId }); hireApprovedAgentId = created?.id ?? null; } if (hireApprovedAgentId) { diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 701894a7610..dab5cd12e43 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -5671,7 +5671,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { ...patch, ...automationPausePatch, status: pauseAutomations ? "paused" : "idle", - }); + }, { createdByUserId: actorUserId }); await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active"); await access.setPrincipalPermission( targetCompany.id, diff --git a/server/src/services/onboarding-seed.ts b/server/src/services/onboarding-seed.ts index 878b2f05a4a..92399bef344 100644 --- a/server/src/services/onboarding-seed.ts +++ b/server/src/services/onboarding-seed.ts @@ -189,6 +189,7 @@ export function onboardingSeedService(db: Db) { dbx: Db, companyId: string, seed: ApplyOnboardingSeed, + audit?: OnboardingSeedAuditActor, ): Promise { const agentSvc = agentService(dbx); const goalSvc = goalService(dbx); @@ -255,7 +256,7 @@ export function onboardingSeedService(db: Db) { status: "idle", spentMonthlyCents: 0, lastHeartbeatAt: null, - }); + }, { createdByUserId: audit?.actorType === "user" ? audit.actorId : null }); agentId = created.id; } } @@ -404,7 +405,7 @@ export function onboardingSeedService(db: Db) { sql`select pg_advisory_xact_lock(hashtextextended(${`paperclip:onboarding-seed:${companyId}`}, 0))`, ); const dbx = tx as unknown as Db; - const applied = await applyWithin(dbx, companyId, seed); + const applied = await applyWithin(dbx, companyId, seed, audit); if (applied.changed && audit) { await logActivity( diff --git a/server/src/services/primary-agent.ts b/server/src/services/primary-agent.ts new file mode 100644 index 00000000000..f72e97e616e --- /dev/null +++ b/server/src/services/primary-agent.ts @@ -0,0 +1,82 @@ +import { and, eq } from "drizzle-orm"; +import { agents, userCompanyPreferences, type Db } from "@paperclipai/db"; +import type { PrimaryAgentPreference } from "@paperclipai/shared"; +import { notFound, unprocessable } from "../errors.js"; +import { persistActivity, publishActivity } from "./activity-log.js"; +import { resourceMembershipService } from "./resource-memberships.js"; + +const preferenceWhere = (companyId: string, userId: string) => and( + eq(userCompanyPreferences.companyId, companyId), eq(userCompanyPreferences.userId, userId), +); + +/** Call inside the transaction that creates the human's agent. The unique key + * arbitrates concurrent first creations; an explicit or cleared choice wins. */ +export async function initializePrimaryAgent(db: Db, companyId: string, userId: string, agentId: string) { + const [row] = await db.insert(userCompanyPreferences).values({ + companyId, userId, primaryAgentId: agentId, primaryAgentInitialized: true, + }).onConflictDoUpdate({ + target: [userCompanyPreferences.companyId, userCompanyPreferences.userId], + set: { primaryAgentId: agentId, primaryAgentInitialized: true, updatedAt: new Date() }, + setWhere: eq(userCompanyPreferences.primaryAgentInitialized, false), + }).returning(); + if (row) await persistActivity(db, { + companyId, actorType: "user", actorId: userId, action: "primary_agent.initialized", + entityType: "user_preference", entityId: userId, + }); +} + +/** Keep initialization sticky when the primary leaves or is terminated. */ +export async function clearPrimaryAgent(db: Db, companyId: string, agentId: string, userId?: string) { + const rows = await db.update(userCompanyPreferences).set({ + primaryAgentId: null, updatedAt: new Date(), + }).where(and( + eq(userCompanyPreferences.companyId, companyId), eq(userCompanyPreferences.primaryAgentId, agentId), + userId ? eq(userCompanyPreferences.userId, userId) : undefined, + )).returning(); + for (const row of rows) await persistActivity(db, { + companyId, actorType: "system", actorId: "primary-agent-lifecycle", action: "primary_agent.cleared", + entityType: "user_preference", entityId: row.userId, + }); +} + +export function primaryAgentService(db: Db) { + async function get(companyId: string, userId: string): Promise { + const row = await db.query.userCompanyPreferences.findFirst({ where: preferenceWhere(companyId, userId) }); + return { companyId, userId, primaryAgentId: row?.primaryAgentId ?? null, initialized: row?.primaryAgentInitialized ?? false }; + } + return { + get, + async set(companyId: string, userId: string, agentId: string, actor: Express.Request["actor"]) { + const result = await db.transaction(async tx => { + const txDb = tx as unknown as Db; + // Same lock order as leave/termination: agent, then preference. A racing + // lifecycle change cannot leave a newly crowned unavailable agent. + const [agent] = await tx.select().from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update"); + if (!agent) throw notFound("Agent not found"); + if (agent.status === "terminated" || agent.status === "pending_approval") { + throw unprocessable("Choose an active, approved agent as your primary."); + } + await resourceMembershipService(txDb).updateAgent({ companyId, userId, agentId, state: "joined", actor }); + // Serialize personal changes even when they target different agents. + await tx.insert(userCompanyPreferences).values({ companyId, userId }).onConflictDoNothing(); + await tx.select().from(userCompanyPreferences).where(preferenceWhere(companyId, userId)).for("update"); + await tx.insert(userCompanyPreferences).values({ + companyId, userId, primaryAgentId: agentId, primaryAgentInitialized: true, + }).onConflictDoUpdate({ + target: [userCompanyPreferences.companyId, userCompanyPreferences.userId], + set: { primaryAgentId: agentId, primaryAgentInitialized: true, updatedAt: new Date() }, + }); + const { publication } = await persistActivity(txDb, { + companyId, actorType: "user", actorId: userId, action: "primary_agent.updated", + // Activity and its live events are company-visible. Audit the change + // without disclosing the user's private agent selection. + entityType: "user_preference", entityId: userId, + }); + return { preference: await primaryAgentService(txDb).get(companyId, userId), publication }; + }); + publishActivity(result.publication); + return result.preference; + }, + }; +} diff --git a/server/src/services/resource-memberships.ts b/server/src/services/resource-memberships.ts index 54d02efe2e6..1ff9c5925f4 100644 --- a/server/src/services/resource-memberships.ts +++ b/server/src/services/resource-memberships.ts @@ -15,6 +15,7 @@ import type { ResourceMembershipUpdateResult, } from "@paperclipai/shared"; import { forbidden, notFound } from "../errors.js"; +import { clearPrimaryAgent } from "./primary-agent.js"; import { logger } from "../middleware/logger.js"; type BoardActor = { @@ -47,6 +48,7 @@ export type ResourceMembershipPolicyHook = (input: { }) => Promise | PolicyDecision; type ResourceMembershipServiceOptions = { + inTransaction?: boolean; policyHook?: ResourceMembershipPolicyHook | null; }; @@ -351,6 +353,11 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer starred?: boolean; actor: BoardActor; }): Promise { + if (!options.inTransaction) { + return db.transaction(async tx => resourceMembershipService(tx as unknown as Db, { ...options, inTransaction: true }).updateAgent(input)); + } + await db.select({ id: agents.id }).from(agents) + .where(and(eq(agents.companyId, input.companyId), eq(agents.id, input.agentId))).for("update"); const agent = await db.query.agents.findFirst({ where: and( eq(agents.id, input.agentId), @@ -422,6 +429,8 @@ export function resourceMembershipService(db: Db, options: ResourceMembershipSer }) .returning(); + if (nextState === "left") await clearPrimaryAgent(db, input.companyId, input.agentId, input.userId); + return { resourceType: "agent", resourceId: input.agentId, diff --git a/tests/e2e/agent-chat-sessions.spec.ts b/tests/e2e/agent-chat-sessions.spec.ts index 44a6084b1bc..6cd5bf1f2d8 100644 --- a/tests/e2e/agent-chat-sessions.spec.ts +++ b/tests/e2e/agent-chat-sessions.spec.ts @@ -262,7 +262,14 @@ test("secondary chat navigation preserves layout, unique conversations, history, })); await page.goto(`/${f.company.issuePrefix}/dashboard`); await page.getByRole("link", { name: "Chat", exact: true }).click(); - await expect(page.getByRole("heading", { name: "Who would you like to talk to?" })).toBeVisible(); + // With no recent conversation, Chat opens the first human-created primary. + // Navigation alone must not create a conversation or start execution. + expect(await json(await request.get(`/api/companies/${f.company.id}/primary-agent/me`))).toMatchObject({ + primaryAgentId: f.agents[0].id, initialized: true, + }); + await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible(); + expect(await json(await request.get(f.chatPath))).toBeNull(); + expect(await json(await request.get(`/api/companies/${f.company.id}/heartbeat-runs`))).toHaveLength(0); const sidebar = page.getByRole("complementary", { name: "Chat", exact: true }); // The rail lists every eligible agent before any conversation exists. const nav = sidebar.getByRole("navigation", { name: "Agent conversations" }); diff --git a/ui/src/api/primaryAgent.ts b/ui/src/api/primaryAgent.ts new file mode 100644 index 00000000000..cddb69e0eab --- /dev/null +++ b/ui/src/api/primaryAgent.ts @@ -0,0 +1,7 @@ +import type { PrimaryAgentPreference, UpdatePrimaryAgent } from "@paperclipai/shared"; +import { api } from "./client"; + +export const primaryAgentApi = { + get: (companyId: string) => api.get(`/companies/${companyId}/primary-agent/me`), + set: (companyId: string, body: UpdatePrimaryAgent) => api.put(`/companies/${companyId}/primary-agent/me`, body), +}; diff --git a/ui/src/components/EntityRow.tsx b/ui/src/components/EntityRow.tsx index 43009093d45..7e1a18f211a 100644 --- a/ui/src/components/EntityRow.tsx +++ b/ui/src/components/EntityRow.tsx @@ -6,6 +6,7 @@ interface EntityRowProps { leading?: ReactNode; identifier?: string; title: string; + titleAccessory?: ReactNode; subtitle?: string; /** * Optional metadata columns rendered immediately after the title. When set, @@ -44,6 +45,7 @@ export function EntityRow({ leading, identifier, title, + titleAccessory, subtitle, meta, metaSpacerClassName, @@ -91,6 +93,7 @@ export function EntityRow({ {title} + {titleAccessory} {(subtitle || reserveSubtitleSpace) && (

({ primaryAgentId: null as string | null, loading: false })); +vi.mock("./primary-agent/PrimaryAgentPresentation", () => ({ usePrimaryAgentPresentation: () => primaryState })); + const dialogState = vi.hoisted(() => ({ newIssueOpen: true, newIssueDefaults: {} as Record, @@ -351,6 +354,8 @@ describe("NewIssueDialog", () => { let originalInnerHeightDescriptor: PropertyDescriptor | undefined; beforeEach(() => { + primaryState.primaryAgentId = null; + primaryState.loading = false; vi.useRealTimers(); originalResizeObserver = globalThis.ResizeObserver; originalVisualViewportDescriptor = Object.getOwnPropertyDescriptor(window, "visualViewport"); @@ -507,6 +512,23 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it.each([ + { recent: null, explicit: null, draft: null, primary: "worker", expected: "Worker" }, + { recent: "ceo", explicit: null, draft: null, primary: "worker", expected: "CEO" }, + { recent: "worker", explicit: "ceo", draft: null, primary: "worker", expected: "CEO" }, + { recent: "worker", explicit: null, draft: "ceo", primary: "worker", expected: "CEO" }, + { recent: null, explicit: null, draft: null, primary: "missing", expected: "CEO" }, + ])("preserves task selection precedence ($recent, $explicit, $draft, $primary)", async ({ recent, explicit, draft, primary, expected }) => { + mockAgentsApi.list.mockResolvedValue(defaultAgents); + primaryState.primaryAgentId = primary; + if (recent) trackRecentAssignee(recent, "company-1"); + if (explicit) dialogState.newIssueDefaults = { assigneeAgentId: explicit }; + if (draft) localStorage.setItem("paperclip:issue-draft", JSON.stringify({ title: "Saved request", description: "", assigneeValue: `agent:${draft}`, projectId: "" })); + const { root } = renderDialog(container); + await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe(expected)); + act(() => root.unmount()); + }); + it("ignores another company's remembered human and keeps explicit launch assignees", async () => { mockAgentsApi.list.mockResolvedValue(defaultAgents); trackRecentAssigneeUser("user-1", "company-2"); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index c4eb7c7c9d9..5a893e5780c 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1,3 +1,4 @@ +import { usePrimaryAgentPresentation } from "./primary-agent/PrimaryAgentPresentation"; import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls"; import { useState, useEffect, useRef, useCallback, useMemo, type CSSProperties, type DragEvent } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -318,6 +319,7 @@ export function NewIssueDialog() { const defaultProjectPendingRef = useRef(false); const createRequestRef = useRef<{ fingerprint: string; idempotencyKey: string } | null>(null); + const primaryAgent = usePrimaryAgentPresentation(effectiveCompanyId); const isSubIssueMode = Boolean(newIssueDefaults.parentId); const parentIssueLabel = newIssueDefaults.parentIdentifier ?? (newIssueDefaults.parentId ? newIssueDefaults.parentId.slice(0, 8) : ""); @@ -979,7 +981,7 @@ export function NewIssueDialog() { // the queries were in flight or replacing an explicit/restored assignee. useEffect(() => { if (!newIssueOpen || !effectiveCompanyId || !defaultAssigneePendingRef.current - || !agents || !sessionFetched || !membersFetched) return; + || !agents || !sessionFetched || !membersFetched || primaryAgent?.loading) return; defaultAssigneePendingRef.current = false; const available = new Set(assigneeOptions.map((option) => option.id)); const scopedRecents = getRecentAssigneeSelectionIds(effectiveCompanyId); @@ -989,9 +991,10 @@ export function NewIssueDialog() { : getRecentAssigneeSelectionIds().filter((value) => value.startsWith("agent:")); const recent = recents.find((value) => available.has(value)); const targets = agents.filter(isAgentTaskTarget); - const fallback = targets.find((agent) => agent.role === "ceo") ?? targets[0]; + const fallback = targets.find((agent) => agent.id === primaryAgent?.primaryAgentId) + ?? targets.find((agent) => agent.role === "ceo") ?? targets[0]; setAssigneeValue(recent ?? (fallback ? assigneeValueFromSelection({ assigneeAgentId: fallback.id }) : "")); - }, [newIssueOpen, effectiveCompanyId, agents, assigneeOptions, sessionFetched, membersFetched]); + }, [newIssueOpen, effectiveCompanyId, agents, assigneeOptions, sessionFetched, membersFetched, primaryAgent?.loading, primaryAgent?.primaryAgentId]); useEffect(() => { if (!newIssueOpen || !effectiveCompanyId || !defaultProjectPendingRef.current || !projects) return; defaultProjectPendingRef.current = false; diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index e745e2b0164..53db38dcf06 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -279,7 +279,7 @@ export function Sidebar({ children }: { children?: ReactNode }) { ) : null} - {children} + {children ?? (streamlinedUiEnabled ? : null)} {streamlinedUiEnabled ? ( diff --git a/ui/src/components/SidebarAgents.production.tsx b/ui/src/components/SidebarAgents.production.tsx index c2d0bcfe4d2..08231f68b7d 100644 --- a/ui/src/components/SidebarAgents.production.tsx +++ b/ui/src/components/SidebarAgents.production.tsx @@ -1,3 +1,4 @@ +import { usePrimaryAgentPresentation } from "@/components/primary-agent/PrimaryAgentPresentation"; import { AgentAvatar } from "@/components/AgentAvatar"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation } from "@/lib/router"; @@ -300,6 +301,7 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } const lastSeenLiveAtRef = useRef>(new Map()); const queryClient = useQueryClient(); const { selectedCompanyId } = useCompany(); + const primary = usePrimaryAgentPresentation(selectedCompanyId); const { openNewAgent } = useDialogActions(); const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); const rail = collapsed && !peeking; @@ -608,6 +610,10 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } [displayedAgents, starredAgentIdSet], ); + const primaryAgent = primary && !primary.loading + ? visibleAgents.find(agent => agent.id === primary.primaryAgentId) + : undefined; + const renderAgentRow = (agent: Agent, isStarredRow: boolean) => ( - {starredAgents.map((agent: Agent) => renderAgentRow(agent, true))} - {dedupedDisplayedAgents.map((agent: Agent) => renderAgentRow(agent, false))} + {[ + ...(primaryAgent ? [primaryAgent] : []), + ...starredAgents.filter(agent => agent.id !== primaryAgent?.id), + ...dedupedDisplayedAgents.filter(agent => agent.id !== primaryAgent?.id), + ].map(agent => renderAgentRow(agent, starredAgentIdSet.has(agent.id)))} {showSeeAllLink && (() => { // Deliberately NOT a SidebarNavItem: this is a quiet muted affordance // (plain Link) that must not adopt nav-row active-route highlighting. diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 0ae054f9eaf..7f5f6a55a6d 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -1,3 +1,4 @@ +import { usePrimaryAgentPresentation } from "@/components/primary-agent/PrimaryAgentPresentation"; import { AgentAvatar } from "@/components/AgentAvatar"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation } from "@/lib/router"; @@ -300,6 +301,7 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } const lastSeenLiveAtRef = useRef>(new Map()); const queryClient = useQueryClient(); const { selectedCompanyId } = useCompany(); + const primary = usePrimaryAgentPresentation(selectedCompanyId); const { openNewAgent } = useDialogActions(); const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); const rail = collapsed && !peeking; @@ -608,6 +610,10 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } [displayedAgents, starredAgentIdSet], ); + const primaryAgent = primary && !primary.loading + ? visibleAgents.find(agent => agent.id === primary.primaryAgentId) + : undefined; + const renderAgentRow = (agent: Agent, isStarredRow: boolean) => ( - {starredAgents.map((agent: Agent) => renderAgentRow(agent, true))} - {dedupedDisplayedAgents.map((agent: Agent) => renderAgentRow(agent, false))} + {[ + ...(primaryAgent ? [primaryAgent] : []), + ...starredAgents.filter(agent => agent.id !== primaryAgent?.id), + ...dedupedDisplayedAgents.filter(agent => agent.id !== primaryAgent?.id), + ].map(agent => renderAgentRow(agent, starredAgentIdSet.has(agent.id)))} {showSeeAllLink && (() => { // Deliberately NOT a SidebarNavItem: this is a quiet muted affordance // (plain Link) that must not adopt nav-row active-route highlighting. diff --git a/ui/src/components/primary-agent/PrimaryAgentPresentation.tsx b/ui/src/components/primary-agent/PrimaryAgentPresentation.tsx new file mode 100644 index 00000000000..4a978f72346 --- /dev/null +++ b/ui/src/components/primary-agent/PrimaryAgentPresentation.tsx @@ -0,0 +1,94 @@ +import { createContext, useContext } from "react"; +import { ArrowRight, Crown, Loader2 } from "lucide-react"; +import type { Agent } from "@paperclipai/shared"; +import { AgentAvatar } from "@/components/AgentAvatar"; +import { Button } from "@/components/ui/button"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +/** Shared presentation for the application and the approved review stories. */ +export interface PrimaryAgentPresentation { + companyId: string; + primaryAgentId: string | null; + primaryAgent: Agent | null; + loading?: boolean; + error?: string; + onRetry?: () => void; + pendingAgentId?: string | null; + onChange: (agentId: string) => void; +} + +const PrimaryAgentContext = createContext(null); +export const PrimaryAgentPresentationProvider = PrimaryAgentContext.Provider; + +export function usePrimaryAgentPresentation(companyId?: string | null) { + const value = useContext(PrimaryAgentContext); + return value && (!companyId || value.companyId === companyId) ? value : null; +} + +export function PrimaryAgentIndicator({ agentId, companyId }: { agentId: string; companyId?: string }) { + const primary = usePrimaryAgentPresentation(companyId); + if (!primary || primary.loading || primary.primaryAgentId !== agentId) return null; + return + + + + + My primary agent + ; +} + +export function SetPrimaryAgentButton({ agent }: { + agent: Agent; +}) { + const primary = usePrimaryAgentPresentation(agent.companyId); + if (!primary || agent.status === "terminated" || agent.status === "pending_approval") return null; + if (primary.error) return ; + const selected = primary.primaryAgentId === agent.id; + const pending = primary.pendingAgentId === agent.id; + if (selected && !pending && !primary.loading) return null; + const previous = primary.primaryAgent?.id === primary.primaryAgentId ? primary.primaryAgent : null; + const loading = primary.loading || Boolean(primary.primaryAgentId && !previous); + const disabled = loading || Boolean(primary.pendingAgentId); + const button = ; + if (!previous) return button; + return + {button} + +

+
+ + {previous.name} +
+
+ + Change your primary agent? + + This will make {agent.name} your primary agent, do you want to do this? + + + + Cancel + primary.onChange(agent.id)}>Set as my primary + + + ; +} diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index a51d76423cf..059ede5b0ff 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -16,6 +16,18 @@ import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider"; import { queryKeys } from "../lib/queryKeys"; describe("LiveUpdatesProvider issue invalidation", () => { + it("refreshes personal membership state when a primary changes on another device", () => { + const client = new QueryClient(); + const invalidate = vi.spyOn(client, "invalidateQueries"); + __liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", { + entityType: "user_preference", entityId: "user-1", action: "primary_agent.updated", + actorType: "user", actorId: "user-1", + }, { userId: "user-1", agentId: null }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: ["primary-agent", "company-1"] }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.resourceMemberships.mine("company-1") }); + client.clear(); + }); + it.each([ ["chat-1", "issue.comment_added", "agent", "agent-1", true], ["task-1", "issue.comment_added", "agent", "agent-1", false], diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 0a8118a39c5..62260aad93b 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1280,7 +1280,11 @@ function invalidateActivityQueries( queryClient.invalidateQueries({ queryKey: ["ai-connections", companyId] }); } - if (action?.startsWith("resource_membership.")) { + if (action?.startsWith("primary_agent.") || action?.startsWith("agent.") || action?.startsWith("resource_membership.")) { + queryClient.invalidateQueries({ queryKey: ["primary-agent", companyId] }); + } + + if (action?.startsWith("resource_membership.") || action?.startsWith("primary_agent.")) { const targetUserId = readString(details?.userId); if (!targetUserId || targetUserId === currentActor.userId) { queryClient.invalidateQueries({ diff --git a/ui/src/context/PrimaryAgentProvider.test.tsx b/ui/src/context/PrimaryAgentProvider.test.tsx new file mode 100644 index 00000000000..82dd4b47296 --- /dev/null +++ b/ui/src/context/PrimaryAgentProvider.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +import { act, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PrimaryAgentProvider } from "./PrimaryAgentProvider"; +import { usePrimaryAgentPresentation } from "@/components/primary-agent/PrimaryAgentPresentation"; +import { queryKeys } from "@/lib/queryKeys"; + +const state = vi.hoisted(() => ({ + companyId: "company-a", get: vi.fn(), set: vi.fn(), session: vi.fn(), toast: vi.fn(), +})); +vi.mock("./CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.companyId }) })); +vi.mock("./ToastContext", () => ({ useToastActions: () => ({ pushToast: state.toast }) })); +vi.mock("@/api/auth", () => ({ authApi: { getSession: state.session } })); +vi.mock("@/api/primaryAgent", () => ({ primaryAgentApi: { get: state.get, set: state.set } })); +vi.mock("@/api/agents", () => ({ agentsApi: { list: async (companyId: string) => [ + { id: "maia", name: "Maia", companyId, status: "idle" }, + { id: "alex", name: "Alex", companyId, status: "idle" }, +] } })); + +let root: Root, container: HTMLDivElement, client: QueryClient; +const preference = (companyId = "company-a", userId = "user-a", primaryAgentId: string | null = "maia") => ({ + companyId, userId, primaryAgentId, initialized: true, +}); +function Harness() { + const value = usePrimaryAgentPresentation(state.companyId); + const [onboarding, setOnboarding] = useState(false); + return <> + {value?.loading ? "loading" : value?.error ? "error" : value?.primaryAgentId ?? "none"} + + + + {onboarding ? : null} + {value?.pendingAgentId ?? "none"} + ; +} +async function render() { + await act(async () => root.render()); +} +async function waitForOutput(text: string) { + await vi.waitFor(async () => { + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + expect(container.querySelector("output")?.textContent).toBe(text); + }); +} +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.companyId = "company-a"; + state.get.mockReset().mockResolvedValue(preference()); + state.set.mockReset(); state.toast.mockReset(); + state.session.mockReset().mockResolvedValue({ user: { id: "user-a" } }); + client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); +}); + +describe("primary agent persistence provider", () => { + it("optimistically switches, restores the previous crown on failure, and offers a working retry", async () => { + let reject!: (error: Error) => void; + state.set.mockImplementationOnce(() => new Promise((_resolve, onReject) => { reject = onReject; })); + await render(); await waitForOutput("maia"); + await act(async () => container.querySelector("button")!.click()); + await waitForOutput("alex"); + await act(async () => reject(new Error("Connection lost"))); + await waitForOutput("maia"); + expect(state.toast).toHaveBeenCalledWith(expect.objectContaining({ tone: "error", body: "Connection lost" })); + state.get.mockResolvedValue(preference("company-a", "user-a", "alex")); + state.set.mockResolvedValue(preference("company-a", "user-a", "alex")); + await act(async () => state.toast.mock.calls[0][0].action.onClick()); + await waitForOutput("alex"); + expect(state.set).toHaveBeenLastCalledWith("company-a", { primaryAgentId: "alex" }); + }); + + it("keeps cached preferences separate across companies and users", async () => { + await render(); await waitForOutput("maia"); + state.companyId = "company-b"; + state.get.mockResolvedValue(preference("company-b", "user-a", "alex")); + await render(); await waitForOutput("alex"); + state.get.mockResolvedValue(preference("company-b", "user-b", null)); + await act(async () => client.setQueryData(queryKeys.auth.session, { user: { id: "user-b" } })); + await waitForOutput("none"); + expect(client.getQueryData(queryKeys.primaryAgent.mine("company-a", "user-a"))).toEqual(preference()); + expect(client.getQueryData(queryKeys.primaryAgent.mine("company-b", "user-a"))).toEqual(preference("company-b", "user-a", "alex")); + expect(client.getQueryData(queryKeys.primaryAgent.mine("company-b", "user-b"))).toEqual(preference("company-b", "user-b", null)); + }); + + it("preserves app and onboarding state when the company or authenticated user changes", async () => { + await render(); await waitForOutput("maia"); + await act(async () => container.querySelectorAll("button")[2].click()); + state.companyId = "company-b"; + state.get.mockResolvedValue(preference("company-b", "user-a", "alex")); + await render(); await waitForOutput("alex"); + expect(container.querySelector("aside")?.textContent).toBe("Onboarding is open"); + state.get.mockResolvedValue(preference("company-b", "user-b", null)); + await act(async () => client.setQueryData(queryKeys.auth.session, { user: { id: "user-b" } })); + await waitForOutput("none"); + expect(container.querySelector("aside")?.textContent).toBe("Onboarding is open"); + }); + + it("keeps a late mutation failure in its original company and user scope", async () => { + let reject!: (error: Error) => void; + state.set.mockImplementationOnce(() => new Promise((_resolve, onReject) => { reject = onReject; })); + await render(); await waitForOutput("maia"); + await act(async () => container.querySelector("button")!.click()); + await waitForOutput("alex"); + state.companyId = "company-b"; + state.get.mockResolvedValue(preference("company-b", "user-b", null)); + await act(async () => client.setQueryData(queryKeys.auth.session, { user: { id: "user-b" } })); + await render(); await waitForOutput("none"); + expect(container.querySelector("[data-pending]")?.textContent).toBe("none"); + await act(async () => reject(new Error("Old request failed"))); + await waitForOutput("none"); + expect(client.getQueryData(queryKeys.primaryAgent.mine("company-a", "user-a"))).toEqual(preference()); + expect(client.getQueryData(queryKeys.primaryAgent.mine("company-b", "user-b"))).toEqual(preference("company-b", "user-b", null)); + expect(state.toast).not.toHaveBeenCalled(); + }); + + it("retries session failures before fetching a personal preference", async () => { + state.session.mockRejectedValueOnce(new Error("Session unavailable")); + await render(); await waitForOutput("error"); + expect(state.get).not.toHaveBeenCalled(); + await act(async () => container.querySelectorAll("button")[1].click()); + await waitForOutput("maia"); + expect(state.session).toHaveBeenCalledTimes(2); + }); +}); diff --git a/ui/src/context/PrimaryAgentProvider.tsx b/ui/src/context/PrimaryAgentProvider.tsx new file mode 100644 index 00000000000..71f211586a8 --- /dev/null +++ b/ui/src/context/PrimaryAgentProvider.tsx @@ -0,0 +1,54 @@ +import { useRef, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { PrimaryAgentPreference } from "@paperclipai/shared"; +import { agentsApi } from "@/api/agents"; +import { primaryAgentApi } from "@/api/primaryAgent"; +import { PrimaryAgentPresentationProvider } from "@/components/primary-agent/PrimaryAgentPresentation"; +import { usePrimaryAgent } from "@/hooks/usePrimaryAgent"; +import { queryKeys } from "@/lib/queryKeys"; +import { useCompany } from "./CompanyContext"; +import { useToastActions } from "./ToastContext"; + +export function PrimaryAgentProvider({ children }: { children: ReactNode }) { + const { selectedCompanyId: companyId } = useCompany(); + const preference = usePrimaryAgent(companyId); + const client = useQueryClient(); + const { pushToast } = useToastActions(); + const scope = useRef({ companyId, userId: preference.userId }); + scope.current = { companyId, userId: preference.userId }; + type Choice = { companyId: string; userId: string; agentId: string }; + const keyFor = (choice: Choice) => queryKeys.primaryAgent.mine(choice.companyId, choice.userId); + const isCurrentScope = (choice: Choice) => choice.companyId === scope.current.companyId && choice.userId === scope.current.userId; + const roster = useQuery({ queryKey: queryKeys.agents.list(companyId!), queryFn: () => agentsApi.list(companyId!), enabled: !!companyId }); + const mutation = useMutation({ + mutationFn: (choice: Choice) => primaryAgentApi.set(choice.companyId, { primaryAgentId: choice.agentId }), + onMutate: async choice => { + const key = keyFor(choice); + await client.cancelQueries({ queryKey: key }); + const previous = client.getQueryData(key); + client.setQueryData(key, { companyId: choice.companyId, userId: choice.userId, primaryAgentId: choice.agentId, initialized: true }); + return { previous }; + }, + onError: (error, choice, context) => { + client.setQueryData(keyFor(choice), context?.previous); + if (!isCurrentScope(choice)) return; + pushToast({ title: "Couldn't change your primary agent.", body: error.message, tone: "error", + action: { label: "Retry", onClick: () => { if (isCurrentScope(choice)) mutation.mutate(choice); } } }); + }, + onSuccess: (data, choice) => client.setQueryData(keyFor(choice), data), + onSettled: (_data, _error, choice) => { + void client.invalidateQueries({ queryKey: keyFor(choice) }); + void client.invalidateQueries({ queryKey: queryKeys.resourceMemberships.mine(choice.companyId) }); + }, + }); + const primaryAgent = roster.data?.find(agent => agent.id === preference.data?.primaryAgentId && agent.status !== "terminated") ?? null; + const error = preference.error ?? roster.error; + return { void preference.retry(); void roster.refetch(); }, + pendingAgentId: mutation.isPending && isCurrentScope(mutation.variables) ? mutation.variables.agentId : null, + onChange: agentId => mutation.mutate({ companyId, userId: preference.userId, agentId }), + } : null}>{children}; +} diff --git a/ui/src/hooks/usePrimaryAgent.ts b/ui/src/hooks/usePrimaryAgent.ts new file mode 100644 index 00000000000..a3d3ca5a025 --- /dev/null +++ b/ui/src/hooks/usePrimaryAgent.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; +import { authApi } from "@/api/auth"; +import { primaryAgentApi } from "@/api/primaryAgent"; +import { queryKeys } from "@/lib/queryKeys"; + +export function usePrimaryAgent(companyId: string | null | undefined) { + const session = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession() }); + const userId = session.data?.user?.id ?? session.data?.session?.userId ?? "local-board"; + const query = useQuery({ + queryKey: queryKeys.primaryAgent.mine(companyId ?? "__none__", userId), + queryFn: () => primaryAgentApi.get(companyId!), + enabled: !!companyId && session.isSuccess, + }); + return { + ...query, + userId, + loading: !!companyId && (session.isPending || query.isPending && !session.error), + error: session.error ?? query.error, + retry: () => session.isError ? session.refetch() : query.refetch(), + }; +} diff --git a/ui/src/hooks/useResourceMemberships.ts b/ui/src/hooks/useResourceMemberships.ts index cfc33185246..bbdd0e942ec 100644 --- a/ui/src/hooks/useResourceMemberships.ts +++ b/ui/src/hooks/useResourceMemberships.ts @@ -193,6 +193,7 @@ export function useResourceMembershipMutation(companyId: string | null | undefin ); }, onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["primary-agent", companyId] }); queryClient.invalidateQueries({ queryKey }); }, }); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 085e9ec20fc..81b1ac98387 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -623,6 +623,9 @@ export const queryKeys = { projectOrder: (companyId: string, userId: string) => ["sidebar-preferences", "project-order", companyId, userId] as const, }, + primaryAgent: { + mine: (companyId: string, userId: string) => ["primary-agent", companyId, userId] as const, + }, resourceMemberships: { mine: (companyId: string) => ["resource-memberships", companyId, "me"] as const, diff --git a/ui/src/main.tsx b/ui/src/main.tsx index a12e3130d75..7fa23269b09 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -13,6 +13,7 @@ import { PanelProvider } from "./context/PanelContext"; import { SidebarProvider } from "./context/SidebarContext"; import { DialogProvider } from "./context/DialogContext"; import { EditorAutocompleteProvider } from "./context/EditorAutocompleteContext"; +import { PrimaryAgentProvider } from "./context/PrimaryAgentProvider"; import { ToastProvider } from "./context/ToastContext"; import { ThemeProvider } from "./context/ThemeContext"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -70,6 +71,7 @@ getOrCreatePaperclipReactRoot(window, rootElement).render( + @@ -85,6 +87,7 @@ getOrCreatePaperclipReactRoot(window, rootElement).render( + diff --git a/ui/src/pages/AgentChats.test.tsx b/ui/src/pages/AgentChats.test.tsx index a65f8fa646f..29fc4928c36 100644 --- a/ui/src/pages/AgentChats.test.tsx +++ b/ui/src/pages/AgentChats.test.tsx @@ -7,6 +7,8 @@ import { recordAgentChatVisit } from "@/lib/recent-agent-chats"; import { AgentChats } from "./AgentChats"; const state = vi.hoisted(() => ({ + primaryAgentId: null as string | null, + primaryLoading: false, companyId: "company-a", userId: "user-a", agents: [] as Agent[], @@ -17,6 +19,8 @@ const state = vi.hoisted(() => ({ navigate: vi.fn(), })); +vi.mock("@/components/primary-agent/PrimaryAgentPresentation", () => ({ usePrimaryAgentPresentation: () => ({ primaryAgentId: state.primaryAgentId, loading: state.primaryLoading }) })); + vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }) })); vi.mock("@/hooks/useAgentChatNavigation", () => ({ useAgentChatNavigation: () => ({ @@ -48,6 +52,8 @@ async function render() { beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.primaryAgentId = null; + state.primaryLoading = false; state.companyId = "company-a"; state.userId = "user-a"; state.agents = [agent("alice"), agent("bob")]; @@ -73,6 +79,29 @@ afterEach(async () => { }); describe("Chat landing", () => { + it("uses the primary only when there is no valid recent conversation", async () => { + state.primaryAgentId = "alice"; + recordAgentChatVisit("company-a", "user-a", "removed"); + await render(); + expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true }); + state.navigate.mockClear(); + recordAgentChatVisit("company-a", "user-a", "bob"); + await render(); + expect(state.navigate).toHaveBeenCalledWith("/chats/bob-slug", { replace: true }); + }); + + it("waits for the primary without sending anything and preserves the chooser for unavailable primaries", async () => { + state.primaryLoading = true; + await render(); + expect(state.navigate).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Opening chat"); + state.primaryLoading = false; + state.primaryAgentId = "removed"; + await render(); + expect(state.navigate).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Who would you like to talk to?"); + }); + it("opens the most recently visited agent and replaces the landing history entry", async () => { recordAgentChatVisit("company-a", "user-a", "alice"); recordAgentChatVisit("company-a", "user-a", "bob"); diff --git a/ui/src/pages/AgentChats.tsx b/ui/src/pages/AgentChats.tsx index 0cb30cd4a10..047d6d1d5be 100644 --- a/ui/src/pages/AgentChats.tsx +++ b/ui/src/pages/AgentChats.tsx @@ -1,3 +1,4 @@ +import { usePrimaryAgentPresentation } from "@/components/primary-agent/PrimaryAgentPresentation"; import { useEffect, useState } from "react"; import { MessageCircle } from "lucide-react"; import { AgentAvatar } from "@/components/AgentAvatar"; @@ -17,6 +18,7 @@ export function AgentChats() { } function AgentChatsContent({ companyId, userId, enabled, loaded, agents, chats, session }: Pick, "companyId" | "userId" | "enabled" | "loaded" | "agents" | "chats" | "session">) { + const primary = usePrimaryAgentPresentation(companyId); const openChat = useOpenAgentChat(companyId, userId); const navigate = useNavigate(); const recentIds = useRecentAgentChats(companyId ?? "", userId); @@ -41,13 +43,18 @@ function AgentChatsContent({ companyId, userId, enabled, loaded, agents, chats, return null; }).find((path): path is string => path !== null) : undefined; + const primaryAgent = agents.data?.find(agent => agent.id === primary?.primaryAgentId + && agent.status !== "terminated" && agent.status !== "pending_approval"); + const entryPath = recentChatPath ?? (loaded && enabled && !blockingError && !resolvingRecent + && agents.isFetched && session.isFetched && !primary?.loading && primaryAgent + ? `/chats/${encodeURIComponent(agentRouteRef(primaryAgent))}` : undefined); useEffect(() => { - if (recentChatPath) navigate(recentChatPath, { replace: true }); - }, [navigate, recentChatPath]); + if (entryPath) navigate(entryPath, { replace: true }); + }, [navigate, entryPath]); if (!loaded) return

Loading chat…

; if (!enabled) return

Agent Chat is disabled. Enable it in Experimental settings.

; if (!companyId) return

Select a company to start a conversation.

; - if (resolvingRecent || recentChatPath) return

Opening chat…

; + if (resolvingRecent || entryPath || primary?.loading) return

Opening chat…

; const error = blockingError ?? (recentIds.length > 0 ? chats.error : null); return
diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 7d4863bcebc..d81683b6e8a 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1,3 +1,4 @@ +import { PrimaryAgentIndicator, SetPrimaryAgentButton } from "@/components/primary-agent/PrimaryAgentPresentation"; import { AiConnectionPoolRunDetails } from "@/components/ai-connections/AiConnectionPoolRunDetails"; import { AgentConnectionInstructions } from "@/features/connections/ConnectionInstructions"; import type { AgentInstructionCandidate, AgentInstructionsBundle } from "@paperclipai/shared"; @@ -1247,7 +1248,7 @@ export function AgentDetail() {
-

{agent.name}

+

{agent.name}

{agent.adapterType === "claude_local" || agent.adapterType === "codex_local" ? @@ -1255,6 +1256,7 @@ export function AgentDetail() { {getAdapterDisplay(agent.adapterType).label}· {agent.title || roleLabels[agent.role] || agent.role}
+
diff --git a/ui/src/pages/Agents.production.tsx b/ui/src/pages/Agents.production.tsx index 34512e9dc0c..8c20e535e5c 100644 --- a/ui/src/pages/Agents.production.tsx +++ b/ui/src/pages/Agents.production.tsx @@ -1,3 +1,4 @@ +import { PrimaryAgentIndicator } from "@/components/primary-agent/PrimaryAgentPresentation"; import { AgentAvatar } from "@/components/AgentAvatar"; import { useState, useEffect, useMemo, lazy, Suspense } from "react"; import { Link, useNavigate, useLocation } from "@/lib/router"; @@ -373,6 +374,7 @@ export function Agents() { } // Fixed (truncating) title width at xl so the `meta` group starts at a // constant x on every row — that's what makes the model + timestamp // columns line up vertically. Below xl the meta columns are hidden, so diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 1ee474816da..bbf192c674f 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -1,3 +1,4 @@ +import { PrimaryAgentIndicator } from "@/components/primary-agent/PrimaryAgentPresentation"; import { AgentAvatar } from "@/components/AgentAvatar"; import { useAgentChatEnabled } from "../hooks/useAgentChatEnabled"; import { useState, useEffect, useMemo, lazy, Suspense } from "react"; @@ -376,6 +377,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = } titleClassName="flex-1 @5xl:flex-none @5xl:w-56" titleTextClassName="truncate" subtitleClassName="truncate" diff --git a/ui/storybook/stories/primary-agent/00-review-guide.stories.tsx b/ui/storybook/stories/primary-agent/00-review-guide.stories.tsx new file mode 100644 index 00000000000..229bef555d5 --- /dev/null +++ b/ui/storybook/stories/primary-agent/00-review-guide.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const surfaces = [ + ["01 Components", "crown-indicator", "Primary indicator", "Identity and crown presentation only; absent primary, loading, long names."], + ["02 Sidebar", "idle-primary", "Agents sidebar", "Pinned idle primary, active colleagues, independent stars, collapsed section, mobile drawer, classic shell."], + ["03 Agents", "specialist-profile", "Agent list and profile", "Set as my primary on the individual page; crown beside the primary’s name, first-created agent, paused and unavailable states."], + ["04 New task", "primary-fallback", "New-task composer", "Primary fallback, recent specialist, explicit assignment, restored draft, mobile."], + ["05 Chat", "primary-fallback", "Chat", "Full production Chat page and navigation; recent conversation, primary fallback, chooser, paused state, mobile."], + ["06 Journeys", "switch-primary", "Interactive journeys", "Switch from an agent’s page, keep stars, recover a failed save, leave a primary, and start work."], + ["07 Confirmation", "replace-primary", "Switch confirmation", "Current and new agent avatars, named warning, cancel/confirm, long names, mobile, and light theme."], +]; +const storyLink = (group: string, story: string) => `/?path=/story/primary-agent-${group.toLowerCase().replaceAll(" ", "-")}--${story}`; + +function ReviewGuide() { + return
+
+
+

Design review · Primary Agent

+

Your main point of contact

+

One crown per person, per company. Your first agent receives it automatically. Recent choices take precedence; the crown is your fallback.

+ Start the walkthrough +
+
+ + + {surfaces.map(([group, id, label, coverage]) => + + + )} +
SurfaceReview coverage
{label}{coverage}
+
+
+

What to try

+
    +
  1. Open an agent’s page and choose Set as my primary beneath its details. If you already have a primary, review the two avatars and named warning before confirming. Cancel preserves your current primary.
  2. +
  3. When no primary exists, choosing one applies immediately without a confirmation modal. The selected agent’s profile and list row receive the crown; it moves to the top of the Agents sidebar.
  4. +
  5. Inspect both sidebars and the agent kebab menus: they have no crown or primary action. The current primary has no removal option.
  6. +
  7. Star or unstar an agent. Stars remain independent of your primary choice.
  8. +
  9. Compare the new-task and Chat scenarios. A recent specialist wins over Maia; an explicit choice or saved draft is preserved.
  10. +
  11. Try the failed-save journey twice: the first attempt rolls back, the second succeeds.
  12. +
  13. Use the light-theme and mobile stories. Change the editable primary name in Controls to inspect truncation.
  14. +
+
+
+

Review boundary

+

The pages, menus, avatars, composer, and app shell are production components. Preferences, first-creation results, fallback routing, and server responses are isolated Storybook simulations. Reload resets a story; no real agents run and no company data changes.

+

The live app has retired the collapsed icon rail. Coverage uses the supported collapsible Agents section and mobile drawer instead. In the streamlined shell, the proposed Agents section is supplied through Layout’s existing sidebar slot; the classic shell uses its existing section.

+

Persistence, migration, API authorization, and production default selection come after this review. These stories remain the implementation reference.

+
+
+
; +} +export default { title: "Primary Agent/00 Review guide", parameters: { layout: "fullscreen" }, render: () => } satisfies Meta; +export const StartHere: StoryObj = {}; diff --git a/ui/storybook/stories/primary-agent/01-components.stories.tsx b/ui/storybook/stories/primary-agent/01-components.stories.tsx new file mode 100644 index 00000000000..339a71269fe --- /dev/null +++ b/ui/storybook/stories/primary-agent/01-components.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AgentIdentity } from "@/components/AgentIdentity"; +import { PrimaryAgentIndicator } from "@/components/primary-agent/PrimaryAgentPresentation"; +import { primaryDecorator, primaryParameters, usePrimaryStory, type PrimaryScenario } from "./PrimaryAgentStory"; + +function Components(_args: PrimaryScenario) { + const { agents } = usePrimaryStory(); + return
+ {agents.map(agent =>
+
+
)} +
; +} +export default { title: "Primary Agent/01 Components", component: Components, decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, render: args => } satisfies Meta; +type Story = StoryObj; +export const CrownIndicator: Story = {}; +export const NoPrimary: Story = { args: { primaryId: null } }; +export const Loading: Story = { args: { loading: true } }; +export const LongName: Story = { args: { primaryName: "Maia · Strategy and cross-functional operations" } }; +export const Light: Story = { globals: { theme: "light" } }; diff --git a/ui/storybook/stories/primary-agent/02-sidebar.stories.tsx b/ui/storybook/stories/primary-agent/02-sidebar.stories.tsx new file mode 100644 index 00000000000..e392af89f73 --- /dev/null +++ b/ui/storybook/stories/primary-agent/02-sidebar.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { userEvent, within } from "storybook/test"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; + +export default { title: "Primary Agent/02 Sidebar", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => } satisfies Meta; +type Story = StoryObj; +export const IdlePrimary: Story = {}; +export const ActiveColleague: Story = { parameters: { primaryAgent: { active: true } } }; +export const ActivePrimary: Story = { parameters: { primaryAgent: { activePrimary: true } } }; +export const LongName: Story = { args: { primaryName: "Maia · Strategy and cross-functional operations" } }; +export const PrimaryIsAlsoStarred: Story = { parameters: { primaryAgent: { primaryId: "agent-qa" } } }; +export const SectionCollapsed: Story = { play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Collapse Agents" })); +} }; +export const MobileDrawer: Story = { globals: mobile, render: () => }; +export const MobileDrawerClosed: Story = { globals: mobile }; +export const ClassicShell: Story = { parameters: { primaryAgent: { classic: true } } }; +export const LoadingPreference: Story = { parameters: { primaryAgent: { loading: true } } }; +export const NoPrimary: Story = { parameters: { primaryAgent: { primaryId: null } } }; +export const Light: Story = { globals: { theme: "light" } }; diff --git a/ui/storybook/stories/primary-agent/03-agents.stories.tsx b/ui/storybook/stories/primary-agent/03-agents.stories.tsx new file mode 100644 index 00000000000..ae4d8115a3f --- /dev/null +++ b/ui/storybook/stories/primary-agent/03-agents.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; + +export default { title: "Primary Agent/03 Agents", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => } satisfies Meta; +type Story = StoryObj; +export const Roster: Story = {}; +export const Profile: Story = { parameters: { initialEntries: ["/PAP/agents/maia"] } }; +export const SpecialistProfile: Story = { parameters: { initialEntries: ["/PAP/agents/alex"] } }; +export const FirstCreatedAgent: Story = { parameters: { primaryAgent: { firstCreated: true } } }; +export const PausedPrimary: Story = { parameters: { primaryAgent: { paused: true }, initialEntries: ["/PAP/agents/maia"] } }; +export const ErrorPrimary: Story = { parameters: { primaryAgent: { error: true }, initialEntries: ["/PAP/agents/maia"] } }; +export const PreviouslyLeftAgent: Story = { parameters: { primaryAgent: { leftPrimary: true }, initialEntries: ["/PAP/agents/maia"] } }; +export const UnavailablePrimary: Story = { parameters: { primaryAgent: { unavailable: true } } }; +export const ClearedPrimary: Story = { parameters: { primaryAgent: { primaryId: null } } }; +export const MobileRoster: Story = { globals: mobile }; +export const MobileProfile: Story = { ...Profile, globals: mobile }; +export const MobileSpecialistProfile: Story = { ...SpecialistProfile, globals: mobile }; +export const LoadingProfile: Story = { ...SpecialistProfile, parameters: { ...SpecialistProfile.parameters, primaryAgent: { loading: true } } }; +export const ClassicProfile: Story = { ...Profile, parameters: { ...Profile.parameters, primaryAgent: { classic: true } } }; diff --git a/ui/storybook/stories/primary-agent/04-new-task.stories.tsx b/ui/storybook/stories/primary-agent/04-new-task.stories.tsx new file mode 100644 index 00000000000..62a316e629e --- /dev/null +++ b/ui/storybook/stories/primary-agent/04-new-task.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; + +export default { title: "Primary Agent/04 New task", decorators: [primaryDecorator], parameters: primaryParameters, render: () => } satisfies Meta; +type Story = StoryObj; +export const PrimaryFallback: Story = {}; +export const RecentSpecialistWins: Story = { parameters: { primaryAgent: { recentAssignee: true } }, render: () => }; +export const ExplicitAssignmentWins: Story = { parameters: { primaryAgent: { recentAssignee: true } }, render: () => }; +export const RestoredDraftWins: Story = { parameters: { primaryAgent: { recentAssignee: true } }, render: () => }; +export const NoPrimary: Story = { parameters: { primaryAgent: { primaryId: null } } }; +export const UnavailablePrimary: Story = { parameters: { primaryAgent: { unavailable: true } } }; +export const PausedPrimary: Story = { parameters: { primaryAgent: { paused: true } } }; +export const MobilePrimary: Story = { globals: mobile }; +export const MobileDraft: Story = { ...RestoredDraftWins, globals: mobile }; diff --git a/ui/storybook/stories/primary-agent/05-chat.stories.tsx b/ui/storybook/stories/primary-agent/05-chat.stories.tsx new file mode 100644 index 00000000000..b5a2f3fa2aa --- /dev/null +++ b/ui/storybook/stories/primary-agent/05-chat.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; + +export default { title: "Primary Agent/05 Chat", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/chats"] }, render: () => } satisfies Meta; +type Story = StoryObj; +export const PrimaryFallback: Story = {}; +export const RecentConversationWins: Story = { parameters: { primaryAgent: { recentChat: true } } }; +export const NoPrimaryChooser: Story = { parameters: { primaryAgent: { primaryId: null } } }; +export const PausedPrimary: Story = { parameters: { primaryAgent: { paused: true } } }; +export const UnavailablePrimaryChooser: Story = { parameters: { primaryAgent: { unavailable: true } } }; +export const MobilePrimary: Story = { globals: mobile }; +export const MobileChooser: Story = { ...NoPrimaryChooser, globals: mobile }; +export const Light: Story = { globals: { theme: "light" } }; diff --git a/ui/storybook/stories/primary-agent/06-journeys.stories.tsx b/ui/storybook/stories/primary-agent/06-journeys.stories.tsx new file mode 100644 index 00000000000..0172759b91f --- /dev/null +++ b/ui/storybook/stories/primary-agent/06-journeys.stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within, waitFor } from "storybook/test"; +import { PrimaryPage, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; + +async function openActions(canvasElement: HTMLElement, name: string, profile = false) { + const page = within(canvasElement.ownerDocument.body); + const buttons = await page.findAllByRole("button", { name: `Open actions for ${name}` }); + const button = (profile ? buttons[buttons.length - 1] : buttons[0])!; + button.focus(); + await userEvent.keyboard("{Enter}"); + return page; +} +const maiaProfile = { initialEntries: ["/PAP/agents/maia"] }; +export default { title: "Primary Agent/06 Journeys", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/agents/alex"] }, render: () => } satisfies Meta; +type Story = StoryObj; +async function confirmPrimaryChange(canvasElement: HTMLElement) { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Set as my primary" })); + const dialog = within(await page.findByRole("alertdialog", { name: "Change your primary agent?" })); + await expect(dialog.getByText("This will make Alex your primary agent, do you want to do this?")).toBeVisible(); + await expect(dialog.getByRole("img", { name: "Maia avatar" })).toBeVisible(); + await expect(dialog.getByRole("img", { name: "Alex avatar" })).toBeVisible(); + await userEvent.click(dialog.getByRole("button", { name: "Set as my primary" })); + return page; +} +export const SwitchPrimary: Story = { play: async ({ canvasElement }) => { + const page = await confirmPrimaryChange(canvasElement); + await waitFor(() => expect(page.queryByRole("button", { name: "Setting primary…" })).not.toBeInTheDocument()); + await expect(within(page.getByRole("main")).getByRole("img", { name: "My primary agent" })).toBeVisible(); + await expect(page.queryByRole("button", { name: "Set as my primary" })).not.toBeInTheDocument(); +} }; +export const PrimaryHasNoUnsetAction: Story = { parameters: maiaProfile, play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await expect(await page.findByRole("img", { name: "My primary agent" })).toBeVisible(); + await expect(page.queryByRole("button", { name: "Set as my primary" })).not.toBeInTheDocument(); + await openActions(canvasElement, "Maia", true); + await expect(page.queryByRole("button", { name: /primary/i })).not.toBeInTheDocument(); +} }; +export const FailedSaveRestoresCrown: Story = { parameters: { primaryAgent: { failNext: true } }, play: async ({ canvasElement }) => { + const page = await confirmPrimaryChange(canvasElement); + await waitFor(() => expect(page.getByText("Couldn't change your primary agent")).toBeVisible()); + await expect(within(page.getByRole("main")).queryByRole("img", { name: "My primary agent" })).not.toBeInTheDocument(); + await expect(page.getByRole("button", { name: "Set as my primary" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Try again" })).toBeVisible(); +} }; +export const StarDoesNotMoveCrown: Story = { parameters: maiaProfile, play: async ({ canvasElement }) => { + const page = await openActions(canvasElement, "Jules"); + await userEvent.click(await page.findByText("Star agent")); + await openActions(canvasElement, "Jules"); + await expect(await page.findByText("Remove from starred")).toBeVisible(); + await expect(page.queryByRole("menuitem", { name: /primary/i })).not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + await expect(within(page.getByRole("main")).getByRole("img", { name: "My primary agent" })).toBeVisible(); +} }; +export const LeavePrimary: Story = { parameters: maiaProfile, play: async ({ canvasElement }) => { + const page = await openActions(canvasElement, "Maia"); + await userEvent.click(await page.findByText("Leave agent")); + await waitFor(() => expect(page.queryAllByRole("img", { name: "My primary agent" })).toHaveLength(0)); + await expect(await page.findByRole("button", { name: "Set as my primary" })).toBeVisible(); +} }; +export const SwitchThenStartTask: Story = { play: async context => { + await SwitchPrimary.play!(context); + const page = within(context.canvasElement.ownerDocument.body); + await userEvent.click(page.getByRole("button", { name: "New Task" })); + await expect(await page.findByRole("dialog")).toBeVisible(); + await expect(await page.findByRole("button", { name: "Select assignee" })).toHaveTextContent("Alex"); +} }; +export const RetryFailedSave: Story = { ...FailedSaveRestoresCrown, play: async context => { + await FailedSaveRestoresCrown.play!(context); + const page = within(context.canvasElement.ownerDocument.body); + await userEvent.click(page.getByRole("button", { name: "Try again" })); + await waitFor(() => expect(page.queryByRole("button", { name: "Setting primary…" })).not.toBeInTheDocument()); + await expect(within(page.getByRole("main")).getByRole("img", { name: "My primary agent" })).toBeVisible(); +} }; +export const SwitchThenOpenChat: Story = { play: async context => { + await SwitchPrimary.play!(context); + const page = within(context.canvasElement.ownerDocument.body); + await userEvent.click(page.getByRole("link", { name: "Chat" })); + await expect(await page.findByText("Message Alex — describe what you want done…")).toBeVisible(); + await expect(page.queryAllByRole("img", { name: "My primary agent" })).toHaveLength(0); +} }; +export const ProfileActionOnly: Story = { play: async ({ canvasElement }) => { + const page = await openActions(canvasElement, "Alex"); + await expect(page.queryByRole("menuitem", { name: /primary/i })).not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + await openActions(canvasElement, "Alex", true); + await expect(within(page.getByRole("dialog")).queryByRole("button", { name: /primary/i })).not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + await expect(page.getByRole("button", { name: "Set as my primary" })).toBeVisible(); +} }; +export const CancelKeepsPrimary: Story = { play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Set as my primary" })); + const dialog = within(await page.findByRole("alertdialog")); + await userEvent.click(dialog.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(page.queryByRole("alertdialog")).not.toBeInTheDocument()); + await expect(page.getByRole("button", { name: "Set as my primary" })).toHaveFocus(); + await expect(within(page.getByRole("main")).queryByRole("img", { name: "My primary agent" })).not.toBeInTheDocument(); + await userEvent.click(page.getByRole("link", { name: "See all agents" })); + await expect(await page.findByRole("link", { name: /^Maia My primary agent/ })).toBeVisible(); +} }; +export const FirstChoiceNeedsNoConfirmation: Story = { parameters: { primaryAgent: { primaryId: null } }, play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Set as my primary" })); + await expect(page.queryByRole("alertdialog")).not.toBeInTheDocument(); + await waitFor(() => expect(page.queryByRole("button", { name: "Setting primary…" })).not.toBeInTheDocument()); + await expect(within(page.getByRole("main")).getByRole("img", { name: "My primary agent" })).toBeVisible(); +} }; diff --git a/ui/storybook/stories/primary-agent/07-confirmation.stories.tsx b/ui/storybook/stories/primary-agent/07-confirmation.stories.tsx new file mode 100644 index 00000000000..73329989d76 --- /dev/null +++ b/ui/storybook/stories/primary-agent/07-confirmation.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; + +export default { + title: "Primary Agent/07 Confirmation", + decorators: [primaryDecorator], + parameters: { ...primaryParameters, initialEntries: ["/PAP/agents/alex"] }, + render: () => , +} satisfies Meta; +type Story = StoryObj; + +export const ReplacePrimary: Story = { play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Set as my primary" })); + await expect(await page.findByRole("alertdialog", { name: "Change your primary agent?" })).toBeVisible(); +} }; +export const LongName: Story = { ...ReplacePrimary, args: { primaryName: "Maia · Strategy and cross-functional operations" } }; +export const Mobile: Story = { ...ReplacePrimary, globals: mobile }; +export const Light: Story = { ...ReplacePrimary, globals: { theme: "light" } }; diff --git a/ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx b/ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx new file mode 100644 index 00000000000..c743d65d5f9 --- /dev/null +++ b/ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx @@ -0,0 +1,269 @@ +import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { Decorator } from "@storybook/react-vite"; +import type { Agent, Issue, ResourceMemberships } from "@paperclipai/shared"; +import { Layout } from "@/components/Layout"; +import { Layout as ClassicLayout } from "@/components/Layout.production"; +import { SidebarAgents } from "@/components/SidebarAgents"; +import { Agents } from "@/pages/Agents"; +import { Agents as ClassicAgents } from "@/pages/Agents.production"; +import { AgentDetail } from "@/pages/AgentDetail"; +import { AgentChats } from "@/pages/AgentChats"; +import { AgentChat } from "@/pages/AgentChat"; +import { PluginLauncherProvider } from "@/plugins/launchers"; +import { PrimaryAgentPresentationProvider, usePrimaryAgentPresentation } from "@/components/primary-agent/PrimaryAgentPresentation"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { useDialogActions } from "@/context/DialogContext"; +import { useToastActions } from "@/context/ToastContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { agentChatDraft } from "@/lib/agent-chat-draft"; +import { Navigate, Route, Routes } from "@/lib/router"; +import { storybookAgents, storybookAuthSession, storybookCompanies, storybookIssues, storybookDashboardSummary } from "../../fixtures/paperclipData"; + +export const companyId = "company-storybook"; +export const mobile = { viewport: { value: "mobile", isRotated: false } }; +export const primaryAgent = { ...storybookAgents[0]!, name: "Maia", urlKey: "maia", title: "Chief of staff", role: "general", status: "idle", reportsTo: null } as Agent; +export const specialist = { ...storybookAgents[1]!, name: "Alex", urlKey: "alex", title: "Product engineer", role: "engineer", status: "idle", reportsTo: null } as Agent; +export const designer = { ...storybookAgents[2]!, name: "Jules", urlKey: "jules", title: "Designer", role: "designer", status: "idle" } as Agent; + +export interface PrimaryScenario { + primaryId?: string | null; + primaryName?: string; + paused?: boolean; + error?: boolean; + active?: boolean; + activePrimary?: boolean; + loading?: boolean; + failNext?: boolean; + firstCreated?: boolean; + classic?: boolean; + drawer?: boolean; + recentAssignee?: boolean; + recentChat?: boolean; + unavailable?: boolean; + leftPrimary?: boolean; +} + +interface StoryState { + agents: Agent[]; + memberships: ResourceMemberships; + chats: Issue[]; + primaryId: string | null; + options: PrimaryScenario; +} +const StoryStateContext = createContext(null); +export function usePrimaryStory() { + return useContext(StoryStateContext)!; +} + +function makeState(options: PrimaryScenario): StoryState { + const first = { ...primaryAgent, name: options.primaryName || primaryAgent.name, status: options.paused ? "paused" : options.error ? "error" : options.activePrimary ? "running" : "idle" } as Agent; + return { + agents: options.firstCreated ? [first] : [...(options.unavailable ? [] : [first]), { ...specialist, status: options.active ? "running" : "idle" }, designer], + memberships: { projectMemberships: {}, agentMemberships: options.leftPrimary ? { [first.id]: "left" } : {}, starredAgentIds: options.firstCreated ? [] : [specialist.id], starredProjectIds: [], starredDocumentIds: [], agentStarredAt: {}, projectStarredAt: {}, documentStarredAt: {}, updatedAt: null }, + chats: options.recentChat ? [{ ...agentChatDraft(specialist), id: "primary-story-recent-chat", conversationUserId: "user-board", createdAt: new Date("2026-10-07T13:00:00Z"), updatedAt: new Date("2026-10-07T13:05:00Z") }] : [], + primaryId: options.unavailable || options.leftPrimary ? null : options.primaryId === undefined ? first.id : options.primaryId, + options, + }; +} + +/** All simulated writes are confined to this mounted story. No new API client + * or server behavior is installed in the application during design review. */ +function installFixtures(state: StoryState, changed: () => void) { + const previousFetch = window.fetch; + window.fetch = async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.origin); + if (!url.pathname.startsWith("/api/")) return previousFetch(input, init); + const path = url.pathname; + const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + const body = init?.body && typeof init.body === "string" ? JSON.parse(init.body) : {}; + const json = (value: unknown) => Response.json(value); + if (path === "/api/auth/get-session") return json(storybookAuthSession); + if (path === "/api/companies") return json([{ ...storybookCompanies[0], name: "Northstar", requireBoardApprovalForNewAgents: false }]); + if (path === "/api/health") return json({ deploymentMode: "authenticated", status: "ok" }); + if (path === "/api/instance/settings/experimental") return json({ enableStreamlinedUi: !state.options.classic, enableAgentChat: true, enableManagedSandboxOnly: false, enableCombinedInboxTasks: true }); + if (path === "/api/instance/settings") return json({ experimental: { enableStreamlinedUi: !state.options.classic, enableAgentChat: true, enableBuiltInAgents: false, enableEnvironments: false } }); + if (path === "/api/cli-auth/me" || path.endsWith("/access/me")) return json({ user: storybookAuthSession.user, userId: "user-board", source: "authenticated", isInstanceAdmin: false, companyIds: [companyId], memberships: [{ companyId, membershipRole: "owner", status: "active" }], keyId: null }); + if (path.endsWith("/resource-memberships/me")) return json(state.memberships); + const membership = path.match(/resource-memberships\/me\/agents\/([^/]+)$/); + if (membership && method === "PUT") { + const id = membership[1]!; + const stars = new Set(state.memberships.starredAgentIds); + if (body.starred === true) stars.add(id); + if (body.starred === false || body.state === "left") stars.delete(id); + state.memberships = { ...state.memberships, starredAgentIds: [...stars], agentMemberships: { ...state.memberships.agentMemberships, [id]: body.state ?? "joined" } }; + if (body.state === "left" && state.primaryId === id) state.primaryId = null; + changed(); + return json({ resourceType: "agent", resourceId: id, state: body.state ?? "joined", starredAt: stars.has(id) ? new Date() : null, updatedAt: new Date() }); + } + if (path === `/api/companies/${companyId}/agents`) return json(state.agents); + const detail = path.match(/^\/api\/agents\/([^/]+)$/); + if (detail) { + const agent = state.agents.find(a => a.id === detail[1] || a.urlKey === detail[1]); + return agent ? json({ ...agent, chainOfCommand: [], access: { canAssignTasks: true, taskAssignSource: "explicit_grant", membership: null, grants: [] } }) : new Response(null, { status: 404 }); + } + const lifecycle = path.match(/^\/api\/agents\/([^/]+)\/(pause|resume|terminate)$/); + if (lifecycle && method === "POST") { + const agent = state.agents.find(a => a.id === lifecycle[1])!; + agent.status = lifecycle[2] === "pause" ? "paused" : lifecycle[2] === "terminate" ? "terminated" : "idle"; + if (agent.status === "terminated") { + state.agents = state.agents.filter(a => a.id !== agent.id); + if (state.primaryId === agent.id) state.primaryId = null; + } + changed(); + return json(agent); + } + if (path.endsWith("/runtime-state") || path.endsWith("/identity")) return json(null); + if (path.endsWith("/org")) return json(state.agents.map(a => ({ ...a, children: [] }))); + if (path.endsWith("/live-runs")) return json(state.options.active || state.options.activePrimary ? [{ id: "primary-story-run", agentId: state.options.activePrimary ? primaryAgent.id : specialist.id, companyId, status: "running", issueId: null, startedAt: new Date() }] : []); + if (path.endsWith("/dashboard")) return json(storybookDashboardSummary); + if (path.endsWith("/sidebar-badges")) return json({ inbox: 0, approvals: 0, failedRuns: 0, joinRequests: 0 }); + if (path.endsWith("/sidebar-preferences/me")) return json({ orderedIds: [], updatedAt: null }); + if (path.endsWith("/user-directory")) return json({ users: [{ principalId: "user-board", status: "active", user: storybookAuthSession.user }] }); + if (path.endsWith("/budgets/overview")) return json({ companyId, policies: [], activeIncidents: [], pausedAgentCount: 0, pausedProjectCount: 0, pendingApprovalCount: 0 }); + if (path === `/api/companies/${companyId}/chats`) return json(state.chats); + const chat = path.match(/\/chats\/([^/]+)$/); + if (chat) { + const agent = state.agents.find(a => a.id === chat[1] || a.urlKey === chat[1]); + let issue = state.chats.find(i => i.conversationAgentId === agent?.id); + if (!issue && method === "POST" && agent) { + issue = { ...agentChatDraft(agent), id: `primary-chat-${agent.id}`, conversationUserId: "user-board", createdAt: new Date(), updatedAt: new Date() }; + state.chats.push(issue); + } + return json(issue ?? null); + } + const issueDetail = path.match(/^\/api\/issues\/([^/]+)$/); + if (issueDetail) return json(state.chats.find(issue => issue.id === issueDetail[1]) ?? null); + if (path === "/api/issues/primary-story-recent-chat/comments") return json([ + { id: "primary-story-reply", issueId: "primary-story-recent-chat", companyId, authorType: "agent", authorAgentId: specialist.id, authorUserId: null, body: "The mobile navigation update is ready for your review.", presentation: null, metadata: null, createdAt: "2026-10-07T13:05:00Z", updatedAt: "2026-10-07T13:05:00Z" }, + { id: "primary-story-message", issueId: "primary-story-recent-chat", companyId, authorType: "user", authorAgentId: null, authorUserId: "user-board", body: "Can you review the mobile navigation before we launch?", presentation: null, metadata: null, createdAt: "2026-10-07T13:00:00Z", updatedAt: "2026-10-07T13:00:00Z" }, + ]); + if (path.endsWith("/active-run") || path.endsWith("/documents/plan")) return json(null); + if (path.endsWith("/tree-control/state")) return json({ activePauseHold: null }); + if (path.includes("/email/tasks/")) return json(null); + if (path.endsWith("/queued-comments")) return json({ issueId: path.split("/")[3], queueId: null, state: null, targetRunId: null, revision: "0", entries: [] }); + if (path === `/api/companies/${companyId}/issues`) { + if (method === "POST") return json({ ...storybookIssues[0], ...body, id: "primary-story-task", identifier: "PAP-1642", companyId }); + return json([]); + } + if (path.endsWith("/read-state")) return json({ unread: false }); + if (path.endsWith("/capabilities") && path.includes("/environments")) return json({ environments: [], defaultEnvironmentId: null }); + if (path.includes("/models") || path === "/api/adapters" || path.includes("/model-profiles")) return previousFetch(input, init); + // Other existing read-only shell panels are empty in this isolated company. + // Refuse unknown writes rather than ever reaching a running Paperclip API. + return method === "GET" ? json([]) : Response.json({ error: "This action is outside the primary-agent design preview." }, { status: 422 }); + }; + return () => { window.fetch = previousFetch; }; +} + +function Sandbox({ options, children }: { options: PrimaryScenario; children: ReactNode }) { + const [state] = useState(() => makeState(options)); + const [ready, setReady] = useState(false); + const [primaryId, setPrimaryId] = useState(state.primaryId); + const [pendingAgentId, setPendingAgentId] = useState(null); + const [loading] = useState(Boolean(options.loading)); + const failed = useRef(false); + const timers = useRef[]>([]); + const client = useQueryClient(); + const { selectedCompanyId, setSelectedCompanyId } = useCompany(); + const { pushToast, dismissToast } = useToastActions(); + useLayoutEffect(() => { + const storageKeys = ["paperclip:issue-draft", "paperclip:recent-assignees", `paperclip:recent-assignees:${companyId}`, `paperclip.recentAgentChats:${companyId}:user-board`, ...state.agents.map(a => `paperclip.recentAgentChatIssue:${companyId}:user-board:${a.id}`)]; + const previous = storageKeys.map(key => [key, localStorage.getItem(key)] as const); + storageKeys.forEach(key => localStorage.removeItem(key)); + const changed = () => { + setPrimaryId(state.primaryId); + client.setQueryData(queryKeys.agents.list(companyId), [...state.agents]); + client.setQueryData(queryKeys.resourceMemberships.mine(companyId), { ...state.memberships }); + }; + const restore = installFixtures(state, changed); + client.setQueryData(queryKeys.companies.list("user-board"), { companies: [{ ...storybookCompanies[0], name: "Northstar", requireBoardApprovalForNewAgents: false }], unauthorized: false }); + client.setQueryData(queryKeys.auth.session, storybookAuthSession); + client.setQueryData(queryKeys.agents.list(companyId), state.agents); + client.setQueryData(queryKeys.resourceMemberships.mine(companyId), state.memberships); + client.setQueryData(queryKeys.projects.list(companyId), []); + setSelectedCompanyId(companyId); + setReady(true); + return () => { + restore(); + timers.current.forEach(clearTimeout); + previous.forEach(([key, value]) => value === null ? localStorage.removeItem(key) : localStorage.setItem(key, value)); + }; + }, [client, setSelectedCompanyId, state]); + // Review-only simulation: feed the proposed resolved default to the existing + // composer through its remembered selection. Production resolution is deferred. + useEffect(() => { + if (!ready) return; + const id = options.recentAssignee ? specialist.id : primaryId; + localStorage.setItem(`paperclip:recent-assignees:${companyId}`, JSON.stringify(id ? [`agent:${id}`] : [])); + }, [ready, primaryId, options.recentAssignee]); + const changePrimary = (id: string) => { + const before = state.primaryId; + setPrimaryId(id); + setPendingAgentId(id); + timers.current.push(setTimeout(() => { + setPendingAgentId(null); + if (options.failNext && !failed.current) { + failed.current = true; + setPrimaryId(before); + const toastId = pushToast({ title: "Couldn't change your primary agent", body: "Your previous choice is still saved.", tone: "error", action: { label: "Try again", onClick: () => { if (toastId) dismissToast(toastId); changePrimary(id); } } }); + } else { + state.primaryId = id; + if (id && state.memberships.agentMemberships[id] === "left") { + state.memberships = { ...state.memberships, agentMemberships: { ...state.memberships.agentMemberships, [id]: "joined" } }; + client.setQueryData(queryKeys.resourceMemberships.mine(companyId), state.memberships); + } + } + }, 450)); + }; + return ready && selectedCompanyId === companyId ? + agent.id === primaryId) ?? null, pendingAgentId, loading, onChange: changePrimary }}> + {children} + + : null; +} + +export const primaryDecorator: Decorator = (Story, context) => ; +export const primaryParameters = { + layout: "fullscreen", + initialEntries: ["/PAP/agents/all"], + waitForViewport: true, + docs: { story: { inline: false, height: "850px" }, description: { component: "Design review: shared production surfaces with in-memory preferences and API fixtures. Primary fallback routing and first-creation initialization are simulated here; no persistence, automatic production selection, or agent execution has been implemented." } }, +}; + +function ChatEntry() { + const state = usePrimaryStory(); + const primary = usePrimaryAgentPresentation(companyId); + const agent = state.agents.find(a => a.id === (state.options.recentChat ? specialist.id : primary?.primaryAgentId)); + return agent ? : ; +} + +export function PrimaryPage({ task, drawer }: { task?: "primary" | "recent" | "explicit" | "draft"; drawer?: boolean }) { + const state = usePrimaryStory(); + const { setSidebarOpen } = useSidebar(); + const { openNewIssue } = useDialogActions(); + const launched = useRef(false); + useEffect(() => { + if (drawer || state.options.drawer) setSidebarOpen(true); + if (task && !launched.current) { + launched.current = true; + if (task === "draft") { + localStorage.setItem("paperclip:issue-draft", JSON.stringify({ title: "Review the launch mockups", description: "Keep the mobile navigation compact.", companyId, status: "todo", priority: "medium", assigneeValue: `agent:${designer.id}`, projectId: "", reviewerValue: "", approverValue: "", assigneeModelOverride: "", assigneeThinkingEffort: "", assigneeChrome: false })); + openNewIssue(); + } else { + openNewIssue({ title: "Plan next week's launch", ...(task === "explicit" ? { assigneeAgentId: designer.id } : task === "primary" ? { assigneeAgentId: state.primaryId ?? undefined } : {}) }); + } + } + }, [drawer, openNewIssue, setSidebarOpen, state, task]); + return + : } />}> + : } /> + } /> + } /> + } /> + } /> + } /> + + ; +}