diff --git a/.agents/skills/company-creator/SKILL.md b/.agents/skills/company-creator/SKILL.md index 7c1aa61b1e5..b2f4432bcdb 100644 --- a/.agents/skills/company-creator/SKILL.md +++ b/.agents/skills/company-creator/SKILL.md @@ -154,6 +154,14 @@ Each AGENTS.md body should include not just what the agent does, but how they fi This turns a collection of agents into an organization that actually works together. Without workflow context, agents operate in isolation — they do their job but don't know what happens before or after them. +Add a concise execution contract to every generated working agent: + +- Start actionable work in the same heartbeat and do not stop at a plan unless planning was requested. +- Leave durable progress in comments, documents, or work products with the next action. +- Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes. +- Mark blocked work with the unblock owner and action. +- Respect budget, pause/cancel, approval gates, and company boundaries. + ### Step 5: Confirm Output Location Ask the user where to write the package. Common options: diff --git a/.agents/skills/company-creator/references/example-company.md b/.agents/skills/company-creator/references/example-company.md index ba7623b98c1..cbc10d28f15 100644 --- a/.agents/skills/company-creator/references/example-company.md +++ b/.agents/skills/company-creator/references/example-company.md @@ -105,6 +105,13 @@ Your responsibilities: - Implement features and fix bugs - Write tests and documentation - Participate in code reviews + +Execution contract: + +- Start actionable implementation work in the same heartbeat; do not stop at a plan unless planning was requested. +- Leave durable progress with a clear next action. +- Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes. +- Mark blocked work with the unblock owner and action. ``` ## teams/engineering/TEAM.md diff --git a/.agents/skills/create-agent-adapter/SKILL.md b/.agents/skills/create-agent-adapter/SKILL.md index dcd6456ee49..a41318580c8 100644 --- a/.agents/skills/create-agent-adapter/SKILL.md +++ b/.agents/skills/create-agent-adapter/SKILL.md @@ -548,7 +548,7 @@ Import from `@paperclipai/adapter-utils/server-utils`: ### Prompt Templates - Support `promptTemplate` for every run - Use `renderTemplate()` with the standard variable set -- Default prompt: `"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work."` +- Default prompt should use `DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE` from `@paperclipai/adapter-utils/server-utils` so local adapters share Paperclip's execution contract: act in the same heartbeat, avoid planning-only exits unless requested, leave durable progress and a next action, use child issues instead of polling, mark blockers with owner/action, and respect governance boundaries. ### Error Handling - Differentiate timeout vs process error vs parse failure diff --git a/.env.example b/.env.example index 8ec9d57e964..84e20d96058 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,6 @@ DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip PORT=3100 SERVE_UI=false BETTER_AUTH_SECRET=paperclip-dev-secret + +# Discord webhook for daily merge digest (scripts/discord-daily-digest.sh) +# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 02e836227c9..c12c05eb70f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -38,6 +38,8 @@ - +> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. + ## Model Used HumanInvite --> Active + Board --> OpenClawInvite --> Active + Active --> Revoked: revoke + Active --> Expired: expiresAt passes + + Active --> BootstrapDone: bootstrap_ceo accept + BootstrapDone --> Accepted + + Active --> HumanReuse: human accept + HumanReuse --> HumanPending: reuse existing pending request + HumanReuse --> HumanApproved: reuse existing approved request + HumanReuse --> HumanPending: no reusable request
create new request + HumanPending --> HumanApproved: board approves + HumanPending --> HumanRejected: board rejects + HumanPending --> Accepted + HumanApproved --> Accepted + + Active --> AgentPending: agent accept + AgentPending --> Accepted + AgentPending --> AgentApproved: board approves + AgentPending --> AgentRejected: board rejects + AgentApproved --> ClaimAvailable: createdAgentId + claimSecretHash + ClaimAvailable --> ClaimConsumed: POST claim-api-key succeeds + ClaimAvailable --> ClaimExpired: secret expires + + Accepted --> OpenClawReplay + OpenClawReplay --> AgentPending + OpenClawReplay --> AgentApproved +``` + +## Board-Side Screen States + +```mermaid +stateDiagram-v2 + [*] --> CompanySelection + + CompanySelection --> NoCompany: no company selected + CompanySelection --> LoadingHistory: selectedCompanyId present + LoadingHistory --> HistoryError: listInvites failed + LoadingHistory --> Ready: listInvites succeeded + + state Ready { + [*] --> EmptyHistory + EmptyHistory --> PopulatedHistory: invites exist + PopulatedHistory --> LoadingMore: View more + LoadingMore --> PopulatedHistory: next page loaded + + PopulatedHistory --> RevokePending: Revoke active invite + RevokePending --> PopulatedHistory: revoke succeeded + RevokePending --> PopulatedHistory: revoke failed + + EmptyHistory --> CreatePending: Create invite + PopulatedHistory --> CreatePending: Create invite + CreatePending --> LatestInviteVisible: create succeeded + CreatePending --> Ready: create failed + LatestInviteVisible --> CopiedToast: clipboard copy succeeded + LatestInviteVisible --> Ready: navigate away or refresh + } + + CompanySelection --> OpenClawPromptReady: Company settings prompt generator + OpenClawPromptReady --> OpenClawPromptPending: Generate OpenClaw Invite Prompt + OpenClawPromptPending --> OpenClawSnippetVisible: prompt generated + OpenClawPromptPending --> OpenClawPromptReady: generation failed +``` + +## Invite Landing Screen States + +```mermaid +stateDiagram-v2 + [*] --> TokenGate + + TokenGate --> InvalidToken: token missing + TokenGate --> Loading: token present + Loading --> InviteUnavailable: invite fetch failed or invite not returned + Loading --> CheckingAccess: signed-in session and invite.companyId + Loading --> InviteResolved: invite loaded without membership check + Loading --> AcceptedInviteSummary: invite already consumed
but linked join request still exists + + CheckingAccess --> RedirectToBoard: current user already belongs to company + CheckingAccess --> InviteResolved: membership check finished and no join-request summary state is active + CheckingAccess --> AcceptedInviteSummary: membership check finished and invite has joinRequestStatus + + state InviteResolved { + [*] --> Branch + Branch --> AgentForm: company_join + allowedJoinTypes=agent + Branch --> InlineAuth: authenticated mode + no session + join is not agent-only + Branch --> AcceptReady: bootstrap invite or human-ready session/local_trusted + + InlineAuth --> InlineAuth: toggle sign-up/sign-in + InlineAuth --> InlineAuth: auth validation or auth error message + InlineAuth --> RedirectToBoard: auth succeeded and company membership already exists + InlineAuth --> AcceptPending: auth succeeded and invite still needs acceptance + + AgentForm --> AcceptPending: submit request + AgentForm --> AgentForm: validation or accept error + + AcceptReady --> AcceptPending: Accept invite + AcceptReady --> AcceptReady: accept error + } + + AcceptPending --> BootstrapComplete: bootstrapAccepted=true + AcceptPending --> RedirectToBoard: join status=approved + AcceptPending --> PendingApprovalResult: join status=pending_approval + AcceptPending --> RejectedResult: join status=rejected + + state AcceptedInviteSummary { + [*] --> SummaryBranch + SummaryBranch --> PendingApprovalReload: joinRequestStatus=pending_approval + SummaryBranch --> OpeningCompany: joinRequestStatus=approved
and human invite user is now a member + SummaryBranch --> RejectedReload: joinRequestStatus=rejected + SummaryBranch --> ConsumedReload: approved agent invite or other consumed state + } + + PendingApprovalResult --> PendingApprovalReload: reload after submit + RejectedResult --> RejectedReload: reload after board rejects + RedirectToBoard --> OpeningCompany: brief pre-navigation render when approved membership is detected + OpeningCompany --> RedirectToBoard: navigate to board +``` + +## Sequence Diagrams + +### Human Invite Creation And First Acceptance + +```mermaid +sequenceDiagram + autonumber + actor Board as Board user + participant Settings as Company Invites UI + participant API as Access routes + participant Invites as invites table + actor Invitee as Invite recipient + participant Landing as Invite landing UI + participant Auth as Auth session + participant Join as join_requests table + + Board->>Settings: Choose role and click Create invite + Settings->>API: POST /api/companies/:companyId/invites + API->>Invites: Insert active invite + API-->>Settings: inviteUrl + metadata + + Invitee->>Landing: Open invite URL + Landing->>API: GET /api/invites/:token + API->>Invites: Load active invite + API-->>Landing: Invite summary + + alt Authenticated mode and no session + Landing->>Auth: Sign up or sign in + Auth-->>Landing: Session established + end + + Landing->>API: POST /api/invites/:token/accept (requestType=human) + API->>Join: Look for reusable human join request + alt Reusable pending or approved request exists + API->>Invites: Mark invite accepted + API-->>Landing: Existing join request status + else No reusable request exists + API->>Invites: Mark invite accepted + API->>Join: Insert pending_approval join request + API-->>Landing: New pending_approval join request + end +``` + +### Human Approval And Reload Path + +```mermaid +sequenceDiagram + autonumber + actor Invitee as Invite recipient + participant Landing as Invite landing UI + participant API as Access routes + participant Join as join_requests table + actor Approver as Company admin + participant Queue as Access queue UI + participant Membership as company_memberships + grants + + Invitee->>Landing: Reload consumed invite URL + Landing->>API: GET /api/invites/:token + API->>Join: Load join request by inviteId + API-->>Landing: joinRequestStatus + joinRequestType + + alt joinRequestStatus = pending_approval + Landing-->>Invitee: Show waiting-for-approval panel + Approver->>Queue: Review request in Company Settings -> Access + Queue->>API: POST /companies/:companyId/join-requests/:requestId/approve + API->>Membership: Ensure membership and grants + API->>Join: Mark join request approved + Invitee->>Landing: Refresh after approval + Landing->>API: GET /api/invites/:token + API->>Join: Reload approved join request + API-->>Landing: approved status + Landing-->>Invitee: Opening company and redirect + else joinRequestStatus = rejected + Landing-->>Invitee: Show rejected error panel + else joinRequestStatus = approved but membership missing + Landing-->>Invitee: Fall through to consumed/unavailable state + end +``` + +### Agent Invite Approval, Claim, And Replay + +```mermaid +sequenceDiagram + autonumber + actor Board as Board user + participant Settings as Company Settings UI + participant API as Access routes + participant Invites as invites table + actor Gateway as OpenClaw gateway agent + participant Join as join_requests table + actor Approver as Company admin + participant Agents as agents table + participant Keys as agent_api_keys table + + Board->>Settings: Generate OpenClaw invite prompt + Settings->>API: POST /api/companies/:companyId/openclaw-invite-prompt + API->>Invites: Insert active agent invite + API-->>Settings: Prompt text + invite token + + Gateway->>API: POST /api/invites/:token/accept (agent, openclaw_gateway) + API->>Invites: Mark invite accepted + API->>Join: Insert pending_approval join request + claimSecretHash + API-->>Gateway: requestId + claimSecret + claimApiKeyPath + + Approver->>API: POST /companies/:companyId/join-requests/:requestId/approve + API->>Agents: Create agent + membership + grants + API->>Join: Mark request approved and store createdAgentId + + Gateway->>API: POST /api/join-requests/:requestId/claim-api-key (claimSecret) + API->>Keys: Create initial API key + API->>Join: Mark claim secret consumed + API-->>Gateway: Plaintext Paperclip API key + + opt Replay accepted invite for updated gateway defaults + Gateway->>API: POST /api/invites/:token/accept again + API->>Join: Reuse existing approved or pending request + API->>Agents: Update approved agent adapter config when applicable + API-->>Gateway: Updated join request payload + end +``` + +## Notes + +- `GET /api/invites/:token` treats `revoked` and `expired` invites as unavailable. Accepted invites remain resolvable when they already have a linked join request, and the summary now includes `joinRequestStatus` plus `joinRequestType`. +- Human acceptance consumes the invite immediately and then either creates a new join request or reuses an existing `pending_approval` or `approved` human join request for the same user/email. +- The landing page has two layers of post-accept UI: + - immediate mutation-result UI from `POST /api/invites/:token/accept` + - reload-time summary UI from `GET /api/invites/:token` once the invite has already been consumed +- Reload behavior for accepted company invites is now status-sensitive: + - `pending_approval` re-renders the waiting-for-approval panel + - `rejected` renders the "This join request was not approved." error panel + - `approved` only becomes a success path for human invites after membership is visible to the current session; otherwise the page falls through to the generic consumed/unavailable state +- `GET /api/invites/:token/logo` still rejects accepted invites, so accepted-invite reload states may fall back to the generated company icon even though the summary payload still carries `companyLogoUrl`. +- The only accepted-invite replay path in the current implementation is `POST /api/invites/:token/accept` for `agent` requests with `adapterType=openclaw_gateway`, and only when the existing join request is still `pending_approval` or already `approved`. +- `bootstrap_ceo` invites are one-time and do not create join requests. diff --git a/docs/adapters/creating-an-adapter.md b/docs/adapters/creating-an-adapter.md index ae5e4ccbe53..c81274f7559 100644 --- a/docs/adapters/creating-an-adapter.md +++ b/docs/adapters/creating-an-adapter.md @@ -203,6 +203,43 @@ export const sessionCodec: AdapterSessionCodec = { }; ``` +## Capability Flags + +Adapters can declare what "local" capabilities they support by setting optional fields on the `ServerAdapterModule`. The server and UI use these flags to decide which features to enable for agents using the adapter (instructions bundle editor, skills sync, JWT auth, etc.). + +| Flag | Type | Default | What it controls | +|------|------|---------|------------------| +| `supportsLocalAgentJwt` | `boolean` | `false` | Whether heartbeat generates a local JWT for the agent | +| `supportsInstructionsBundle` | `boolean` | `false` | Managed instructions bundle (AGENTS.md) — server-side resolution + UI editor | +| `instructionsPathKey` | `string` | `"instructionsFilePath"` | The `adapterConfig` key that holds the instructions file path | +| `requiresMaterializedRuntimeSkills` | `boolean` | `false` | Whether runtime skill entries must be written to disk before execution | + +These flags are exposed via `GET /api/adapters` in a `capabilities` object, along with a derived `supportsSkills` flag (true when `listSkills` or `syncSkills` is defined). + +### Example + +```ts +export function createServerAdapter(): ServerAdapterModule { + return { + type: "my_k8s_adapter", + execute: myExecute, + testEnvironment: myTestEnvironment, + listSkills: myListSkills, + syncSkills: mySyncSkills, + + // Capability flags + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, + }; +} +``` + +With these flags set, the Paperclip UI will automatically show the instructions bundle editor, skills management tab, and working directory field for agents using this adapter — no Paperclip source changes required. + +If capability flags are not set, the server falls back to legacy hardcoded lists for built-in adapter types. External adapters that omit the flags will default to `false` for all capabilities. + ## Skills Injection Make Paperclip skills discoverable to your agent runtime without writing to the agent's working directory: diff --git a/docs/agents-runtime.md b/docs/agents-runtime.md index cafc39e9753..81bbda7ca15 100644 --- a/docs/agents-runtime.md +++ b/docs/agents-runtime.md @@ -124,14 +124,14 @@ If the connection drops, the UI reconnects automatically. 1. Enable timer wakeups (for example every 300s) 2. Keep assignment wakeups on -3. Use a focused prompt template +3. Use a focused prompt template that tells agents to act in the same heartbeat, leave durable progress, and mark blocked work with an owner/action 4. Watch run logs and adjust prompt/config over time ## 7.2 Event-driven loop (less constant polling) 1. Disable timer or set a long interval 2. Keep wake-on-assignment enabled -3. Use on-demand wakeups for manual nudges +3. Use child issues, comments, and on-demand wakeups for handoffs instead of loops that poll agents, sessions, or processes ## 7.3 Safety-first loop diff --git a/docs/api/agents.md b/docs/api/agents.md index 143cbc5f476..70e779a9b26 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -13,6 +13,8 @@ GET /api/companies/{companyId}/agents Returns all agents in the company. +This route does not accept query filters. Unsupported query parameters return `400`. + ## Get Agent ``` diff --git a/docs/api/issues.md b/docs/api/issues.md index 09738f07c31..c95724c44d6 100644 --- a/docs/api/issues.md +++ b/docs/api/issues.md @@ -66,6 +66,8 @@ The optional `comment` field adds a comment in the same call. Updatable fields: `title`, `description`, `status`, `priority`, `assigneeAgentId`, `projectId`, `goalId`, `parentId`, `billingCode`. +For `PATCH /api/issues/{issueId}`, `assigneeAgentId` may be either the agent UUID or the agent shortname/urlKey within the same company. + ## Checkout (Claim Task) ``` diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index 5ebef728266..b5b6486dfb7 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -18,6 +18,7 @@ All environment variables that Paperclip uses for server configuration. | `PAPERCLIP_INSTANCE_ID` | `default` | Instance identifier (for multiple local instances) | | `PAPERCLIP_DEPLOYMENT_MODE` | `local_trusted` | Runtime mode override | | `PAPERCLIP_DEPLOYMENT_EXPOSURE` | `private` | Exposure policy when deployment mode is `authenticated` | +| `PAPERCLIP_API_URL` | (auto-derived) | Paperclip API base URL. When set externally (e.g., via Kubernetes ConfigMap, load balancer, or reverse proxy), the server preserves the value instead of deriving it from the listen host and port. Useful for deployments where the public-facing URL differs from the local bind address. | ## Secrets @@ -35,7 +36,7 @@ These are set automatically by the server when invoking agents: |----------|-------------| | `PAPERCLIP_AGENT_ID` | Agent's unique ID | | `PAPERCLIP_COMPANY_ID` | Company ID | -| `PAPERCLIP_API_URL` | Paperclip API base URL | +| `PAPERCLIP_API_URL` | Paperclip API base URL (inherits the server-level value; see Server Configuration above) | | `PAPERCLIP_API_KEY` | Short-lived JWT for API auth | | `PAPERCLIP_RUN_ID` | Current heartbeat run ID | | `PAPERCLIP_TASK_ID` | Issue that triggered this wake | diff --git a/docs/docs.json b/docs/docs.json index be48cc8e9e3..54dfb003eb6 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -48,6 +48,8 @@ "guides/board-operator/managing-tasks", "guides/board-operator/execution-workspaces-and-runtime-services", "guides/board-operator/delegation", + "guides/board-operator/execution-workspaces-and-runtime-services", + "guides/board-operator/delegation", "guides/board-operator/approvals", "guides/board-operator/costs-and-budgets", "guides/board-operator/activity-log", diff --git a/docs/guides/agent-developer/heartbeat-protocol.md b/docs/guides/agent-developer/heartbeat-protocol.md index 7c24715eb56..b846d7ab973 100644 --- a/docs/guides/agent-developer/heartbeat-protocol.md +++ b/docs/guides/agent-developer/heartbeat-protocol.md @@ -66,7 +66,9 @@ Read ancestors to understand why this task exists. If woken by a specific commen ### Step 7: Do the Work -Use your tools and capabilities to complete the task. +Use your tools and capabilities to complete the task. If the issue is actionable, take a concrete action in the same heartbeat. Do not stop at a plan unless the issue asked for planning. + +Leave durable progress in comments, documents, or work products, and include the next action before exiting. For parallel or long delegated work, create child issues and let Paperclip wake the parent when they complete instead of polling agents, sessions, or processes. ### Step 8: Update Status @@ -102,6 +104,22 @@ Always set `parentId` and `goalId` on subtasks. - **Always checkout** before working — never PATCH to `in_progress` manually - **Never retry a 409** — the task belongs to someone else - **Always comment** on in-progress work before exiting a heartbeat +- **Start actionable work** in the same heartbeat; planning-only exits are for planning tasks +- **Leave a clear next action** in durable issue context +- **Use child issues instead of polling** for long or parallel delegated work - **Always set parentId** on subtasks - **Never cancel cross-team tasks** — reassign to your manager - **Escalate when stuck** — use your chain of command + +## Run Liveness + +Paperclip records run liveness as metadata on heartbeat runs. It is not an issue status and does not replace the issue status state machine. + +- Issue status remains authoritative for workflow: `todo`, `in_progress`, `blocked`, `in_review`, `done`, and related states. +- Run liveness describes the latest run outcome: for example `completed`, `advanced`, `plan_only`, `empty_response`, `blocked`, `failed`, or `needs_followup`. +- Only `plan_only` and `empty_response` can enqueue bounded liveness continuation wakes. +- Continuations re-wake the same assigned agent on the same issue when the issue is still active and budget/execution policy allow it. +- `continuationAttempt` counts semantic liveness continuations for a source run chain. It is separate from process recovery, queued wake delivery, adapter session resume, and other operational retries. +- Liveness continuation wake prompts include the attempt, source run, liveness state, liveness reason, and the instruction for the next heartbeat. +- Continuations do not mark the issue `blocked` or `done`. If automatic continuations are exhausted, Paperclip leaves an audit comment so a human or manager can clarify, block, or assign follow-up work. +- Workspace provisioning alone is not treated as concrete task progress. Durable progress should appear as tool/action events, issue comments, document or work-product revisions, activity log entries, commits, or tests. diff --git a/docs/guides/board-operator/execution-workspaces-and-runtime-services.md b/docs/guides/board-operator/execution-workspaces-and-runtime-services.md index 285d701a489..cd8a31c8cb1 100644 --- a/docs/guides/board-operator/execution-workspaces-and-runtime-services.md +++ b/docs/guides/board-operator/execution-workspaces-and-runtime-services.md @@ -5,22 +5,28 @@ summary: How project runtime configuration, execution workspaces, and issue runs This guide documents the intended runtime model for projects, execution workspaces, and issue runs in Paperclip. +Paperclip now presents this as a workspace-command model: + +- `Services` are long-running commands that stay supervised. +- `Jobs` are one-shot commands that run once and exit. +- Raw runtime JSON is still available for advanced config, but it is no longer the primary mental model. + ## Project runtime configuration You can define how to run a project on the project workspace itself. -- Project workspace runtime config describes how to run services for that project checkout. +- Project workspace runtime config describes the services and jobs available for that project checkout. - This is the default runtime configuration that child execution workspaces may inherit. - Defining the config does not start anything by itself. ## Manual runtime control -Runtime services are manually controlled from the UI. +Workspace commands are manually controlled from the UI. -- Project workspace runtime services are started and stopped from the project workspace UI. -- Execution workspace runtime services are started and stopped from the execution workspace UI. -- Paperclip does not automatically start or stop these runtime services as part of issue execution. -- Paperclip also does not automatically restart workspace runtime services on server boot. +- Project workspace services are started and stopped from the project workspace UI, and project jobs can be run on demand there. +- Execution workspace services are started and stopped from the execution workspace UI, and execution-workspace jobs can be run on demand there. +- Paperclip does not automatically start or stop these workspace services as part of issue execution. +- Paperclip also does not automatically restart workspace services on server boot. ## Execution workspace inheritance @@ -29,7 +35,7 @@ Execution workspaces isolate code and runtime state from the project primary wor - An isolated execution workspace has its own checkout path, branch, and local runtime instance. - The runtime configuration may inherit from the linked project workspace by default. - The execution workspace may override that runtime configuration with its own workspace-specific settings. -- The inherited configuration answers "how to run the service", but the running process is still specific to that execution workspace. +- The inherited configuration answers "which commands exist and how to run them", but any running service process is still specific to that execution workspace. ## Issues and execution workspaces @@ -38,7 +44,7 @@ Issues are attached to execution workspace behavior, not to automatic runtime ma - An issue may create a new execution workspace when you choose an isolated workspace mode. - An issue may reuse an existing execution workspace when you choose reuse. - Multiple issues may intentionally share one execution workspace so they can work against the same branch and running runtime services. -- Assigning or running an issue does not automatically start or stop runtime services for that workspace. +- Assigning or running an issue does not automatically start or stop workspace services for that workspace. ## Execution workspace lifecycle @@ -62,7 +68,7 @@ Heartbeat still resolves a workspace for the run, but that is about code locatio With the current implementation: -- Project workspace runtime config is the fallback for execution workspace UI controls. +- Project workspace command config is the fallback for execution workspace UI controls. - Execution workspace runtime overrides are stored on the execution workspace. -- Heartbeat runs do not auto-start workspace runtime services. -- Server startup does not auto-restart workspace runtime services. +- Heartbeat runs do not auto-start workspace services. +- Server startup does not auto-restart workspace services. diff --git a/evals/promptfoo/prompts/heartbeat-system.txt b/evals/promptfoo/prompts/heartbeat-system.txt index 22518b47451..607b4a007ed 100644 --- a/evals/promptfoo/prompts/heartbeat-system.txt +++ b/evals/promptfoo/prompts/heartbeat-system.txt @@ -20,6 +20,13 @@ The Heartbeat Procedure: 8. Update status: PATCH /api/issues/{issueId} with status and comment 9. Delegate if needed: POST /api/companies/{companyId}/issues +Execution Contract: +- If the issue is actionable, start concrete work in this heartbeat. Do not stop at a plan unless the issue asks for planning. +- Leave durable progress in comments, documents, or work products, with a clear next action. +- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes. +- If blocked, PATCH the issue to blocked and name the unblock owner and action. +- Respect budget, pause/cancel, approval gates, and company boundaries. + Critical Rules: - Always checkout before working. Never PATCH to in_progress manually. - Never retry a 409. The task belongs to someone else. diff --git a/package.json b/package.json index 9ab8f60c87c..ce666bfaf38 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "dev:ui": "pnpm --filter @paperclipai/ui dev", "build": "pnpm run preflight:workspace-links && pnpm -r build", "typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck", - "test": "pnpm run preflight:workspace-links && vitest", + "test": "pnpm run test:run", + "test:watch": "pnpm run preflight:workspace-links && vitest", "test:run": "pnpm run preflight:workspace-links && vitest run", "db:generate": "pnpm --filter @paperclipai/db generate", "db:migrate": "pnpm --filter @paperclipai/db migrate", @@ -33,6 +34,7 @@ "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh", "test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts", "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed", + "test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts", "evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval", "test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts", "test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed", diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index bcdc4ba09e0..f49dfd45928 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -1,6 +1,12 @@ import { randomUUID } from "node:crypto"; import { describe, expect, it } from "vitest"; -import { runChildProcess } from "./server-utils.js"; +import { + appendWithByteCap, + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + renderPaperclipWakePrompt, + runChildProcess, + stringifyPaperclipWakePayload, +} from "./server-utils.js"; function isPidAlive(pid: number) { try { @@ -21,6 +27,25 @@ async function waitForPidExit(pid: number, timeoutMs = 2_000) { } describe("runChildProcess", () => { + it("does not arm a timeout when timeoutSec is 0", async () => { + const result = await runChildProcess( + randomUUID(), + process.execPath, + ["-e", "setTimeout(() => process.stdout.write('done'), 150);"], + { + cwd: process.cwd(), + env: {}, + timeoutSec: 0, + graceSec: 1, + onLog: async () => {}, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.timedOut).toBe(false); + expect(result.stdout).toBe("done"); + }); + it("waits for onSpawn before sending stdin to the child", async () => { const spawnDelayMs = 150; const startedAt = Date.now(); @@ -86,3 +111,118 @@ describe("runChildProcess", () => { expect(await waitForPidExit(descendantPid!, 2_000)).toBe(true); }); }); + +describe("appendWithByteCap", () => { + it("keeps valid UTF-8 when trimming through multibyte text", () => { + const output = appendWithByteCap("prefix ", "hello — world", 7); + + expect(output).not.toContain("\uFFFD"); + expect(Buffer.from(output, "utf8").toString("utf8")).toBe(output); + expect(Buffer.byteLength(output, "utf8")).toBeLessThanOrEqual(7); + }); +}); + +describe("renderPaperclipWakePrompt", () => { + it("keeps the default local-agent prompt action-oriented", () => { + expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Start actionable work in this heartbeat"); + expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("do not stop at a plan"); + expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Use child issues"); + expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("instead of polling agents, sessions, or processes"); + expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain( + "Respect budget, pause/cancel, approval gates, and company boundaries", + ); + }); + + it("adds the execution contract to scoped wake prompts", () => { + const prompt = renderPaperclipWakePrompt({ + reason: "issue_assigned", + issue: { + id: "issue-1", + identifier: "PAP-1580", + title: "Update prompts", + status: "in_progress", + }, + commentWindow: { + requestedCount: 0, + includedCount: 0, + missingCount: 0, + }, + comments: [], + fallbackFetchNeeded: false, + }); + + expect(prompt).toContain("## Paperclip Wake Payload"); + expect(prompt).toContain("Execution contract: take concrete action in this heartbeat"); + expect(prompt).toContain("use child issues instead of polling"); + expect(prompt).toContain("mark blocked work with the unblock owner/action"); + }); + + it("includes continuation and child issue summaries in structured wake context", () => { + const payload = { + reason: "issue_children_completed", + issue: { + id: "parent-1", + identifier: "PAP-100", + title: "Integrate child work", + status: "in_progress", + priority: "medium", + }, + continuationSummary: { + key: "continuation-summary", + title: "Continuation Summary", + body: "# Continuation Summary\n\n## Next Action\n\n- Integrate child outputs.", + updatedAt: "2026-04-18T12:00:00.000Z", + }, + livenessContinuation: { + attempt: 2, + maxAttempts: 2, + sourceRunId: "run-1", + state: "plan_only", + reason: "Run described future work without concrete action evidence", + instruction: "Take the first concrete action now.", + }, + childIssueSummaries: [ + { + id: "child-1", + identifier: "PAP-101", + title: "Implement helper", + status: "done", + priority: "medium", + summary: "Added the helper route and tests.", + }, + ], + }; + + expect(JSON.parse(stringifyPaperclipWakePayload(payload) ?? "{}")).toMatchObject({ + continuationSummary: { + body: expect.stringContaining("Continuation Summary"), + }, + livenessContinuation: { + attempt: 2, + maxAttempts: 2, + sourceRunId: "run-1", + state: "plan_only", + instruction: "Take the first concrete action now.", + }, + childIssueSummaries: [ + { + identifier: "PAP-101", + summary: "Added the helper route and tests.", + }, + ], + }); + + const prompt = renderPaperclipWakePrompt(payload); + expect(prompt).toContain("Issue continuation summary:"); + expect(prompt).toContain("Integrate child outputs."); + expect(prompt).toContain("Run liveness continuation:"); + expect(prompt).toContain("- attempt: 2/2"); + expect(prompt).toContain("- source run: run-1"); + expect(prompt).toContain("- liveness state: plan_only"); + expect(prompt).toContain("- reason: Run described future work without concrete action evidence"); + expect(prompt).toContain("- instruction: Take the first concrete action now."); + expect(prompt).toContain("Direct child issue summaries:"); + expect(prompt).toContain("PAP-101 Implement helper (done)"); + expect(prompt).toContain("Added the helper route and tests."); + }); +}); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 3c0e0db7217..2eec1892eff 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -66,6 +66,17 @@ const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [ "../../../../../skills", ]; +export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [ + "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", + "", + "Execution contract:", + "- Start actionable work in this heartbeat; do not stop at a plan unless the issue asks for planning.", + "- Leave durable progress in comments, documents, or work products with a clear next action.", + "- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.", + "- If blocked, mark the issue blocked and name the unblock owner and action.", + "- Respect budget, pause/cancel, approval gates, and company boundaries.", +].join("\n"); + export interface PaperclipSkillEntry { key: string; runtimeName: string; @@ -180,6 +191,22 @@ export function appendWithCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYT return combined.length > cap ? combined.slice(combined.length - cap) : combined; } +export function appendWithByteCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYTES) { + const combined = prev + chunk; + const bytes = Buffer.byteLength(combined, "utf8"); + if (bytes <= cap) return combined; + + const buffer = Buffer.from(combined, "utf8"); + let start = Math.max(0, bytes - cap); + while (start < buffer.length && (buffer[start]! & 0xc0) === 0x80) start += 1; + return buffer.subarray(start).toString("utf8"); +} + +function resumeReadable(readable: { resume: () => unknown; destroyed?: boolean } | null | undefined) { + if (!readable || readable.destroyed) return; + readable.resume(); +} + export function resolvePathValue(obj: Record, dottedPath: string) { const parts = dottedPath.split("."); let cursor: unknown = obj; @@ -250,10 +277,41 @@ type PaperclipWakeComment = { authorId: string | null; }; +type PaperclipWakeContinuationSummary = { + key: string | null; + title: string | null; + body: string; + bodyTruncated: boolean; + updatedAt: string | null; +}; + +type PaperclipWakeLivenessContinuation = { + attempt: number | null; + maxAttempts: number | null; + sourceRunId: string | null; + state: string | null; + reason: string | null; + instruction: string | null; +}; + +type PaperclipWakeChildIssueSummary = { + id: string | null; + identifier: string | null; + title: string | null; + status: string | null; + priority: string | null; + summary: string | null; +}; + type PaperclipWakePayload = { reason: string | null; issue: PaperclipWakeIssue | null; + checkedOutByHarness: boolean; executionStage: PaperclipWakeExecutionStage | null; + continuationSummary: PaperclipWakeContinuationSummary | null; + livenessContinuation: PaperclipWakeLivenessContinuation | null; + childIssueSummaries: PaperclipWakeChildIssueSummary[]; + childIssueSummaryTruncated: boolean; commentIds: string[]; latestCommentId: string | null; comments: PaperclipWakeComment[]; @@ -297,6 +355,50 @@ function normalizePaperclipWakeComment(value: unknown): PaperclipWakeComment | n }; } +function normalizePaperclipWakeContinuationSummary(value: unknown): PaperclipWakeContinuationSummary | null { + const summary = parseObject(value); + const body = asString(summary.body, "").trim(); + if (!body) return null; + return { + key: asString(summary.key, "").trim() || null, + title: asString(summary.title, "").trim() || null, + body, + bodyTruncated: asBoolean(summary.bodyTruncated, false), + updatedAt: asString(summary.updatedAt, "").trim() || null, + }; +} + +function normalizePaperclipWakeLivenessContinuation(value: unknown): PaperclipWakeLivenessContinuation | null { + const continuation = parseObject(value); + const attempt = asNumber(continuation.attempt, 0); + const maxAttempts = asNumber(continuation.maxAttempts, 0); + const sourceRunId = asString(continuation.sourceRunId, "").trim() || null; + const state = asString(continuation.state, "").trim() || null; + const reason = asString(continuation.reason, "").trim() || null; + const instruction = asString(continuation.instruction, "").trim() || null; + if (!attempt && !maxAttempts && !sourceRunId && !state && !reason && !instruction) return null; + return { + attempt: attempt > 0 ? attempt : null, + maxAttempts: maxAttempts > 0 ? maxAttempts : null, + sourceRunId, + state, + reason, + instruction, + }; +} + +function normalizePaperclipWakeChildIssueSummary(value: unknown): PaperclipWakeChildIssueSummary | null { + const child = parseObject(value); + const id = asString(child.id, "").trim() || null; + const identifier = asString(child.identifier, "").trim() || null; + const title = asString(child.title, "").trim() || null; + const status = asString(child.status, "").trim() || null; + const priority = asString(child.priority, "").trim() || null; + const summary = asString(child.summary, "").trim() || null; + if (!id && !identifier && !title && !status && !summary) return null; + return { id, identifier, title, status, priority, summary }; +} + function normalizePaperclipWakeExecutionPrincipal(value: unknown): PaperclipWakeExecutionPrincipal | null { const principal = parseObject(value); const typeRaw = asString(principal.type, "").trim().toLowerCase(); @@ -355,15 +457,27 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl .map((entry) => entry.trim()) : []; const executionStage = normalizePaperclipWakeExecutionStage(payload.executionStage); + const continuationSummary = normalizePaperclipWakeContinuationSummary(payload.continuationSummary); + const livenessContinuation = normalizePaperclipWakeLivenessContinuation(payload.livenessContinuation); + const childIssueSummaries = Array.isArray(payload.childIssueSummaries) + ? payload.childIssueSummaries + .map((entry) => normalizePaperclipWakeChildIssueSummary(entry)) + .filter((entry): entry is PaperclipWakeChildIssueSummary => Boolean(entry)) + : []; - if (comments.length === 0 && commentIds.length === 0 && !executionStage && !normalizePaperclipWakeIssue(payload.issue)) { + if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && !executionStage && !continuationSummary && !livenessContinuation && !normalizePaperclipWakeIssue(payload.issue)) { return null; } return { reason: asString(payload.reason, "").trim() || null, issue: normalizePaperclipWakeIssue(payload.issue), + checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false), executionStage, + continuationSummary, + livenessContinuation, + childIssueSummaries, + childIssueSummaryTruncated: asBoolean(payload.childIssueSummaryTruncated, false), commentIds, latestCommentId: asString(payload.latestCommentId, "").trim() || null, comments, @@ -404,6 +518,8 @@ export function renderPaperclipWakePrompt( "Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.", "Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.", "", + "Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.", + "", `- reason: ${normalized.reason ?? "unknown"}`, `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, @@ -419,6 +535,8 @@ export function renderPaperclipWakePrompt( "Use this inline wake data first before refetching the issue thread.", "Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.", "", + "Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.", + "", `- reason: ${normalized.reason ?? "unknown"}`, `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, @@ -432,6 +550,9 @@ export function renderPaperclipWakePrompt( if (normalized.issue?.priority) { lines.push(`- issue priority: ${normalized.issue.priority}`); } + if (normalized.checkedOutByHarness) { + lines.push("- checkout: already claimed by the harness for this run"); + } if (normalized.missingCount > 0) { lines.push(`- omitted comments: ${normalized.missingCount}`); } @@ -465,6 +586,64 @@ export function renderPaperclipWakePrompt( } } + if (normalized.continuationSummary) { + lines.push( + "", + "Issue continuation summary:", + normalized.continuationSummary.body, + ); + if (normalized.continuationSummary.bodyTruncated) { + lines.push("[continuation summary truncated]"); + } + } + + if (normalized.livenessContinuation) { + const continuation = normalized.livenessContinuation; + lines.push("", "Run liveness continuation:"); + if (continuation.attempt) { + lines.push( + `- attempt: ${continuation.attempt}${continuation.maxAttempts ? `/${continuation.maxAttempts}` : ""}`, + ); + } + if (continuation.sourceRunId) { + lines.push(`- source run: ${continuation.sourceRunId}`); + } + if (continuation.state) { + lines.push(`- liveness state: ${continuation.state}`); + } + if (continuation.reason) { + lines.push(`- reason: ${continuation.reason}`); + } + if (continuation.instruction) { + lines.push(`- instruction: ${continuation.instruction}`); + } + } + + if (normalized.childIssueSummaries.length > 0) { + lines.push("", "Direct child issue summaries:"); + for (const child of normalized.childIssueSummaries) { + const label = child.identifier ?? child.id ?? "unknown"; + lines.push( + `- ${label}${child.title ? ` ${child.title}` : ""}${child.status ? ` (${child.status})` : ""}`, + ); + if (child.summary) { + lines.push(` ${child.summary}`); + } + } + if (normalized.childIssueSummaryTruncated) { + lines.push("[child issue summaries truncated]"); + } + } + + if (normalized.checkedOutByHarness) { + lines.push( + "", + "The harness already checked out this issue for the current run.", + "Do not call `/api/issues/{id}/checkout` again unless you intentionally switch to a different task.", + "", + ); + } + if (normalized.comments.length > 0) { lines.push("New comments in order:"); } @@ -1120,19 +1299,27 @@ export async function runChildProcess( : null; child.stdout?.on("data", (chunk: unknown) => { + const readable = child.stdout; + if (!readable) return; + readable.pause(); const text = String(chunk); stdout = appendWithCap(stdout, text); logChain = logChain .then(() => opts.onLog("stdout", text)) - .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")); + .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")) + .finally(() => resumeReadable(readable)); }); child.stderr?.on("data", (chunk: unknown) => { + const readable = child.stderr; + if (!readable) return; + readable.pause(); const text = String(chunk); stderr = appendWithCap(stderr, text); logChain = logChain .then(() => opts.onLog("stderr", text)) - .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")); + .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")) + .finally(() => resumeReadable(readable)); }); const stdin = child.stdin; diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 534a5a59754..e02b9efe8de 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -328,6 +328,36 @@ export interface ServerAdapterModule { * resolved inside this method — the caller receives a fully hydrated schema. */ getConfigSchema?: () => Promise | AdapterConfigSchema; + + // --------------------------------------------------------------------------- + // Adapter capability flags + // + // These allow adapter plugins to declare what "local" capabilities they + // support, replacing hardcoded type lists in the server and UI. + // All flags are optional — when undefined, the server falls back to + // legacy hardcoded lists for built-in adapters. + // --------------------------------------------------------------------------- + + /** + * Adapter supports managed instructions bundle (AGENTS.md files). + * When true, the server uses instructionsPathKey (default "instructionsFilePath") + * to resolve the instructions config key, and the UI shows the bundle editor. + * Built-in local adapters default to true; external plugins must opt in. + */ + supportsInstructionsBundle?: boolean; + + /** + * The adapterConfig key that holds the instructions file path. + * Defaults to "instructionsFilePath" when supportsInstructionsBundle is true. + */ + instructionsPathKey?: string; + + /** + * Adapter needs runtime skill entries materialized (written to disk) + * before being passed via config. Used by adapters that scan a directory + * rather than reading config.paperclipRuntimeSkills. + */ + requiresMaterializedRuntimeSkills?: boolean; } // --------------------------------------------------------------------------- diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index b2f85732fb3..88e734eb3eb 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -2,6 +2,7 @@ export const type = "claude_local"; export const label = "Claude Code (local)"; export const models = [ + { id: "claude-opus-4-7", label: "Claude Opus 4.7" }, { id: "claude-opus-4-6", label: "Claude Opus 4.6" }, { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, { id: "claude-haiku-4-6", label: "Claude Haiku 4.6" }, diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index bc34f4e2f9c..8a6a18ed22a 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -21,6 +21,7 @@ import { renderTemplate, renderPaperclipWakePrompt, stringifyPaperclipWakePayload, + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { @@ -300,7 +301,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise --base-url "); + } + + const config = JSON.parse(readFileSync(path.resolve(configPath), "utf8")) as { + database?: { + mode?: string; + embeddedPostgresPort?: number; + connectionString?: string; + }; + }; + const dbUrl = + config.database?.mode === "postgres" + ? config.database.connectionString + : `postgres://paperclip:paperclip@127.0.0.1:${config.database?.embeddedPostgresPort ?? 54329}/paperclip`; + if (!dbUrl) { + throw new Error(`Could not resolve database connection from ${configPath}`); + } + + const db = createDb(dbUrl); + const closableDb = db as typeof db & { + $client?: { + end?: (options?: { timeout?: number }) => Promise; + }; + }; + + try { + const now = new Date(); + await db + .update(invites) + .set({ revokedAt: now, updatedAt: now }) + .where( + and( + eq(invites.inviteType, "bootstrap_ceo"), + isNull(invites.revokedAt), + isNull(invites.acceptedAt), + gt(invites.expiresAt, now) + ) + ); + + const token = createInviteToken(); + await db.insert(invites).values({ + inviteType: "bootstrap_ceo", + tokenHash: hashToken(token), + allowedJoinTypes: "human", + expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000), + invitedByUserId: "system", + }); + + process.stdout.write(`${baseUrl.replace(/\/+$/, "")}/invite/${token}\n`); + } finally { + await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined); + } +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts index 2ea9b07001c..6497cbe6af3 100644 --- a/packages/db/src/backup-lib.test.ts +++ b/packages/db/src/backup-lib.test.ts @@ -127,6 +127,7 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { backupDir, retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: "paperclip-test", + backupEngine: "javascript", }); expect(result.backupFile).toMatch(/paperclip-test-.*\.sql\.gz$/); @@ -148,14 +149,17 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { title: string; payload: string; state: string; - metadata: { index: number; even: boolean }; + metadata: { index: number; even: boolean } | string; }[]>(` SELECT "title", "payload", "state"::text AS "state", "metadata" FROM "public"."backup_test_records" WHERE "title" IN ('row-0', 'row-159') ORDER BY "title" `); - expect(sampleRows).toEqual([ + expect(sampleRows.map((row) => ({ + ...row, + metadata: typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata, + }))).toEqual([ { title: "row-0", payload, diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index e1e88724f8a..2662b6dc991 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -1,6 +1,8 @@ import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; import { basename, resolve } from "node:path"; import { createInterface } from "node:readline"; +import { spawn } from "node:child_process"; +import { open as openFile } from "node:fs/promises"; import { pipeline } from "node:stream/promises"; import { createGunzip, createGzip } from "node:zlib"; import postgres from "postgres"; @@ -20,6 +22,7 @@ export type RunDatabaseBackupOptions = { includeMigrationJournal?: boolean; excludeTables?: string[]; nullifyColumns?: Record; + backupEngine?: "auto" | "pg_dump" | "javascript"; }; export type RunDatabaseBackupResult = { @@ -61,6 +64,9 @@ type ExtensionDefinition = { const DRIZZLE_SCHEMA = "drizzle"; const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations"; const DEFAULT_BACKUP_WRITE_BUFFER_BYTES = 1024 * 1024; +const BACKUP_DATA_CURSOR_ROWS = 100; +const BACKUP_CLI_STDERR_BYTES = 64 * 1024; +const BACKUP_BREAKPOINT_DETECT_BYTES = 64 * 1024; const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900"; @@ -223,6 +229,134 @@ function tableKey(schemaName: string, tableName: string): string { return `${schemaName}.${tableName}`; } +function hasBackupTransforms(opts: RunDatabaseBackupOptions): boolean { + return opts.includeMigrationJournal === true || + (opts.excludeTables?.length ?? 0) > 0 || + Object.keys(opts.nullifyColumns ?? {}).length > 0; +} + +function formatSqlValue(rawValue: unknown, columnName: string | undefined, nullifiedColumns: Set): string { + const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue; + if (val === null || val === undefined) return "NULL"; + if (typeof val === "boolean") return val ? "true" : "false"; + if (typeof val === "number") return String(val); + if (val instanceof Date) return formatSqlLiteral(val.toISOString()); + if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val)); + return formatSqlLiteral(String(val)); +} + +function appendCapturedStderr(previous: string, chunk: Buffer | string): string { + const next = previous + (Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk); + if (Buffer.byteLength(next, "utf8") <= BACKUP_CLI_STDERR_BYTES) return next; + return Buffer.from(next, "utf8").subarray(-BACKUP_CLI_STDERR_BYTES).toString("utf8"); +} + +async function waitForChildExit(child: ReturnType, label: string): Promise { + let stderr = ""; + child.stderr?.on("data", (chunk) => { + stderr = appendCapturedStderr(stderr, chunk); + }); + + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + + if (result.signal) { + throw new Error(`${label} exited via ${result.signal}${stderr.trim() ? `: ${stderr.trim()}` : ""}`); + } + if (result.code !== 0) { + throw new Error(`${label} failed with exit code ${result.code ?? "unknown"}${stderr.trim() ? `: ${stderr.trim()}` : ""}`); + } +} + +async function runPgDumpBackup(opts: { + connectionString: string; + backupFile: string; + connectTimeout: number; +}): Promise { + const pgDumpBin = process.env.PAPERCLIP_PG_DUMP_PATH || "pg_dump"; + const child = spawn( + pgDumpBin, + [ + `--dbname=${opts.connectionString}`, + "--format=plain", + "--clean", + "--if-exists", + "--no-owner", + "--no-privileges", + "--schema=public", + ], + { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + PGCONNECT_TIMEOUT: String(opts.connectTimeout), + }, + }, + ); + + if (!child.stdout) { + throw new Error("pg_dump did not expose stdout"); + } + + await Promise.all([ + pipeline(child.stdout, createGzip(), createWriteStream(opts.backupFile)), + waitForChildExit(child, pgDumpBin), + ]); +} + +async function restoreWithPsql(opts: RunDatabaseRestoreOptions, connectTimeout: number): Promise { + const psqlBin = process.env.PAPERCLIP_PSQL_PATH || "psql"; + const child = spawn( + psqlBin, + [ + `--dbname=${opts.connectionString}`, + "--set=ON_ERROR_STOP=1", + "--quiet", + "--no-psqlrc", + ], + { + stdio: ["pipe", "ignore", "pipe"], + env: { + ...process.env, + PGCONNECT_TIMEOUT: String(connectTimeout), + }, + }, + ); + + if (!child.stdin) { + throw new Error("psql did not expose stdin"); + } + + const input = opts.backupFile.endsWith(".gz") + ? createReadStream(opts.backupFile).pipe(createGunzip()) + : createReadStream(opts.backupFile); + + await Promise.all([ + pipeline(input, child.stdin), + waitForChildExit(child, psqlBin), + ]); +} + +async function hasStatementBreakpoints(backupFile: string): Promise { + const raw = createReadStream(backupFile); + const stream = backupFile.endsWith(".gz") ? raw.pipe(createGunzip()) : raw; + let text = ""; + + try { + for await (const chunk of stream) { + text += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + if (text.includes(STATEMENT_BREAKPOINT)) return true; + if (Buffer.byteLength(text, "utf8") >= BACKUP_BREAKPOINT_DETECT_BYTES) return false; + } + return text.includes(STATEMENT_BREAKPOINT); + } finally { + stream.destroy(); + raw.destroy(); + } +} + async function* readRestoreStatements(backupFile: string): AsyncGenerator { const raw = createReadStream(backupFile); const stream = backupFile.endsWith(".gz") ? raw.pipe(createGunzip()) : raw; @@ -263,41 +397,21 @@ async function* readRestoreStatements(backupFile: string): AsyncGenerator { - streamError = error; - }); - - const writeChunk = async (chunk: string): Promise => { - if (streamError) throw streamError; - const canContinue = stream.write(chunk); - if (!canContinue) { - await new Promise((resolve, reject) => { - const handleDrain = () => { - cleanup(); - resolve(); - }; - const handleError = (error: Error) => { - cleanup(); - reject(error); - }; - const cleanup = () => { - stream.off("drain", handleDrain); - stream.off("error", handleError); - }; - stream.once("drain", handleDrain); - stream.once("error", handleError); - }); + const writeChunk = async (chunk: string | Buffer): Promise => { + const file = await filePromise; + if (typeof chunk === "string") { + await file.write(chunk, null, "utf8"); + } else { + await file.write(chunk); } - if (streamError) throw streamError; }; const flushBufferedLines = () => { @@ -316,37 +430,43 @@ export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes if (closed) { throw new Error(`Cannot write to closed backup file: ${filePath}`); } - if (streamError) throw streamError; bufferedLines.push(line); bufferedBytes += Buffer.byteLength(line, "utf8") + 1; if (bufferedBytes >= flushThreshold) { flushBufferedLines(); } }, + async drain() { + if (closed) { + throw new Error(`Cannot drain closed backup file: ${filePath}`); + } + flushBufferedLines(); + await pendingWrite; + }, + async writeRaw(chunk: string | Buffer) { + if (closed) { + throw new Error(`Cannot write to closed backup file: ${filePath}`); + } + flushBufferedLines(); + firstChunk = false; + pendingWrite = pendingWrite.then(() => writeChunk(chunk)); + await pendingWrite; + }, async close() { if (closed) return; closed = true; flushBufferedLines(); await pendingWrite; - await new Promise((resolve, reject) => { - if (streamError) { - reject(streamError); - return; - } - stream.end((error?: Error | null) => { - if (error) reject(error); - else resolve(); - }); - }); - if (streamError) throw streamError; + const file = await filePromise; + await file.close(); }, async abort() { if (closed) return; closed = true; bufferedLines = []; bufferedBytes = 0; - stream.destroy(); await pendingWrite.catch(() => {}); + await filePromise.then((file) => file.close()).catch(() => {}); if (existsSync(filePath)) { try { unlinkSync(filePath); @@ -362,16 +482,53 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise const filenamePrefix = opts.filenamePrefix ?? "paperclip"; const retention = opts.retention; const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); + const backupEngine = opts.backupEngine ?? "auto"; + const canUsePgDump = !hasBackupTransforms(opts); const includeMigrationJournal = opts.includeMigrationJournal === true; const excludedTableNames = normalizeTableNameSet(opts.excludeTables); const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns); - const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); + let sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); + let sqlClosed = false; + const closeSql = async () => { + if (sqlClosed) return; + sqlClosed = true; + await sql.end(); + }; mkdirSync(opts.backupDir, { recursive: true }); const sqlFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`); const backupFile = `${sqlFile}.gz`; const writer = createBufferedTextFileWriter(sqlFile); try { + if (backupEngine === "pg_dump" || (backupEngine === "auto" && canUsePgDump)) { + await sql`SELECT 1`; + try { + await closeSql(); + await runPgDumpBackup({ + connectionString: opts.connectionString, + backupFile, + connectTimeout, + }); + await writer.abort(); + const sizeBytes = statSync(backupFile).size; + const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix); + return { + backupFile, + sizeBytes, + prunedCount, + }; + } catch (error) { + if (existsSync(backupFile)) { + try { unlinkSync(backupFile); } catch { /* ignore */ } + } + if (backupEngine === "pg_dump") { + throw error; + } + sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); + sqlClosed = false; + } + } + await sql`SELECT 1`; const emit = (line: string) => writer.emit(line); @@ -703,20 +860,39 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise emit(`-- Data for: ${schema_name}.${tablename} (${count[0]!.n} rows)`); - const rows = await sql.unsafe(`SELECT * FROM ${qualifiedTableName}`).values(); const nullifiedColumns = nullifiedColumnsByTable.get(tablename) ?? new Set(); - for (const row of rows) { - const values = row.map((rawValue: unknown, index) => { - const columnName = cols[index]?.column_name; - const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue; - if (val === null || val === undefined) return "NULL"; - if (typeof val === "boolean") return val ? "true" : "false"; - if (typeof val === "number") return String(val); - if (val instanceof Date) return formatSqlLiteral(val.toISOString()); - if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val)); - return formatSqlLiteral(String(val)); - }); - emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`); + if (backupEngine !== "javascript" && nullifiedColumns.size === 0) { + emit(`COPY ${qualifiedTableName} (${colNames}) FROM stdin;`); + await writer.writeRaw("\n"); + const copySql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); + try { + const copyStream = await copySql + .unsafe(`COPY ${qualifiedTableName} (${colNames}) TO STDOUT`) + .readable(); + for await (const chunk of copyStream) { + await writer.writeRaw(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + } + } finally { + await copySql.end(); + } + await writer.writeRaw("\\.\n"); + emitStatementBoundary(); + emit(""); + continue; + } + + const rowCursor = sql + .unsafe(`SELECT * FROM ${qualifiedTableName}`) + .values() + .cursor(BACKUP_DATA_CURSOR_ROWS) as AsyncIterable; + for await (const rows of rowCursor) { + for (const row of rows) { + const values = row.map((rawValue, index) => + formatSqlValue(rawValue, cols[index]?.column_name, nullifiedColumns), + ); + emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`); + } + await writer.drain(); } emit(""); } @@ -768,12 +944,23 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise } throw error; } finally { - await sql.end(); + await closeSql(); } } export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promise { const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); + try { + await restoreWithPsql(opts, connectTimeout); + return; + } catch (error) { + if (!(await hasStatementBreakpoints(opts.backupFile))) { + throw new Error( + `Failed to restore ${basename(opts.backupFile)} with psql: ${sanitizeRestoreErrorMessage(error)}`, + ); + } + } + const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); try { diff --git a/packages/db/src/migrations/0056_oval_gauntlet.sql b/packages/db/src/migrations/0056_oval_gauntlet.sql deleted file mode 100644 index cd88d6d4bb3..00000000000 --- a/packages/db/src/migrations/0056_oval_gauntlet.sql +++ /dev/null @@ -1,151 +0,0 @@ -CREATE TABLE "memory_binding_targets" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "company_id" uuid NOT NULL, - "binding_id" uuid NOT NULL, - "target_type" text NOT NULL, - "target_id" uuid NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "memory_bindings" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "company_id" uuid NOT NULL, - "key" text NOT NULL, - "name" text, - "provider_key" text NOT NULL, - "config" jsonb DEFAULT '{}'::jsonb NOT NULL, - "enabled" boolean DEFAULT true NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "memory_extraction_jobs" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "company_id" uuid NOT NULL, - "binding_id" uuid NOT NULL, - "provider_key" text NOT NULL, - "operation_id" uuid, - "status" text DEFAULT 'queued' NOT NULL, - "provider_job_id" text, - "source_kind" text, - "source_issue_id" uuid, - "source_comment_id" uuid, - "source_document_key" text, - "source_run_id" uuid, - "source_activity_id" uuid, - "source_external_ref" text, - "result_json" jsonb, - "error" text, - "started_at" timestamp with time zone, - "completed_at" timestamp with time zone, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "memory_local_records" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "company_id" uuid NOT NULL, - "binding_id" uuid NOT NULL, - "provider_key" text NOT NULL, - "scope_agent_id" uuid, - "scope_project_id" uuid, - "scope_issue_id" uuid, - "scope_run_id" uuid, - "scope_subject_id" text, - "source_kind" text, - "source_issue_id" uuid, - "source_comment_id" uuid, - "source_document_key" text, - "source_run_id" uuid, - "source_activity_id" uuid, - "source_external_ref" text, - "title" text, - "content" text NOT NULL, - "summary" text, - "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, - "created_by_operation_id" uuid, - "deleted_at" timestamp with time zone, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "memory_operations" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "company_id" uuid NOT NULL, - "binding_id" uuid NOT NULL, - "provider_key" text NOT NULL, - "operation_type" text NOT NULL, - "trigger_kind" text DEFAULT 'manual' NOT NULL, - "hook_kind" text, - "status" text DEFAULT 'succeeded' NOT NULL, - "actor_type" text NOT NULL, - "actor_id" text NOT NULL, - "agent_id" uuid, - "user_id" text, - "scope_agent_id" uuid, - "scope_project_id" uuid, - "scope_issue_id" uuid, - "scope_run_id" uuid, - "scope_subject_id" text, - "source_kind" text, - "source_issue_id" uuid, - "source_comment_id" uuid, - "source_document_key" text, - "source_run_id" uuid, - "source_activity_id" uuid, - "source_external_ref" text, - "query_text" text, - "record_count" integer DEFAULT 0 NOT NULL, - "request_json" jsonb, - "result_json" jsonb, - "usage_json" jsonb DEFAULT '[]'::jsonb NOT NULL, - "error" text, - "cost_event_id" uuid, - "finance_event_id" uuid, - "occurred_at" timestamp with time zone DEFAULT now() NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "memory_binding_targets" ADD CONSTRAINT "memory_binding_targets_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_binding_targets" ADD CONSTRAINT "memory_binding_targets_binding_id_memory_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."memory_bindings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_bindings" ADD CONSTRAINT "memory_bindings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_extraction_jobs" ADD CONSTRAINT "memory_extraction_jobs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_extraction_jobs" ADD CONSTRAINT "memory_extraction_jobs_binding_id_memory_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."memory_bindings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_extraction_jobs" ADD CONSTRAINT "memory_extraction_jobs_operation_id_memory_operations_id_fk" FOREIGN KEY ("operation_id") REFERENCES "public"."memory_operations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_binding_id_memory_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."memory_bindings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_scope_agent_id_agents_id_fk" FOREIGN KEY ("scope_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_scope_project_id_projects_id_fk" FOREIGN KEY ("scope_project_id") REFERENCES "public"."projects"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_scope_issue_id_issues_id_fk" FOREIGN KEY ("scope_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_scope_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("scope_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_source_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("source_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_created_by_operation_id_memory_operations_id_fk" FOREIGN KEY ("created_by_operation_id") REFERENCES "public"."memory_operations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_binding_id_memory_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."memory_bindings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_scope_agent_id_agents_id_fk" FOREIGN KEY ("scope_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_scope_project_id_projects_id_fk" FOREIGN KEY ("scope_project_id") REFERENCES "public"."projects"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_scope_issue_id_issues_id_fk" FOREIGN KEY ("scope_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_scope_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("scope_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_source_comment_id_issue_comments_id_fk" FOREIGN KEY ("source_comment_id") REFERENCES "public"."issue_comments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_source_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("source_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_source_activity_id_activity_log_id_fk" FOREIGN KEY ("source_activity_id") REFERENCES "public"."activity_log"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_cost_event_id_cost_events_id_fk" FOREIGN KEY ("cost_event_id") REFERENCES "public"."cost_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_finance_event_id_finance_events_id_fk" FOREIGN KEY ("finance_event_id") REFERENCES "public"."finance_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -CREATE UNIQUE INDEX "memory_binding_targets_company_target_idx" ON "memory_binding_targets" USING btree ("company_id","target_type","target_id");--> statement-breakpoint -CREATE INDEX "memory_binding_targets_company_binding_idx" ON "memory_binding_targets" USING btree ("company_id","binding_id");--> statement-breakpoint -CREATE UNIQUE INDEX "memory_bindings_company_key_idx" ON "memory_bindings" USING btree ("company_id","key");--> statement-breakpoint -CREATE INDEX "memory_bindings_company_provider_idx" ON "memory_bindings" USING btree ("company_id","provider_key");--> statement-breakpoint -CREATE INDEX "memory_extraction_jobs_company_status_created_idx" ON "memory_extraction_jobs" USING btree ("company_id","status","created_at");--> statement-breakpoint -CREATE INDEX "memory_extraction_jobs_company_binding_created_idx" ON "memory_extraction_jobs" USING btree ("company_id","binding_id","created_at");--> statement-breakpoint -CREATE INDEX "memory_local_records_company_binding_created_idx" ON "memory_local_records" USING btree ("company_id","binding_id","created_at");--> statement-breakpoint -CREATE INDEX "memory_local_records_company_agent_created_idx" ON "memory_local_records" USING btree ("company_id","scope_agent_id","created_at");--> statement-breakpoint -CREATE INDEX "memory_local_records_company_issue_created_idx" ON "memory_local_records" USING btree ("company_id","scope_issue_id","created_at");--> statement-breakpoint -CREATE INDEX "memory_local_records_fts_idx" ON "memory_local_records" USING gin (to_tsvector('english', coalesce("title", '') || ' ' || "content"));--> statement-breakpoint -CREATE INDEX "memory_operations_company_occurred_idx" ON "memory_operations" USING btree ("company_id","occurred_at");--> statement-breakpoint -CREATE INDEX "memory_operations_company_binding_occurred_idx" ON "memory_operations" USING btree ("company_id","binding_id","occurred_at");--> statement-breakpoint -CREATE INDEX "memory_operations_company_issue_occurred_idx" ON "memory_operations" USING btree ("company_id","scope_issue_id","occurred_at");--> statement-breakpoint -CREATE INDEX "memory_operations_company_run_occurred_idx" ON "memory_operations" USING btree ("company_id","scope_run_id","occurred_at"); diff --git a/packages/db/src/migrations/0056_spooky_ultragirl.sql b/packages/db/src/migrations/0056_spooky_ultragirl.sql new file mode 100644 index 00000000000..e8492176a62 --- /dev/null +++ b/packages/db/src/migrations/0056_spooky_ultragirl.sql @@ -0,0 +1,22 @@ +CREATE TABLE "company_user_sidebar_preferences" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "user_id" text NOT NULL, + "project_order" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_sidebar_preferences" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "company_order" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "company_user_sidebar_preferences" ADD CONSTRAINT "company_user_sidebar_preferences_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "company_user_sidebar_preferences_company_idx" ON "company_user_sidebar_preferences" USING btree ("company_id");--> statement-breakpoint +CREATE INDEX "company_user_sidebar_preferences_user_idx" ON "company_user_sidebar_preferences" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "company_user_sidebar_preferences_company_user_uq" ON "company_user_sidebar_preferences" USING btree ("company_id","user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "user_sidebar_preferences_user_uq" ON "user_sidebar_preferences" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/db/src/migrations/0057_misty_riptide.sql b/packages/db/src/migrations/0057_misty_riptide.sql deleted file mode 100644 index 6ddc50e6cb9..00000000000 --- a/packages/db/src/migrations/0057_misty_riptide.sql +++ /dev/null @@ -1,60 +0,0 @@ -ALTER TABLE "memory_local_records" ADD COLUMN "scope_type" text DEFAULT 'org' NOT NULL;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "scope_id" text;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "scope_workspace_id" text;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "scope_team_id" text;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "owner_type" text;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "owner_id" text;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "created_by_actor_type" text;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "created_by_actor_id" text;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "sensitivity_label" text DEFAULT 'internal' NOT NULL;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "retention_policy" jsonb;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "expires_at" timestamp with time zone;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "retention_state" text DEFAULT 'active' NOT NULL;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "citation_json" jsonb;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "supersedes_record_id" uuid;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "superseded_by_record_id" uuid;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "revoked_at" timestamp with time zone;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "revoked_by_actor_type" text;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "revoked_by_actor_id" text;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "revocation_reason" text;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD COLUMN "scope_type" text;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD COLUMN "scope_id" text;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD COLUMN "scope_workspace_id" text;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD COLUMN "scope_team_id" text;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD COLUMN "max_sensitivity_label" text;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD COLUMN "policy_decision_json" jsonb;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD COLUMN "revocation_selector_json" jsonb;--> statement-breakpoint -ALTER TABLE "memory_operations" ADD COLUMN "retention_action_json" jsonb;--> statement-breakpoint -UPDATE "memory_local_records" -SET - "scope_type" = CASE - WHEN "scope_run_id" IS NOT NULL THEN 'run' - WHEN "scope_agent_id" IS NOT NULL THEN 'agent' - WHEN "scope_project_id" IS NOT NULL THEN 'project' - ELSE 'org' - END, - "scope_id" = COALESCE("scope_run_id"::text, "scope_agent_id"::text, "scope_project_id"::text, "company_id"::text), - "retention_state" = CASE WHEN "deleted_at" IS NOT NULL THEN 'revoked' ELSE 'active' END, - "revoked_at" = CASE WHEN "deleted_at" IS NOT NULL THEN "deleted_at" ELSE NULL END, - "revocation_reason" = CASE WHEN "deleted_at" IS NOT NULL THEN 'Record was forgotten before governed memory migration' ELSE NULL END;--> statement-breakpoint -UPDATE "memory_local_records" AS "record" -SET - "owner_type" = "operation"."actor_type", - "owner_id" = "operation"."actor_id", - "created_by_actor_type" = "operation"."actor_type", - "created_by_actor_id" = "operation"."actor_id" -FROM "memory_operations" AS "operation" -WHERE "record"."created_by_operation_id" = "operation"."id";--> statement-breakpoint -UPDATE "memory_operations" -SET - "scope_type" = CASE - WHEN "scope_run_id" IS NOT NULL THEN 'run' - WHEN "scope_agent_id" IS NOT NULL THEN 'agent' - WHEN "scope_project_id" IS NOT NULL THEN 'project' - ELSE 'org' - END, - "scope_id" = COALESCE("scope_run_id"::text, "scope_agent_id"::text, "scope_project_id"::text, "company_id"::text);--> statement-breakpoint -CREATE INDEX "memory_local_records_company_scope_created_idx" ON "memory_local_records" USING btree ("company_id","scope_type","scope_id","created_at");--> statement-breakpoint -CREATE INDEX "memory_local_records_company_sensitivity_created_idx" ON "memory_local_records" USING btree ("company_id","sensitivity_label","created_at");--> statement-breakpoint -CREATE INDEX "memory_local_records_company_retention_created_idx" ON "memory_local_records" USING btree ("company_id","retention_state","expires_at","created_at");--> statement-breakpoint -CREATE INDEX "memory_operations_company_scope_occurred_idx" ON "memory_operations" USING btree ("company_id","scope_type","scope_id","occurred_at"); diff --git a/packages/db/src/migrations/0057_tidy_join_requests.sql b/packages/db/src/migrations/0057_tidy_join_requests.sql new file mode 100644 index 00000000000..8ab27b410be --- /dev/null +++ b/packages/db/src/migrations/0057_tidy_join_requests.sql @@ -0,0 +1,57 @@ +WITH ranked_user_requests AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY company_id, requesting_user_id + ORDER BY created_at ASC, id ASC + ) AS rank + FROM join_requests + WHERE request_type = 'human' + AND status = 'pending_approval' + AND requesting_user_id IS NOT NULL +) +UPDATE join_requests +SET + status = 'rejected', + rejected_at = COALESCE(rejected_at, now()), + updated_at = now() +WHERE id IN ( + SELECT id + FROM ranked_user_requests + WHERE rank > 1 +); +--> statement-breakpoint +WITH ranked_email_requests AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY company_id, lower(request_email_snapshot) + ORDER BY created_at ASC, id ASC + ) AS rank + FROM join_requests + WHERE request_type = 'human' + AND status = 'pending_approval' + AND request_email_snapshot IS NOT NULL +) +UPDATE join_requests +SET + status = 'rejected', + rejected_at = COALESCE(rejected_at, now()), + updated_at = now() +WHERE id IN ( + SELECT id + FROM ranked_email_requests + WHERE rank > 1 +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "join_requests_pending_human_user_uq" +ON "join_requests" USING btree ("company_id", "requesting_user_id") +WHERE "request_type" = 'human' + AND "status" = 'pending_approval' + AND "requesting_user_id" IS NOT NULL; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "join_requests_pending_human_email_uq" +ON "join_requests" USING btree ("company_id", lower("request_email_snapshot")) +WHERE "request_type" = 'human' + AND "status" = 'pending_approval' + AND "request_email_snapshot" IS NOT NULL; diff --git a/packages/db/src/migrations/0058_lovely_eternals.sql b/packages/db/src/migrations/0058_lovely_eternals.sql deleted file mode 100644 index e26d22737bf..00000000000 --- a/packages/db/src/migrations/0058_lovely_eternals.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE "memory_local_records" ADD COLUMN "review_state" text DEFAULT 'pending' NOT NULL;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "reviewed_at" timestamp with time zone;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "reviewed_by_actor_type" text;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "reviewed_by_actor_id" text;--> statement-breakpoint -ALTER TABLE "memory_local_records" ADD COLUMN "review_note" text;--> statement-breakpoint -CREATE INDEX "memory_local_records_company_review_created_idx" ON "memory_local_records" USING btree ("company_id","review_state","created_at"); \ No newline at end of file diff --git a/packages/db/src/migrations/0058_wealthy_starbolt.sql b/packages/db/src/migrations/0058_wealthy_starbolt.sql new file mode 100644 index 00000000000..360dcf81a3b --- /dev/null +++ b/packages/db/src/migrations/0058_wealthy_starbolt.sql @@ -0,0 +1,6 @@ +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "liveness_state" text;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "liveness_reason" text;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "continuation_attempt" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "last_useful_action_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "next_action" text;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "heartbeat_runs_company_liveness_idx" ON "heartbeat_runs" USING btree ("company_id","liveness_state","created_at"); diff --git a/packages/db/src/migrations/0059_foamy_jackpot.sql b/packages/db/src/migrations/0059_foamy_jackpot.sql new file mode 100644 index 00000000000..5601658cd77 --- /dev/null +++ b/packages/db/src/migrations/0059_foamy_jackpot.sql @@ -0,0 +1,486 @@ +CREATE TABLE IF NOT EXISTS "background_job_cost_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "run_id" uuid NOT NULL, + "cost_event_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "background_job_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "run_id" uuid NOT NULL, + "event_type" text NOT NULL, + "level" text DEFAULT 'info' NOT NULL, + "message" text, + "progress_percent" integer, + "total_items" integer, + "processed_items" integer, + "succeeded_items" integer, + "failed_items" integer, + "skipped_items" integer, + "current_item" text, + "details" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "background_job_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "job_id" uuid, + "job_key" text NOT NULL, + "job_type" text NOT NULL, + "trigger" text DEFAULT 'manual' NOT NULL, + "status" text DEFAULT 'queued' NOT NULL, + "requested_by_actor_type" text DEFAULT 'system' NOT NULL, + "requested_by_actor_id" text DEFAULT 'system' NOT NULL, + "requested_by_agent_id" uuid, + "requested_by_user_id" text, + "source_issue_id" uuid, + "source_project_id" uuid, + "source_agent_id" uuid, + "heartbeat_run_id" uuid, + "total_items" integer, + "processed_items" integer DEFAULT 0 NOT NULL, + "succeeded_items" integer DEFAULT 0 NOT NULL, + "failed_items" integer DEFAULT 0 NOT NULL, + "skipped_items" integer DEFAULT 0 NOT NULL, + "progress_percent" integer, + "current_item" text, + "cancellation_requested_at" timestamp with time zone, + "cancelled_at" timestamp with time zone, + "error" text, + "result" jsonb, + "started_at" timestamp with time zone, + "finished_at" timestamp with time zone, + "duration_ms" integer, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "background_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "key" text NOT NULL, + "job_type" text NOT NULL, + "display_name" text NOT NULL, + "description" text, + "backend_kind" text DEFAULT 'server_worker' NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "config" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "source_issue_id" uuid, + "source_project_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "memory_binding_targets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "binding_id" uuid NOT NULL, + "target_type" text NOT NULL, + "target_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "memory_bindings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "key" text NOT NULL, + "name" text, + "provider_key" text NOT NULL, + "config" jsonb DEFAULT '{}'::jsonb NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "memory_extraction_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "binding_id" uuid NOT NULL, + "provider_key" text NOT NULL, + "operation_id" uuid, + "status" text DEFAULT 'queued' NOT NULL, + "provider_job_id" text, + "source_kind" text, + "source_issue_id" uuid, + "source_comment_id" uuid, + "source_document_key" text, + "source_run_id" uuid, + "source_activity_id" uuid, + "source_external_ref" text, + "result_json" jsonb, + "error" text, + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "memory_local_records" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "binding_id" uuid NOT NULL, + "provider_key" text NOT NULL, + "scope_agent_id" uuid, + "scope_project_id" uuid, + "scope_issue_id" uuid, + "scope_run_id" uuid, + "scope_subject_id" text, + "scope_type" text DEFAULT 'org' NOT NULL, + "scope_id" text, + "scope_workspace_id" text, + "scope_team_id" text, + "source_kind" text, + "source_issue_id" uuid, + "source_comment_id" uuid, + "source_document_key" text, + "source_run_id" uuid, + "source_activity_id" uuid, + "source_external_ref" text, + "title" text, + "content" text NOT NULL, + "summary" text, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "owner_type" text, + "owner_id" text, + "created_by_actor_type" text, + "created_by_actor_id" text, + "sensitivity_label" text DEFAULT 'internal' NOT NULL, + "retention_policy" jsonb, + "expires_at" timestamp with time zone, + "retention_state" text DEFAULT 'active' NOT NULL, + "review_state" text DEFAULT 'pending' NOT NULL, + "reviewed_at" timestamp with time zone, + "reviewed_by_actor_type" text, + "reviewed_by_actor_id" text, + "review_note" text, + "citation_json" jsonb, + "supersedes_record_id" uuid, + "superseded_by_record_id" uuid, + "revoked_at" timestamp with time zone, + "revoked_by_actor_type" text, + "revoked_by_actor_id" text, + "revocation_reason" text, + "created_by_operation_id" uuid, + "deleted_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "memory_operations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "binding_id" uuid NOT NULL, + "provider_key" text NOT NULL, + "operation_type" text NOT NULL, + "trigger_kind" text DEFAULT 'manual' NOT NULL, + "hook_kind" text, + "status" text DEFAULT 'succeeded' NOT NULL, + "actor_type" text NOT NULL, + "actor_id" text NOT NULL, + "agent_id" uuid, + "user_id" text, + "scope_agent_id" uuid, + "scope_project_id" uuid, + "scope_issue_id" uuid, + "scope_run_id" uuid, + "scope_subject_id" text, + "scope_type" text, + "scope_id" text, + "scope_workspace_id" text, + "scope_team_id" text, + "max_sensitivity_label" text, + "source_kind" text, + "source_issue_id" uuid, + "source_comment_id" uuid, + "source_document_key" text, + "source_run_id" uuid, + "source_activity_id" uuid, + "source_external_ref" text, + "query_text" text, + "record_count" integer DEFAULT 0 NOT NULL, + "request_json" jsonb, + "result_json" jsonb, + "policy_decision_json" jsonb, + "revocation_selector_json" jsonb, + "retention_action_json" jsonb, + "usage_json" jsonb DEFAULT '[]'::jsonb NOT NULL, + "error" text, + "cost_event_id" uuid, + "finance_event_id" uuid, + "occurred_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +-- Compatibility for databases that ran the old branch-numbered memory migrations before this merge. +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "scope_type" text DEFAULT 'org' NOT NULL;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "scope_id" text;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "scope_workspace_id" text;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "scope_team_id" text;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "owner_type" text;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "owner_id" text;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "created_by_actor_type" text;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "created_by_actor_id" text;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "sensitivity_label" text DEFAULT 'internal' NOT NULL;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "retention_policy" jsonb;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "expires_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "retention_state" text DEFAULT 'active' NOT NULL;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "citation_json" jsonb;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "supersedes_record_id" uuid;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "superseded_by_record_id" uuid;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "revoked_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "revoked_by_actor_type" text;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "revoked_by_actor_id" text;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "revocation_reason" text;--> statement-breakpoint +ALTER TABLE "memory_operations" ADD COLUMN IF NOT EXISTS "scope_type" text;--> statement-breakpoint +ALTER TABLE "memory_operations" ADD COLUMN IF NOT EXISTS "scope_id" text;--> statement-breakpoint +ALTER TABLE "memory_operations" ADD COLUMN IF NOT EXISTS "scope_workspace_id" text;--> statement-breakpoint +ALTER TABLE "memory_operations" ADD COLUMN IF NOT EXISTS "scope_team_id" text;--> statement-breakpoint +ALTER TABLE "memory_operations" ADD COLUMN IF NOT EXISTS "max_sensitivity_label" text;--> statement-breakpoint +ALTER TABLE "memory_operations" ADD COLUMN IF NOT EXISTS "policy_decision_json" jsonb;--> statement-breakpoint +ALTER TABLE "memory_operations" ADD COLUMN IF NOT EXISTS "revocation_selector_json" jsonb;--> statement-breakpoint +ALTER TABLE "memory_operations" ADD COLUMN IF NOT EXISTS "retention_action_json" jsonb;--> statement-breakpoint +UPDATE "memory_local_records" +SET + "scope_type" = CASE + WHEN "scope_run_id" IS NOT NULL THEN 'run' + WHEN "scope_agent_id" IS NOT NULL THEN 'agent' + WHEN "scope_project_id" IS NOT NULL THEN 'project' + ELSE 'org' + END, + "scope_id" = COALESCE("scope_run_id"::text, "scope_agent_id"::text, "scope_project_id"::text, "company_id"::text), + "retention_state" = CASE WHEN "deleted_at" IS NOT NULL THEN 'revoked' ELSE 'active' END, + "revoked_at" = CASE WHEN "deleted_at" IS NOT NULL THEN "deleted_at" ELSE NULL END, + "revocation_reason" = CASE WHEN "deleted_at" IS NOT NULL THEN 'Record was forgotten before governed memory migration' ELSE NULL END;--> statement-breakpoint +UPDATE "memory_local_records" AS "record" +SET + "owner_type" = "operation"."actor_type", + "owner_id" = "operation"."actor_id", + "created_by_actor_type" = "operation"."actor_type", + "created_by_actor_id" = "operation"."actor_id" +FROM "memory_operations" AS "operation" +WHERE "record"."created_by_operation_id" = "operation"."id";--> statement-breakpoint +UPDATE "memory_operations" +SET + "scope_type" = CASE + WHEN "scope_run_id" IS NOT NULL THEN 'run' + WHEN "scope_agent_id" IS NOT NULL THEN 'agent' + WHEN "scope_project_id" IS NOT NULL THEN 'project' + ELSE 'org' + END, + "scope_id" = COALESCE("scope_run_id"::text, "scope_agent_id"::text, "scope_project_id"::text, "company_id"::text);--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "review_state" text DEFAULT 'pending' NOT NULL;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "reviewed_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "reviewed_by_actor_type" text;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "reviewed_by_actor_id" text;--> statement-breakpoint +ALTER TABLE "memory_local_records" ADD COLUMN IF NOT EXISTS "review_note" text;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "background_job_cost_events" ADD CONSTRAINT "background_job_cost_events_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 "background_job_cost_events" ADD CONSTRAINT "background_job_cost_events_run_id_background_job_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."background_job_runs"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "background_job_cost_events" ADD CONSTRAINT "background_job_cost_events_cost_event_id_cost_events_id_fk" FOREIGN KEY ("cost_event_id") REFERENCES "public"."cost_events"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "background_job_events" ADD CONSTRAINT "background_job_events_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 "background_job_events" ADD CONSTRAINT "background_job_events_run_id_background_job_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."background_job_runs"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_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 "background_job_runs" ADD CONSTRAINT "background_job_runs_job_id_background_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."background_jobs"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_requested_by_agent_id_agents_id_fk" FOREIGN KEY ("requested_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_source_project_id_projects_id_fk" FOREIGN KEY ("source_project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_source_agent_id_agents_id_fk" FOREIGN KEY ("source_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "background_jobs" ADD CONSTRAINT "background_jobs_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 "background_jobs" ADD CONSTRAINT "background_jobs_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "background_jobs" ADD CONSTRAINT "background_jobs_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "background_jobs" ADD CONSTRAINT "background_jobs_source_project_id_projects_id_fk" FOREIGN KEY ("source_project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_binding_targets" ADD CONSTRAINT "memory_binding_targets_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 "memory_binding_targets" ADD CONSTRAINT "memory_binding_targets_binding_id_memory_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."memory_bindings"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_bindings" ADD CONSTRAINT "memory_bindings_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 "memory_extraction_jobs" ADD CONSTRAINT "memory_extraction_jobs_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 "memory_extraction_jobs" ADD CONSTRAINT "memory_extraction_jobs_binding_id_memory_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."memory_bindings"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_extraction_jobs" ADD CONSTRAINT "memory_extraction_jobs_operation_id_memory_operations_id_fk" FOREIGN KEY ("operation_id") REFERENCES "public"."memory_operations"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_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 "memory_local_records" ADD CONSTRAINT "memory_local_records_binding_id_memory_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."memory_bindings"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_scope_agent_id_agents_id_fk" FOREIGN KEY ("scope_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_scope_project_id_projects_id_fk" FOREIGN KEY ("scope_project_id") REFERENCES "public"."projects"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_scope_issue_id_issues_id_fk" FOREIGN KEY ("scope_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_scope_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("scope_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_source_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("source_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_local_records" ADD CONSTRAINT "memory_local_records_created_by_operation_id_memory_operations_id_fk" FOREIGN KEY ("created_by_operation_id") REFERENCES "public"."memory_operations"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_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 "memory_operations" ADD CONSTRAINT "memory_operations_binding_id_memory_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."memory_bindings"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_scope_agent_id_agents_id_fk" FOREIGN KEY ("scope_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_scope_project_id_projects_id_fk" FOREIGN KEY ("scope_project_id") REFERENCES "public"."projects"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_scope_issue_id_issues_id_fk" FOREIGN KEY ("scope_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_scope_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("scope_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_source_comment_id_issue_comments_id_fk" FOREIGN KEY ("source_comment_id") REFERENCES "public"."issue_comments"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_source_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("source_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_source_activity_id_activity_log_id_fk" FOREIGN KEY ("source_activity_id") REFERENCES "public"."activity_log"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_cost_event_id_cost_events_id_fk" FOREIGN KEY ("cost_event_id") REFERENCES "public"."cost_events"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "memory_operations" ADD CONSTRAINT "memory_operations_finance_event_id_finance_events_id_fk" FOREIGN KEY ("finance_event_id") REFERENCES "public"."finance_events"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "background_job_cost_events_run_idx" ON "background_job_cost_events" USING btree ("run_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "background_job_cost_events_cost_event_uq" ON "background_job_cost_events" USING btree ("cost_event_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "background_job_events_run_created_idx" ON "background_job_events" USING btree ("run_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "background_job_events_company_created_idx" ON "background_job_events" USING btree ("company_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "background_job_runs_company_created_idx" ON "background_job_runs" USING btree ("company_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "background_job_runs_company_type_status_idx" ON "background_job_runs" USING btree ("company_id","job_type","status");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "background_job_runs_company_issue_created_idx" ON "background_job_runs" USING btree ("company_id","source_issue_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "background_job_runs_job_created_idx" ON "background_job_runs" USING btree ("job_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "background_jobs_company_type_status_idx" ON "background_jobs" USING btree ("company_id","job_type","status");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "background_jobs_company_key_uq" ON "background_jobs" USING btree ("company_id","key");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "memory_binding_targets_company_target_idx" ON "memory_binding_targets" USING btree ("company_id","target_type","target_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_binding_targets_company_binding_idx" ON "memory_binding_targets" USING btree ("company_id","binding_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "memory_bindings_company_key_idx" ON "memory_bindings" USING btree ("company_id","key");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_bindings_company_provider_idx" ON "memory_bindings" USING btree ("company_id","provider_key");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_extraction_jobs_company_status_created_idx" ON "memory_extraction_jobs" USING btree ("company_id","status","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_extraction_jobs_company_binding_created_idx" ON "memory_extraction_jobs" USING btree ("company_id","binding_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_local_records_company_binding_created_idx" ON "memory_local_records" USING btree ("company_id","binding_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_local_records_company_agent_created_idx" ON "memory_local_records" USING btree ("company_id","scope_agent_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_local_records_company_issue_created_idx" ON "memory_local_records" USING btree ("company_id","scope_issue_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_local_records_company_scope_created_idx" ON "memory_local_records" USING btree ("company_id","scope_type","scope_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_local_records_company_sensitivity_created_idx" ON "memory_local_records" USING btree ("company_id","sensitivity_label","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_local_records_company_retention_created_idx" ON "memory_local_records" USING btree ("company_id","retention_state","expires_at","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_local_records_company_review_created_idx" ON "memory_local_records" USING btree ("company_id","review_state","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_local_records_fts_idx" ON "memory_local_records" USING gin (to_tsvector('english', coalesce("title", '') || ' ' || "content"));--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_operations_company_occurred_idx" ON "memory_operations" USING btree ("company_id","occurred_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_operations_company_binding_occurred_idx" ON "memory_operations" USING btree ("company_id","binding_id","occurred_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_operations_company_issue_occurred_idx" ON "memory_operations" USING btree ("company_id","scope_issue_id","occurred_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_operations_company_run_occurred_idx" ON "memory_operations" USING btree ("company_id","scope_run_id","occurred_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "memory_operations_company_scope_occurred_idx" ON "memory_operations" USING btree ("company_id","scope_type","scope_id","occurred_at"); diff --git a/packages/db/src/migrations/0059_lazy_nick_fury.sql b/packages/db/src/migrations/0059_lazy_nick_fury.sql deleted file mode 100644 index abeb7ee4b31..00000000000 --- a/packages/db/src/migrations/0059_lazy_nick_fury.sql +++ /dev/null @@ -1,104 +0,0 @@ -CREATE TABLE "background_job_cost_events" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "company_id" uuid NOT NULL, - "run_id" uuid NOT NULL, - "cost_event_id" uuid NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "background_job_events" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "company_id" uuid NOT NULL, - "run_id" uuid NOT NULL, - "event_type" text NOT NULL, - "level" text DEFAULT 'info' NOT NULL, - "message" text, - "progress_percent" integer, - "total_items" integer, - "processed_items" integer, - "succeeded_items" integer, - "failed_items" integer, - "skipped_items" integer, - "current_item" text, - "details" jsonb, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "background_job_runs" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "company_id" uuid NOT NULL, - "job_id" uuid, - "job_key" text NOT NULL, - "job_type" text NOT NULL, - "trigger" text DEFAULT 'manual' NOT NULL, - "status" text DEFAULT 'queued' NOT NULL, - "requested_by_actor_type" text DEFAULT 'system' NOT NULL, - "requested_by_actor_id" text DEFAULT 'system' NOT NULL, - "requested_by_agent_id" uuid, - "requested_by_user_id" text, - "source_issue_id" uuid, - "source_project_id" uuid, - "source_agent_id" uuid, - "heartbeat_run_id" uuid, - "total_items" integer, - "processed_items" integer DEFAULT 0 NOT NULL, - "succeeded_items" integer DEFAULT 0 NOT NULL, - "failed_items" integer DEFAULT 0 NOT NULL, - "skipped_items" integer DEFAULT 0 NOT NULL, - "progress_percent" integer, - "current_item" text, - "cancellation_requested_at" timestamp with time zone, - "cancelled_at" timestamp with time zone, - "error" text, - "result" jsonb, - "started_at" timestamp with time zone, - "finished_at" timestamp with time zone, - "duration_ms" integer, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "background_jobs" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "company_id" uuid NOT NULL, - "key" text NOT NULL, - "job_type" text NOT NULL, - "display_name" text NOT NULL, - "description" text, - "backend_kind" text DEFAULT 'server_worker' NOT NULL, - "status" text DEFAULT 'active' NOT NULL, - "config" jsonb DEFAULT '{}'::jsonb NOT NULL, - "created_by_agent_id" uuid, - "created_by_user_id" text, - "source_issue_id" uuid, - "source_project_id" uuid, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "background_job_cost_events" ADD CONSTRAINT "background_job_cost_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "background_job_cost_events" ADD CONSTRAINT "background_job_cost_events_run_id_background_job_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."background_job_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "background_job_cost_events" ADD CONSTRAINT "background_job_cost_events_cost_event_id_cost_events_id_fk" FOREIGN KEY ("cost_event_id") REFERENCES "public"."cost_events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "background_job_events" ADD CONSTRAINT "background_job_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "background_job_events" ADD CONSTRAINT "background_job_events_run_id_background_job_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."background_job_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_job_id_background_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."background_jobs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_requested_by_agent_id_agents_id_fk" FOREIGN KEY ("requested_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_source_project_id_projects_id_fk" FOREIGN KEY ("source_project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_source_agent_id_agents_id_fk" FOREIGN KEY ("source_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "background_jobs" ADD CONSTRAINT "background_jobs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "background_jobs" ADD CONSTRAINT "background_jobs_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "background_jobs" ADD CONSTRAINT "background_jobs_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "background_jobs" ADD CONSTRAINT "background_jobs_source_project_id_projects_id_fk" FOREIGN KEY ("source_project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "background_job_cost_events_run_idx" ON "background_job_cost_events" USING btree ("run_id");--> statement-breakpoint -CREATE UNIQUE INDEX "background_job_cost_events_cost_event_uq" ON "background_job_cost_events" USING btree ("cost_event_id");--> statement-breakpoint -CREATE INDEX "background_job_events_run_created_idx" ON "background_job_events" USING btree ("run_id","created_at");--> statement-breakpoint -CREATE INDEX "background_job_events_company_created_idx" ON "background_job_events" USING btree ("company_id","created_at");--> statement-breakpoint -CREATE INDEX "background_job_runs_company_created_idx" ON "background_job_runs" USING btree ("company_id","created_at");--> statement-breakpoint -CREATE INDEX "background_job_runs_company_type_status_idx" ON "background_job_runs" USING btree ("company_id","job_type","status");--> statement-breakpoint -CREATE INDEX "background_job_runs_company_issue_created_idx" ON "background_job_runs" USING btree ("company_id","source_issue_id","created_at");--> statement-breakpoint -CREATE INDEX "background_job_runs_job_created_idx" ON "background_job_runs" USING btree ("job_id","created_at");--> statement-breakpoint -CREATE INDEX "background_jobs_company_type_status_idx" ON "background_jobs" USING btree ("company_id","job_type","status");--> statement-breakpoint -CREATE UNIQUE INDEX "background_jobs_company_key_uq" ON "background_jobs" USING btree ("company_id","key"); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0056_snapshot.json b/packages/db/src/migrations/meta/0056_snapshot.json index 5e69d230938..6db5f80b3e5 100644 --- a/packages/db/src/migrations/meta/0056_snapshot.json +++ b/packages/db/src/migrations/meta/0056_snapshot.json @@ -1,5 +1,5 @@ { - "id": "8e7ecaaf-89a3-403d-ae01-dcca5f4f6449", + "id": "5b9211ec-73c0-4825-bdf7-08ba60b6915c", "prevId": "dfe6d9fd-7969-4f13-a701-1fcc62bf0015", "version": "7", "dialect": "postgresql", @@ -3752,6 +3752,125 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_user_sidebar_preferences": { + "name": "company_user_sidebar_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 + }, + "project_order": { + "name": "project_order", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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": { + "company_user_sidebar_preferences_company_idx": { + "name": "company_user_sidebar_preferences_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_user_sidebar_preferences_user_idx": { + "name": "company_user_sidebar_preferences_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_user_sidebar_preferences_company_user_uq": { + "name": "company_user_sidebar_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": {} + } + }, + "foreignKeys": { + "company_user_sidebar_preferences_company_id_companies_id_fk": { + "name": "company_user_sidebar_preferences_company_id_companies_id_fk", + "tableFrom": "company_user_sidebar_preferences", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.cost_events": { "name": "cost_events", "schema": "", @@ -9801,1374 +9920,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.memory_binding_targets": { - "name": "memory_binding_targets", - "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 - }, - "binding_id": { - "name": "binding_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "target_type": { - "name": "target_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "target_id": { - "name": "target_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "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": { - "memory_binding_targets_company_target_idx": { - "name": "memory_binding_targets_company_target_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "target_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "target_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_binding_targets_company_binding_idx": { - "name": "memory_binding_targets_company_binding_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "binding_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "memory_binding_targets_company_id_companies_id_fk": { - "name": "memory_binding_targets_company_id_companies_id_fk", - "tableFrom": "memory_binding_targets", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_binding_targets_binding_id_memory_bindings_id_fk": { - "name": "memory_binding_targets_binding_id_memory_bindings_id_fk", - "tableFrom": "memory_binding_targets", - "tableTo": "memory_bindings", - "columnsFrom": [ - "binding_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memory_bindings": { - "name": "memory_bindings", - "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 - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider_key": { - "name": "provider_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "config": { - "name": "config", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "enabled": { - "name": "enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "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": { - "memory_bindings_company_key_idx": { - "name": "memory_bindings_company_key_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_bindings_company_provider_idx": { - "name": "memory_bindings_company_provider_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "memory_bindings_company_id_companies_id_fk": { - "name": "memory_bindings_company_id_companies_id_fk", - "tableFrom": "memory_bindings", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memory_extraction_jobs": { - "name": "memory_extraction_jobs", - "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 - }, - "binding_id": { - "name": "binding_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "provider_key": { - "name": "provider_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "operation_id": { - "name": "operation_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'queued'" - }, - "provider_job_id": { - "name": "provider_job_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_kind": { - "name": "source_kind", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_issue_id": { - "name": "source_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_comment_id": { - "name": "source_comment_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_document_key": { - "name": "source_document_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_run_id": { - "name": "source_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_activity_id": { - "name": "source_activity_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_external_ref": { - "name": "source_external_ref", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "result_json": { - "name": "result_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "started_at": { - "name": "started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "memory_extraction_jobs_company_status_created_idx": { - "name": "memory_extraction_jobs_company_status_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_extraction_jobs_company_binding_created_idx": { - "name": "memory_extraction_jobs_company_binding_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "binding_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "memory_extraction_jobs_company_id_companies_id_fk": { - "name": "memory_extraction_jobs_company_id_companies_id_fk", - "tableFrom": "memory_extraction_jobs", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_extraction_jobs_binding_id_memory_bindings_id_fk": { - "name": "memory_extraction_jobs_binding_id_memory_bindings_id_fk", - "tableFrom": "memory_extraction_jobs", - "tableTo": "memory_bindings", - "columnsFrom": [ - "binding_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_extraction_jobs_operation_id_memory_operations_id_fk": { - "name": "memory_extraction_jobs_operation_id_memory_operations_id_fk", - "tableFrom": "memory_extraction_jobs", - "tableTo": "memory_operations", - "columnsFrom": [ - "operation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memory_local_records": { - "name": "memory_local_records", - "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 - }, - "binding_id": { - "name": "binding_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "provider_key": { - "name": "provider_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_agent_id": { - "name": "scope_agent_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_project_id": { - "name": "scope_project_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_issue_id": { - "name": "scope_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_run_id": { - "name": "scope_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_subject_id": { - "name": "scope_subject_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_kind": { - "name": "source_kind", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_issue_id": { - "name": "source_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_comment_id": { - "name": "source_comment_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_document_key": { - "name": "source_document_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_run_id": { - "name": "source_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_activity_id": { - "name": "source_activity_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_external_ref": { - "name": "source_external_ref", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "summary": { - "name": "summary", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "created_by_operation_id": { - "name": "created_by_operation_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "memory_local_records_company_binding_created_idx": { - "name": "memory_local_records_company_binding_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "binding_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_company_agent_created_idx": { - "name": "memory_local_records_company_agent_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_agent_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_company_issue_created_idx": { - "name": "memory_local_records_company_issue_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_issue_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_fts_idx": { - "name": "memory_local_records_fts_idx", - "columns": [ - { - "expression": "to_tsvector('english', coalesce(\"title\", '') || ' ' || \"content\")", - "isExpression": true, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "gin", - "with": {} - } - }, - "foreignKeys": { - "memory_local_records_company_id_companies_id_fk": { - "name": "memory_local_records_company_id_companies_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_local_records_binding_id_memory_bindings_id_fk": { - "name": "memory_local_records_binding_id_memory_bindings_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "memory_bindings", - "columnsFrom": [ - "binding_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_local_records_scope_agent_id_agents_id_fk": { - "name": "memory_local_records_scope_agent_id_agents_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "agents", - "columnsFrom": [ - "scope_agent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_scope_project_id_projects_id_fk": { - "name": "memory_local_records_scope_project_id_projects_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "projects", - "columnsFrom": [ - "scope_project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_scope_issue_id_issues_id_fk": { - "name": "memory_local_records_scope_issue_id_issues_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "issues", - "columnsFrom": [ - "scope_issue_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_scope_run_id_heartbeat_runs_id_fk": { - "name": "memory_local_records_scope_run_id_heartbeat_runs_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "heartbeat_runs", - "columnsFrom": [ - "scope_run_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_source_issue_id_issues_id_fk": { - "name": "memory_local_records_source_issue_id_issues_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "issues", - "columnsFrom": [ - "source_issue_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_source_run_id_heartbeat_runs_id_fk": { - "name": "memory_local_records_source_run_id_heartbeat_runs_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "heartbeat_runs", - "columnsFrom": [ - "source_run_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_created_by_operation_id_memory_operations_id_fk": { - "name": "memory_local_records_created_by_operation_id_memory_operations_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "memory_operations", - "columnsFrom": [ - "created_by_operation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memory_operations": { - "name": "memory_operations", - "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 - }, - "binding_id": { - "name": "binding_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "provider_key": { - "name": "provider_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "operation_type": { - "name": "operation_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "trigger_kind": { - "name": "trigger_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'manual'" - }, - "hook_kind": { - "name": "hook_kind", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'succeeded'" - }, - "actor_type": { - "name": "actor_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "actor_id": { - "name": "actor_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "agent_id": { - "name": "agent_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_agent_id": { - "name": "scope_agent_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_project_id": { - "name": "scope_project_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_issue_id": { - "name": "scope_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_run_id": { - "name": "scope_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_subject_id": { - "name": "scope_subject_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_kind": { - "name": "source_kind", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_issue_id": { - "name": "source_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_comment_id": { - "name": "source_comment_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_document_key": { - "name": "source_document_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_run_id": { - "name": "source_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_activity_id": { - "name": "source_activity_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_external_ref": { - "name": "source_external_ref", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "query_text": { - "name": "query_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "record_count": { - "name": "record_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "request_json": { - "name": "request_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "result_json": { - "name": "result_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "usage_json": { - "name": "usage_json", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cost_event_id": { - "name": "cost_event_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "finance_event_id": { - "name": "finance_event_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "occurred_at": { - "name": "occurred_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "memory_operations_company_occurred_idx": { - "name": "memory_operations_company_occurred_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_operations_company_binding_occurred_idx": { - "name": "memory_operations_company_binding_occurred_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "binding_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_operations_company_issue_occurred_idx": { - "name": "memory_operations_company_issue_occurred_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_issue_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_operations_company_run_occurred_idx": { - "name": "memory_operations_company_run_occurred_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_run_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "memory_operations_company_id_companies_id_fk": { - "name": "memory_operations_company_id_companies_id_fk", - "tableFrom": "memory_operations", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_operations_binding_id_memory_bindings_id_fk": { - "name": "memory_operations_binding_id_memory_bindings_id_fk", - "tableFrom": "memory_operations", - "tableTo": "memory_bindings", - "columnsFrom": [ - "binding_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_operations_agent_id_agents_id_fk": { - "name": "memory_operations_agent_id_agents_id_fk", - "tableFrom": "memory_operations", - "tableTo": "agents", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_scope_agent_id_agents_id_fk": { - "name": "memory_operations_scope_agent_id_agents_id_fk", - "tableFrom": "memory_operations", - "tableTo": "agents", - "columnsFrom": [ - "scope_agent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_scope_project_id_projects_id_fk": { - "name": "memory_operations_scope_project_id_projects_id_fk", - "tableFrom": "memory_operations", - "tableTo": "projects", - "columnsFrom": [ - "scope_project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_scope_issue_id_issues_id_fk": { - "name": "memory_operations_scope_issue_id_issues_id_fk", - "tableFrom": "memory_operations", - "tableTo": "issues", - "columnsFrom": [ - "scope_issue_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_scope_run_id_heartbeat_runs_id_fk": { - "name": "memory_operations_scope_run_id_heartbeat_runs_id_fk", - "tableFrom": "memory_operations", - "tableTo": "heartbeat_runs", - "columnsFrom": [ - "scope_run_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_source_issue_id_issues_id_fk": { - "name": "memory_operations_source_issue_id_issues_id_fk", - "tableFrom": "memory_operations", - "tableTo": "issues", - "columnsFrom": [ - "source_issue_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_source_comment_id_issue_comments_id_fk": { - "name": "memory_operations_source_comment_id_issue_comments_id_fk", - "tableFrom": "memory_operations", - "tableTo": "issue_comments", - "columnsFrom": [ - "source_comment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_source_run_id_heartbeat_runs_id_fk": { - "name": "memory_operations_source_run_id_heartbeat_runs_id_fk", - "tableFrom": "memory_operations", - "tableTo": "heartbeat_runs", - "columnsFrom": [ - "source_run_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_source_activity_id_activity_log_id_fk": { - "name": "memory_operations_source_activity_id_activity_log_id_fk", - "tableFrom": "memory_operations", - "tableTo": "activity_log", - "columnsFrom": [ - "source_activity_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_cost_event_id_cost_events_id_fk": { - "name": "memory_operations_cost_event_id_cost_events_id_fk", - "tableFrom": "memory_operations", - "tableTo": "cost_events", - "columnsFrom": [ - "cost_event_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_finance_event_id_finance_events_id_fk": { - "name": "memory_operations_finance_event_id_finance_events_id_fk", - "tableFrom": "memory_operations", - "tableTo": "finance_events", - "columnsFrom": [ - "finance_event_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.plugin_company_settings": { "name": "plugin_company_settings", "schema": "", @@ -13929,6 +12680,69 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.user_sidebar_preferences": { + "name": "user_sidebar_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_order": { + "name": "company_order", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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_sidebar_preferences_user_uq": { + "name": "user_sidebar_preferences_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.workspace_operations": { "name": "workspace_operations", "schema": "", @@ -14571,4 +13385,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0057_snapshot.json b/packages/db/src/migrations/meta/0057_snapshot.json index 8736bce8b19..f57e05e33d7 100644 --- a/packages/db/src/migrations/meta/0057_snapshot.json +++ b/packages/db/src/migrations/meta/0057_snapshot.json @@ -1,6 +1,6 @@ { - "id": "f45142dd-f5b6-4f35-a507-4bda4efa767c", - "prevId": "8e7ecaaf-89a3-403d-ae01-dcca5f4f6449", + "id": "c13b1dd5-1860-4d0b-aeb2-5bb197766983", + "prevId": "5b9211ec-73c0-4825-bdf7-08ba60b6915c", "version": "7", "dialect": "postgresql", "tables": { @@ -3752,6 +3752,125 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_user_sidebar_preferences": { + "name": "company_user_sidebar_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 + }, + "project_order": { + "name": "project_order", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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": { + "company_user_sidebar_preferences_company_idx": { + "name": "company_user_sidebar_preferences_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_user_sidebar_preferences_user_idx": { + "name": "company_user_sidebar_preferences_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_user_sidebar_preferences_company_user_uq": { + "name": "company_user_sidebar_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": {} + } + }, + "foreignKeys": { + "company_user_sidebar_preferences_company_id_companies_id_fk": { + "name": "company_user_sidebar_preferences_company_id_companies_id_fk", + "tableFrom": "company_user_sidebar_preferences", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.cost_events": { "name": "cost_events", "schema": "", @@ -9649,6 +9768,50 @@ "concurrently": false, "method": "btree", "with": {} + }, + "join_requests_pending_human_user_uq": { + "name": "join_requests_pending_human_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requesting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"requesting_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_pending_human_email_uq": { + "name": "join_requests_pending_human_email_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"request_email_snapshot\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"request_email_snapshot\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -9801,1665 +9964,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.memory_binding_targets": { - "name": "memory_binding_targets", - "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 - }, - "binding_id": { - "name": "binding_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "target_type": { - "name": "target_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "target_id": { - "name": "target_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "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": { - "memory_binding_targets_company_target_idx": { - "name": "memory_binding_targets_company_target_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "target_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "target_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_binding_targets_company_binding_idx": { - "name": "memory_binding_targets_company_binding_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "binding_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "memory_binding_targets_company_id_companies_id_fk": { - "name": "memory_binding_targets_company_id_companies_id_fk", - "tableFrom": "memory_binding_targets", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_binding_targets_binding_id_memory_bindings_id_fk": { - "name": "memory_binding_targets_binding_id_memory_bindings_id_fk", - "tableFrom": "memory_binding_targets", - "tableTo": "memory_bindings", - "columnsFrom": [ - "binding_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memory_bindings": { - "name": "memory_bindings", - "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 - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider_key": { - "name": "provider_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "config": { - "name": "config", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "enabled": { - "name": "enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "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": { - "memory_bindings_company_key_idx": { - "name": "memory_bindings_company_key_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_bindings_company_provider_idx": { - "name": "memory_bindings_company_provider_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "memory_bindings_company_id_companies_id_fk": { - "name": "memory_bindings_company_id_companies_id_fk", - "tableFrom": "memory_bindings", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memory_extraction_jobs": { - "name": "memory_extraction_jobs", - "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 - }, - "binding_id": { - "name": "binding_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "provider_key": { - "name": "provider_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "operation_id": { - "name": "operation_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'queued'" - }, - "provider_job_id": { - "name": "provider_job_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_kind": { - "name": "source_kind", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_issue_id": { - "name": "source_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_comment_id": { - "name": "source_comment_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_document_key": { - "name": "source_document_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_run_id": { - "name": "source_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_activity_id": { - "name": "source_activity_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_external_ref": { - "name": "source_external_ref", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "result_json": { - "name": "result_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "started_at": { - "name": "started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "memory_extraction_jobs_company_status_created_idx": { - "name": "memory_extraction_jobs_company_status_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_extraction_jobs_company_binding_created_idx": { - "name": "memory_extraction_jobs_company_binding_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "binding_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "memory_extraction_jobs_company_id_companies_id_fk": { - "name": "memory_extraction_jobs_company_id_companies_id_fk", - "tableFrom": "memory_extraction_jobs", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_extraction_jobs_binding_id_memory_bindings_id_fk": { - "name": "memory_extraction_jobs_binding_id_memory_bindings_id_fk", - "tableFrom": "memory_extraction_jobs", - "tableTo": "memory_bindings", - "columnsFrom": [ - "binding_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_extraction_jobs_operation_id_memory_operations_id_fk": { - "name": "memory_extraction_jobs_operation_id_memory_operations_id_fk", - "tableFrom": "memory_extraction_jobs", - "tableTo": "memory_operations", - "columnsFrom": [ - "operation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memory_local_records": { - "name": "memory_local_records", - "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 - }, - "binding_id": { - "name": "binding_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "provider_key": { - "name": "provider_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_agent_id": { - "name": "scope_agent_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_project_id": { - "name": "scope_project_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_issue_id": { - "name": "scope_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_run_id": { - "name": "scope_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_subject_id": { - "name": "scope_subject_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_type": { - "name": "scope_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'org'" - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_workspace_id": { - "name": "scope_workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_team_id": { - "name": "scope_team_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_kind": { - "name": "source_kind", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_issue_id": { - "name": "source_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_comment_id": { - "name": "source_comment_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_document_key": { - "name": "source_document_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_run_id": { - "name": "source_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_activity_id": { - "name": "source_activity_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_external_ref": { - "name": "source_external_ref", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "summary": { - "name": "summary", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "owner_type": { - "name": "owner_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "owner_id": { - "name": "owner_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_by_actor_type": { - "name": "created_by_actor_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_by_actor_id": { - "name": "created_by_actor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sensitivity_label": { - "name": "sensitivity_label", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'internal'" - }, - "retention_policy": { - "name": "retention_policy", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "retention_state": { - "name": "retention_state", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'active'" - }, - "citation_json": { - "name": "citation_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "supersedes_record_id": { - "name": "supersedes_record_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "superseded_by_record_id": { - "name": "superseded_by_record_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "revoked_at": { - "name": "revoked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "revoked_by_actor_type": { - "name": "revoked_by_actor_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "revoked_by_actor_id": { - "name": "revoked_by_actor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "revocation_reason": { - "name": "revocation_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_by_operation_id": { - "name": "created_by_operation_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "memory_local_records_company_binding_created_idx": { - "name": "memory_local_records_company_binding_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "binding_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_company_agent_created_idx": { - "name": "memory_local_records_company_agent_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_agent_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_company_issue_created_idx": { - "name": "memory_local_records_company_issue_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_issue_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_company_scope_created_idx": { - "name": "memory_local_records_company_scope_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_company_sensitivity_created_idx": { - "name": "memory_local_records_company_sensitivity_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "sensitivity_label", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_company_retention_created_idx": { - "name": "memory_local_records_company_retention_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "retention_state", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "expires_at", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_fts_idx": { - "name": "memory_local_records_fts_idx", - "columns": [ - { - "expression": "to_tsvector('english', coalesce(\"title\", '') || ' ' || \"content\")", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "gin", - "with": {} - } - }, - "foreignKeys": { - "memory_local_records_company_id_companies_id_fk": { - "name": "memory_local_records_company_id_companies_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_local_records_binding_id_memory_bindings_id_fk": { - "name": "memory_local_records_binding_id_memory_bindings_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "memory_bindings", - "columnsFrom": [ - "binding_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_local_records_scope_agent_id_agents_id_fk": { - "name": "memory_local_records_scope_agent_id_agents_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "agents", - "columnsFrom": [ - "scope_agent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_scope_project_id_projects_id_fk": { - "name": "memory_local_records_scope_project_id_projects_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "projects", - "columnsFrom": [ - "scope_project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_scope_issue_id_issues_id_fk": { - "name": "memory_local_records_scope_issue_id_issues_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "issues", - "columnsFrom": [ - "scope_issue_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_scope_run_id_heartbeat_runs_id_fk": { - "name": "memory_local_records_scope_run_id_heartbeat_runs_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "heartbeat_runs", - "columnsFrom": [ - "scope_run_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_source_issue_id_issues_id_fk": { - "name": "memory_local_records_source_issue_id_issues_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "issues", - "columnsFrom": [ - "source_issue_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_source_run_id_heartbeat_runs_id_fk": { - "name": "memory_local_records_source_run_id_heartbeat_runs_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "heartbeat_runs", - "columnsFrom": [ - "source_run_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_created_by_operation_id_memory_operations_id_fk": { - "name": "memory_local_records_created_by_operation_id_memory_operations_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "memory_operations", - "columnsFrom": [ - "created_by_operation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memory_operations": { - "name": "memory_operations", - "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 - }, - "binding_id": { - "name": "binding_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "provider_key": { - "name": "provider_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "operation_type": { - "name": "operation_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "trigger_kind": { - "name": "trigger_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'manual'" - }, - "hook_kind": { - "name": "hook_kind", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'succeeded'" - }, - "actor_type": { - "name": "actor_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "actor_id": { - "name": "actor_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "agent_id": { - "name": "agent_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_agent_id": { - "name": "scope_agent_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_project_id": { - "name": "scope_project_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_issue_id": { - "name": "scope_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_run_id": { - "name": "scope_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_subject_id": { - "name": "scope_subject_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_type": { - "name": "scope_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_workspace_id": { - "name": "scope_workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_team_id": { - "name": "scope_team_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "max_sensitivity_label": { - "name": "max_sensitivity_label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_kind": { - "name": "source_kind", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_issue_id": { - "name": "source_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_comment_id": { - "name": "source_comment_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_document_key": { - "name": "source_document_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_run_id": { - "name": "source_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_activity_id": { - "name": "source_activity_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_external_ref": { - "name": "source_external_ref", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "query_text": { - "name": "query_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "record_count": { - "name": "record_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "request_json": { - "name": "request_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "result_json": { - "name": "result_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "policy_decision_json": { - "name": "policy_decision_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "revocation_selector_json": { - "name": "revocation_selector_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "retention_action_json": { - "name": "retention_action_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "usage_json": { - "name": "usage_json", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cost_event_id": { - "name": "cost_event_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "finance_event_id": { - "name": "finance_event_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "occurred_at": { - "name": "occurred_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "memory_operations_company_occurred_idx": { - "name": "memory_operations_company_occurred_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_operations_company_binding_occurred_idx": { - "name": "memory_operations_company_binding_occurred_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "binding_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_operations_company_issue_occurred_idx": { - "name": "memory_operations_company_issue_occurred_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_issue_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_operations_company_run_occurred_idx": { - "name": "memory_operations_company_run_occurred_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_run_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_operations_company_scope_occurred_idx": { - "name": "memory_operations_company_scope_occurred_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "memory_operations_company_id_companies_id_fk": { - "name": "memory_operations_company_id_companies_id_fk", - "tableFrom": "memory_operations", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_operations_binding_id_memory_bindings_id_fk": { - "name": "memory_operations_binding_id_memory_bindings_id_fk", - "tableFrom": "memory_operations", - "tableTo": "memory_bindings", - "columnsFrom": [ - "binding_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_operations_agent_id_agents_id_fk": { - "name": "memory_operations_agent_id_agents_id_fk", - "tableFrom": "memory_operations", - "tableTo": "agents", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_scope_agent_id_agents_id_fk": { - "name": "memory_operations_scope_agent_id_agents_id_fk", - "tableFrom": "memory_operations", - "tableTo": "agents", - "columnsFrom": [ - "scope_agent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_scope_project_id_projects_id_fk": { - "name": "memory_operations_scope_project_id_projects_id_fk", - "tableFrom": "memory_operations", - "tableTo": "projects", - "columnsFrom": [ - "scope_project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_scope_issue_id_issues_id_fk": { - "name": "memory_operations_scope_issue_id_issues_id_fk", - "tableFrom": "memory_operations", - "tableTo": "issues", - "columnsFrom": [ - "scope_issue_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_scope_run_id_heartbeat_runs_id_fk": { - "name": "memory_operations_scope_run_id_heartbeat_runs_id_fk", - "tableFrom": "memory_operations", - "tableTo": "heartbeat_runs", - "columnsFrom": [ - "scope_run_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_source_issue_id_issues_id_fk": { - "name": "memory_operations_source_issue_id_issues_id_fk", - "tableFrom": "memory_operations", - "tableTo": "issues", - "columnsFrom": [ - "source_issue_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_source_comment_id_issue_comments_id_fk": { - "name": "memory_operations_source_comment_id_issue_comments_id_fk", - "tableFrom": "memory_operations", - "tableTo": "issue_comments", - "columnsFrom": [ - "source_comment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_source_run_id_heartbeat_runs_id_fk": { - "name": "memory_operations_source_run_id_heartbeat_runs_id_fk", - "tableFrom": "memory_operations", - "tableTo": "heartbeat_runs", - "columnsFrom": [ - "source_run_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_source_activity_id_activity_log_id_fk": { - "name": "memory_operations_source_activity_id_activity_log_id_fk", - "tableFrom": "memory_operations", - "tableTo": "activity_log", - "columnsFrom": [ - "source_activity_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_cost_event_id_cost_events_id_fk": { - "name": "memory_operations_cost_event_id_cost_events_id_fk", - "tableFrom": "memory_operations", - "tableTo": "cost_events", - "columnsFrom": [ - "cost_event_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_finance_event_id_finance_events_id_fk": { - "name": "memory_operations_finance_event_id_finance_events_id_fk", - "tableFrom": "memory_operations", - "tableTo": "finance_events", - "columnsFrom": [ - "finance_event_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.plugin_company_settings": { "name": "plugin_company_settings", "schema": "", @@ -14220,6 +12724,69 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.user_sidebar_preferences": { + "name": "user_sidebar_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_order": { + "name": "company_order", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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_sidebar_preferences_user_uq": { + "name": "user_sidebar_preferences_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.workspace_operations": { "name": "workspace_operations", "schema": "", diff --git a/packages/db/src/migrations/meta/0058_snapshot.json b/packages/db/src/migrations/meta/0058_snapshot.json index 9b60ab1df6a..713bebfe3e3 100644 --- a/packages/db/src/migrations/meta/0058_snapshot.json +++ b/packages/db/src/migrations/meta/0058_snapshot.json @@ -1,6 +1,6 @@ { - "id": "0cb92f3b-c1c4-454a-9ccd-e8d43aebbeda", - "prevId": "f45142dd-f5b6-4f35-a507-4bda4efa767c", + "id": "58b07646-6b04-4945-b6d2-e3409d238235", + "prevId": "c13b1dd5-1860-4d0b-aeb2-5bb197766983", "version": "7", "dialect": "postgresql", "tables": { @@ -3752,6 +3752,125 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_user_sidebar_preferences": { + "name": "company_user_sidebar_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 + }, + "project_order": { + "name": "project_order", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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": { + "company_user_sidebar_preferences_company_idx": { + "name": "company_user_sidebar_preferences_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_user_sidebar_preferences_user_idx": { + "name": "company_user_sidebar_preferences_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_user_sidebar_preferences_company_user_uq": { + "name": "company_user_sidebar_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": {} + } + }, + "foreignKeys": { + "company_user_sidebar_preferences_company_id_companies_id_fk": { + "name": "company_user_sidebar_preferences_company_id_companies_id_fk", + "tableFrom": "company_user_sidebar_preferences", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.cost_events": { "name": "cost_events", "schema": "", @@ -6355,6 +6474,37 @@ "primaryKey": false, "notNull": false }, + "liveness_state": { + "name": "liveness_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "liveness_reason": { + "name": "liveness_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation_attempt": { + "name": "continuation_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_useful_action_at": { + "name": "last_useful_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, "context_snapshot": { "name": "context_snapshot", "type": "jsonb", @@ -6403,6 +6553,33 @@ "concurrently": false, "method": "btree", "with": {} + }, + "heartbeat_runs_company_liveness_idx": { + "name": "heartbeat_runs_company_liveness_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "liveness_state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -9649,6 +9826,50 @@ "concurrently": false, "method": "btree", "with": {} + }, + "join_requests_pending_human_user_uq": { + "name": "join_requests_pending_human_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requesting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"requesting_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_pending_human_email_uq": { + "name": "join_requests_pending_human_email_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"request_email_snapshot\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"request_email_snapshot\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -9801,1723 +10022,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.memory_binding_targets": { - "name": "memory_binding_targets", - "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 - }, - "binding_id": { - "name": "binding_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "target_type": { - "name": "target_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "target_id": { - "name": "target_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "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": { - "memory_binding_targets_company_target_idx": { - "name": "memory_binding_targets_company_target_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "target_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "target_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_binding_targets_company_binding_idx": { - "name": "memory_binding_targets_company_binding_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "binding_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "memory_binding_targets_company_id_companies_id_fk": { - "name": "memory_binding_targets_company_id_companies_id_fk", - "tableFrom": "memory_binding_targets", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_binding_targets_binding_id_memory_bindings_id_fk": { - "name": "memory_binding_targets_binding_id_memory_bindings_id_fk", - "tableFrom": "memory_binding_targets", - "tableTo": "memory_bindings", - "columnsFrom": [ - "binding_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memory_bindings": { - "name": "memory_bindings", - "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 - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider_key": { - "name": "provider_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "config": { - "name": "config", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "enabled": { - "name": "enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "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": { - "memory_bindings_company_key_idx": { - "name": "memory_bindings_company_key_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_bindings_company_provider_idx": { - "name": "memory_bindings_company_provider_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "memory_bindings_company_id_companies_id_fk": { - "name": "memory_bindings_company_id_companies_id_fk", - "tableFrom": "memory_bindings", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memory_extraction_jobs": { - "name": "memory_extraction_jobs", - "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 - }, - "binding_id": { - "name": "binding_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "provider_key": { - "name": "provider_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "operation_id": { - "name": "operation_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'queued'" - }, - "provider_job_id": { - "name": "provider_job_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_kind": { - "name": "source_kind", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_issue_id": { - "name": "source_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_comment_id": { - "name": "source_comment_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_document_key": { - "name": "source_document_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_run_id": { - "name": "source_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_activity_id": { - "name": "source_activity_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_external_ref": { - "name": "source_external_ref", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "result_json": { - "name": "result_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "started_at": { - "name": "started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "memory_extraction_jobs_company_status_created_idx": { - "name": "memory_extraction_jobs_company_status_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_extraction_jobs_company_binding_created_idx": { - "name": "memory_extraction_jobs_company_binding_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "binding_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "memory_extraction_jobs_company_id_companies_id_fk": { - "name": "memory_extraction_jobs_company_id_companies_id_fk", - "tableFrom": "memory_extraction_jobs", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_extraction_jobs_binding_id_memory_bindings_id_fk": { - "name": "memory_extraction_jobs_binding_id_memory_bindings_id_fk", - "tableFrom": "memory_extraction_jobs", - "tableTo": "memory_bindings", - "columnsFrom": [ - "binding_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_extraction_jobs_operation_id_memory_operations_id_fk": { - "name": "memory_extraction_jobs_operation_id_memory_operations_id_fk", - "tableFrom": "memory_extraction_jobs", - "tableTo": "memory_operations", - "columnsFrom": [ - "operation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memory_local_records": { - "name": "memory_local_records", - "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 - }, - "binding_id": { - "name": "binding_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "provider_key": { - "name": "provider_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_agent_id": { - "name": "scope_agent_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_project_id": { - "name": "scope_project_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_issue_id": { - "name": "scope_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_run_id": { - "name": "scope_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_subject_id": { - "name": "scope_subject_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_type": { - "name": "scope_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'org'" - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_workspace_id": { - "name": "scope_workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_team_id": { - "name": "scope_team_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_kind": { - "name": "source_kind", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_issue_id": { - "name": "source_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_comment_id": { - "name": "source_comment_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_document_key": { - "name": "source_document_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_run_id": { - "name": "source_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_activity_id": { - "name": "source_activity_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_external_ref": { - "name": "source_external_ref", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "summary": { - "name": "summary", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "owner_type": { - "name": "owner_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "owner_id": { - "name": "owner_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_by_actor_type": { - "name": "created_by_actor_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_by_actor_id": { - "name": "created_by_actor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "sensitivity_label": { - "name": "sensitivity_label", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'internal'" - }, - "retention_policy": { - "name": "retention_policy", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "retention_state": { - "name": "retention_state", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'active'" - }, - "review_state": { - "name": "review_state", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "reviewed_at": { - "name": "reviewed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "reviewed_by_actor_type": { - "name": "reviewed_by_actor_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "reviewed_by_actor_id": { - "name": "reviewed_by_actor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "review_note": { - "name": "review_note", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "citation_json": { - "name": "citation_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "supersedes_record_id": { - "name": "supersedes_record_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "superseded_by_record_id": { - "name": "superseded_by_record_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "revoked_at": { - "name": "revoked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "revoked_by_actor_type": { - "name": "revoked_by_actor_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "revoked_by_actor_id": { - "name": "revoked_by_actor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "revocation_reason": { - "name": "revocation_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_by_operation_id": { - "name": "created_by_operation_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "memory_local_records_company_binding_created_idx": { - "name": "memory_local_records_company_binding_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "binding_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_company_agent_created_idx": { - "name": "memory_local_records_company_agent_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_agent_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_company_issue_created_idx": { - "name": "memory_local_records_company_issue_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_issue_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_company_scope_created_idx": { - "name": "memory_local_records_company_scope_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_company_sensitivity_created_idx": { - "name": "memory_local_records_company_sensitivity_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "sensitivity_label", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_company_retention_created_idx": { - "name": "memory_local_records_company_retention_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "retention_state", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "expires_at", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_company_review_created_idx": { - "name": "memory_local_records_company_review_created_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "review_state", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_local_records_fts_idx": { - "name": "memory_local_records_fts_idx", - "columns": [ - { - "expression": "to_tsvector('english', coalesce(\"title\", '') || ' ' || \"content\")", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "gin", - "with": {} - } - }, - "foreignKeys": { - "memory_local_records_company_id_companies_id_fk": { - "name": "memory_local_records_company_id_companies_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_local_records_binding_id_memory_bindings_id_fk": { - "name": "memory_local_records_binding_id_memory_bindings_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "memory_bindings", - "columnsFrom": [ - "binding_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_local_records_scope_agent_id_agents_id_fk": { - "name": "memory_local_records_scope_agent_id_agents_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "agents", - "columnsFrom": [ - "scope_agent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_scope_project_id_projects_id_fk": { - "name": "memory_local_records_scope_project_id_projects_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "projects", - "columnsFrom": [ - "scope_project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_scope_issue_id_issues_id_fk": { - "name": "memory_local_records_scope_issue_id_issues_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "issues", - "columnsFrom": [ - "scope_issue_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_scope_run_id_heartbeat_runs_id_fk": { - "name": "memory_local_records_scope_run_id_heartbeat_runs_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "heartbeat_runs", - "columnsFrom": [ - "scope_run_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_source_issue_id_issues_id_fk": { - "name": "memory_local_records_source_issue_id_issues_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "issues", - "columnsFrom": [ - "source_issue_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_source_run_id_heartbeat_runs_id_fk": { - "name": "memory_local_records_source_run_id_heartbeat_runs_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "heartbeat_runs", - "columnsFrom": [ - "source_run_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_local_records_created_by_operation_id_memory_operations_id_fk": { - "name": "memory_local_records_created_by_operation_id_memory_operations_id_fk", - "tableFrom": "memory_local_records", - "tableTo": "memory_operations", - "columnsFrom": [ - "created_by_operation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memory_operations": { - "name": "memory_operations", - "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 - }, - "binding_id": { - "name": "binding_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "provider_key": { - "name": "provider_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "operation_type": { - "name": "operation_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "trigger_kind": { - "name": "trigger_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'manual'" - }, - "hook_kind": { - "name": "hook_kind", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'succeeded'" - }, - "actor_type": { - "name": "actor_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "actor_id": { - "name": "actor_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "agent_id": { - "name": "agent_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_agent_id": { - "name": "scope_agent_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_project_id": { - "name": "scope_project_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_issue_id": { - "name": "scope_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_run_id": { - "name": "scope_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "scope_subject_id": { - "name": "scope_subject_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_type": { - "name": "scope_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_workspace_id": { - "name": "scope_workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope_team_id": { - "name": "scope_team_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "max_sensitivity_label": { - "name": "max_sensitivity_label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_kind": { - "name": "source_kind", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_issue_id": { - "name": "source_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_comment_id": { - "name": "source_comment_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_document_key": { - "name": "source_document_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_run_id": { - "name": "source_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_activity_id": { - "name": "source_activity_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "source_external_ref": { - "name": "source_external_ref", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "query_text": { - "name": "query_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "record_count": { - "name": "record_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "request_json": { - "name": "request_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "result_json": { - "name": "result_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "policy_decision_json": { - "name": "policy_decision_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "revocation_selector_json": { - "name": "revocation_selector_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "retention_action_json": { - "name": "retention_action_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "usage_json": { - "name": "usage_json", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cost_event_id": { - "name": "cost_event_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "finance_event_id": { - "name": "finance_event_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "occurred_at": { - "name": "occurred_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "memory_operations_company_occurred_idx": { - "name": "memory_operations_company_occurred_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_operations_company_binding_occurred_idx": { - "name": "memory_operations_company_binding_occurred_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "binding_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_operations_company_issue_occurred_idx": { - "name": "memory_operations_company_issue_occurred_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_issue_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_operations_company_run_occurred_idx": { - "name": "memory_operations_company_run_occurred_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_run_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "memory_operations_company_scope_occurred_idx": { - "name": "memory_operations_company_scope_occurred_idx", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "memory_operations_company_id_companies_id_fk": { - "name": "memory_operations_company_id_companies_id_fk", - "tableFrom": "memory_operations", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_operations_binding_id_memory_bindings_id_fk": { - "name": "memory_operations_binding_id_memory_bindings_id_fk", - "tableFrom": "memory_operations", - "tableTo": "memory_bindings", - "columnsFrom": [ - "binding_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memory_operations_agent_id_agents_id_fk": { - "name": "memory_operations_agent_id_agents_id_fk", - "tableFrom": "memory_operations", - "tableTo": "agents", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_scope_agent_id_agents_id_fk": { - "name": "memory_operations_scope_agent_id_agents_id_fk", - "tableFrom": "memory_operations", - "tableTo": "agents", - "columnsFrom": [ - "scope_agent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_scope_project_id_projects_id_fk": { - "name": "memory_operations_scope_project_id_projects_id_fk", - "tableFrom": "memory_operations", - "tableTo": "projects", - "columnsFrom": [ - "scope_project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_scope_issue_id_issues_id_fk": { - "name": "memory_operations_scope_issue_id_issues_id_fk", - "tableFrom": "memory_operations", - "tableTo": "issues", - "columnsFrom": [ - "scope_issue_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_scope_run_id_heartbeat_runs_id_fk": { - "name": "memory_operations_scope_run_id_heartbeat_runs_id_fk", - "tableFrom": "memory_operations", - "tableTo": "heartbeat_runs", - "columnsFrom": [ - "scope_run_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_source_issue_id_issues_id_fk": { - "name": "memory_operations_source_issue_id_issues_id_fk", - "tableFrom": "memory_operations", - "tableTo": "issues", - "columnsFrom": [ - "source_issue_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_source_comment_id_issue_comments_id_fk": { - "name": "memory_operations_source_comment_id_issue_comments_id_fk", - "tableFrom": "memory_operations", - "tableTo": "issue_comments", - "columnsFrom": [ - "source_comment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_source_run_id_heartbeat_runs_id_fk": { - "name": "memory_operations_source_run_id_heartbeat_runs_id_fk", - "tableFrom": "memory_operations", - "tableTo": "heartbeat_runs", - "columnsFrom": [ - "source_run_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_source_activity_id_activity_log_id_fk": { - "name": "memory_operations_source_activity_id_activity_log_id_fk", - "tableFrom": "memory_operations", - "tableTo": "activity_log", - "columnsFrom": [ - "source_activity_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_cost_event_id_cost_events_id_fk": { - "name": "memory_operations_cost_event_id_cost_events_id_fk", - "tableFrom": "memory_operations", - "tableTo": "cost_events", - "columnsFrom": [ - "cost_event_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "memory_operations_finance_event_id_finance_events_id_fk": { - "name": "memory_operations_finance_event_id_finance_events_id_fk", - "tableFrom": "memory_operations", - "tableTo": "finance_events", - "columnsFrom": [ - "finance_event_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.plugin_company_settings": { "name": "plugin_company_settings", "schema": "", @@ -14278,6 +12782,69 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.user_sidebar_preferences": { + "name": "user_sidebar_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_order": { + "name": "company_order", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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_sidebar_preferences_user_uq": { + "name": "user_sidebar_preferences_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.workspace_operations": { "name": "workspace_operations", "schema": "", diff --git a/packages/db/src/migrations/meta/0059_snapshot.json b/packages/db/src/migrations/meta/0059_snapshot.json index 3400e4f13bc..278fbaf6e78 100644 --- a/packages/db/src/migrations/meta/0059_snapshot.json +++ b/packages/db/src/migrations/meta/0059_snapshot.json @@ -1,6 +1,6 @@ { - "id": "7489b6fa-6a53-4c30-a852-73761e7a3da9", - "prevId": "0cb92f3b-c1c4-454a-9ccd-e8d43aebbeda", + "id": "11957e00-fbcf-4021-9d6e-3a97126ff0f1", + "prevId": "58b07646-6b04-4945-b6d2-e3409d238235", "version": "7", "dialect": "postgresql", "tables": { @@ -4654,6 +4654,125 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_user_sidebar_preferences": { + "name": "company_user_sidebar_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 + }, + "project_order": { + "name": "project_order", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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": { + "company_user_sidebar_preferences_company_idx": { + "name": "company_user_sidebar_preferences_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_user_sidebar_preferences_user_idx": { + "name": "company_user_sidebar_preferences_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_user_sidebar_preferences_company_user_uq": { + "name": "company_user_sidebar_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": {} + } + }, + "foreignKeys": { + "company_user_sidebar_preferences_company_id_companies_id_fk": { + "name": "company_user_sidebar_preferences_company_id_companies_id_fk", + "tableFrom": "company_user_sidebar_preferences", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.cost_events": { "name": "cost_events", "schema": "", @@ -7257,6 +7376,37 @@ "primaryKey": false, "notNull": false }, + "liveness_state": { + "name": "liveness_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "liveness_reason": { + "name": "liveness_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation_attempt": { + "name": "continuation_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_useful_action_at": { + "name": "last_useful_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, "context_snapshot": { "name": "context_snapshot", "type": "jsonb", @@ -7305,6 +7455,33 @@ "concurrently": false, "method": "btree", "with": {} + }, + "heartbeat_runs_company_liveness_idx": { + "name": "heartbeat_runs_company_liveness_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "liveness_state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -10551,6 +10728,50 @@ "concurrently": false, "method": "btree", "with": {} + }, + "join_requests_pending_human_user_uq": { + "name": "join_requests_pending_human_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requesting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"requesting_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_pending_human_email_uq": { + "name": "join_requests_pending_human_email_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"request_email_snapshot\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"request_email_snapshot\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -15180,6 +15401,69 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.user_sidebar_preferences": { + "name": "user_sidebar_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_order": { + "name": "company_order", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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_sidebar_preferences_user_uq": { + "name": "user_sidebar_preferences_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.workspace_operations": { "name": "workspace_operations", "schema": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 8a22ea2e7a5..a306ddb4614 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -397,29 +397,29 @@ { "idx": 56, "version": "7", - "when": 1775925298474, - "tag": "0056_oval_gauntlet", + "when": 1776084034244, + "tag": "0056_spooky_ultragirl", "breakpoints": true }, { "idx": 57, "version": "7", - "when": 1776377673907, - "tag": "0057_misty_riptide", + "when": 1776309613598, + "tag": "0057_tidy_join_requests", "breakpoints": true }, { "idx": 58, "version": "7", - "when": 1776647797670, - "tag": "0058_lovely_eternals", + "when": 1776542245004, + "tag": "0058_wealthy_starbolt", "breakpoints": true }, { "idx": 59, "version": "7", - "when": 1776648937778, - "tag": "0059_lazy_nick_fury", + "when": 1776687049119, + "tag": "0059_foamy_jackpot", "breakpoints": true } ] diff --git a/packages/db/src/schema/company_user_sidebar_preferences.ts b/packages/db/src/schema/company_user_sidebar_preferences.ts new file mode 100644 index 00000000000..7d776875348 --- /dev/null +++ b/packages/db/src/schema/company_user_sidebar_preferences.ts @@ -0,0 +1,22 @@ +import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex, index } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; + +export const companyUserSidebarPreferences = pgTable( + "company_user_sidebar_preferences", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + userId: text("user_id").notNull(), + projectOrder: jsonb("project_order").$type().notNull().default([]), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIdx: index("company_user_sidebar_preferences_company_idx").on(table.companyId), + userIdx: index("company_user_sidebar_preferences_user_idx").on(table.userId), + companyUserUq: uniqueIndex("company_user_sidebar_preferences_company_user_uq").on( + table.companyId, + table.userId, + ), + }), +); diff --git a/packages/db/src/schema/heartbeat_runs.ts b/packages/db/src/schema/heartbeat_runs.ts index 4c100505198..6caf37016bb 100644 --- a/packages/db/src/schema/heartbeat_runs.ts +++ b/packages/db/src/schema/heartbeat_runs.ts @@ -41,6 +41,11 @@ export const heartbeatRuns = pgTable( issueCommentStatus: text("issue_comment_status").notNull().default("not_applicable"), issueCommentSatisfiedByCommentId: uuid("issue_comment_satisfied_by_comment_id"), issueCommentRetryQueuedAt: timestamp("issue_comment_retry_queued_at", { withTimezone: true }), + livenessState: text("liveness_state"), + livenessReason: text("liveness_reason"), + continuationAttempt: integer("continuation_attempt").notNull().default(0), + lastUsefulActionAt: timestamp("last_useful_action_at", { withTimezone: true }), + nextAction: text("next_action"), contextSnapshot: jsonb("context_snapshot").$type>(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), @@ -51,5 +56,10 @@ export const heartbeatRuns = pgTable( table.agentId, table.startedAt, ), + companyLivenessIdx: index("heartbeat_runs_company_liveness_idx").on( + table.companyId, + table.livenessState, + table.createdAt, + ), }), ); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index fdaaf85ba90..2b2e03e780c 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -3,10 +3,12 @@ export { companyLogos } from "./company_logos.js"; export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js"; export { instanceSettings } from "./instance_settings.js"; export { instanceUserRoles } from "./instance_user_roles.js"; +export { userSidebarPreferences } from "./user_sidebar_preferences.js"; export { agents } from "./agents.js"; export { boardApiKeys } from "./board_api_keys.js"; export { cliAuthChallenges } from "./cli_auth_challenges.js"; export { companyMemberships } from "./company_memberships.js"; +export { companyUserSidebarPreferences } from "./company_user_sidebar_preferences.js"; export { principalPermissionGrants } from "./principal_permission_grants.js"; export { invites } from "./invites.js"; export { joinRequests } from "./join_requests.js"; diff --git a/packages/db/src/schema/join_requests.ts b/packages/db/src/schema/join_requests.ts index 458e45d0805..3813c27db78 100644 --- a/packages/db/src/schema/join_requests.ts +++ b/packages/db/src/schema/join_requests.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; import { invites } from "./invites.js"; @@ -37,5 +38,11 @@ export const joinRequests = pgTable( table.requestType, table.createdAt, ), + pendingHumanUserUniqueIdx: uniqueIndex("join_requests_pending_human_user_uq") + .on(table.companyId, table.requestingUserId) + .where(sql`${table.requestType} = 'human' AND ${table.status} = 'pending_approval' AND ${table.requestingUserId} IS NOT NULL`), + pendingHumanEmailUniqueIdx: uniqueIndex("join_requests_pending_human_email_uq") + .on(table.companyId, sql`lower(${table.requestEmailSnapshot})`) + .where(sql`${table.requestType} = 'human' AND ${table.status} = 'pending_approval' AND ${table.requestEmailSnapshot} IS NOT NULL`), }), ); diff --git a/packages/db/src/schema/user_sidebar_preferences.ts b/packages/db/src/schema/user_sidebar_preferences.ts new file mode 100644 index 00000000000..759f4718bbd --- /dev/null +++ b/packages/db/src/schema/user_sidebar_preferences.ts @@ -0,0 +1,15 @@ +import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core"; + +export const userSidebarPreferences = pgTable( + "user_sidebar_preferences", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id").notNull(), + companyOrder: jsonb("company_order").$type().notNull().default([]), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + userUq: uniqueIndex("user_sidebar_preferences_user_uq").on(table.userId), + }), +); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 0414bda1b54..ea3278be925 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -13,6 +13,7 @@ export const API = { activity: `${API_PREFIX}/activity`, dashboard: `${API_PREFIX}/dashboard`, sidebarBadges: `${API_PREFIX}/sidebar-badges`, + sidebarPreferences: `${API_PREFIX}/sidebar-preferences`, invites: `${API_PREFIX}/invites`, joinRequests: `${API_PREFIX}/join-requests`, members: `${API_PREFIX}/members`, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index c25c87f5674..ec1c0936518 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -66,6 +66,8 @@ export const AGENT_ROLE_LABELS: Record = { general: "General", }; +export const AGENT_DEFAULT_MAX_CONCURRENT_RUNS = 5; + export const AGENT_ICON_NAMES = [ "bot", "cpu", @@ -141,6 +143,16 @@ export type IssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number]; export const ISSUE_RELATION_TYPES = ["blocks"] as const; export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number]; +export const ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY = "continuation-summary" as const; +export const SYSTEM_ISSUE_DOCUMENT_KEYS = [ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY] as const; +export type SystemIssueDocumentKey = (typeof SYSTEM_ISSUE_DOCUMENT_KEYS)[number]; + +const SYSTEM_ISSUE_DOCUMENT_KEY_SET = new Set(SYSTEM_ISSUE_DOCUMENT_KEYS); + +export function isSystemIssueDocumentKey(key: string): key is SystemIssueDocumentKey { + return SYSTEM_ISSUE_DOCUMENT_KEY_SET.has(key); +} + export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const; export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number]; @@ -343,6 +355,17 @@ export const HEARTBEAT_RUN_STATUSES = [ ] as const; export type HeartbeatRunStatus = (typeof HEARTBEAT_RUN_STATUSES)[number]; +export const RUN_LIVENESS_STATES = [ + "completed", + "advanced", + "plan_only", + "empty_response", + "blocked", + "failed", + "needs_followup", +] as const; +export type RunLivenessState = (typeof RUN_LIVENESS_STATES)[number]; + export const LIVE_EVENT_TYPES = [ "heartbeat.run.queued", "heartbeat.run.status", @@ -359,9 +382,33 @@ export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number]; export const PRINCIPAL_TYPES = ["user", "agent"] as const; export type PrincipalType = (typeof PRINCIPAL_TYPES)[number]; -export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended"] as const; +export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended", "archived"] as const; export type MembershipStatus = (typeof MEMBERSHIP_STATUSES)[number]; +export const COMPANY_MEMBERSHIP_ROLES = [ + "owner", + "admin", + "operator", + "viewer", + "member", +] as const; +export type CompanyMembershipRole = (typeof COMPANY_MEMBERSHIP_ROLES)[number]; + +export const HUMAN_COMPANY_MEMBERSHIP_ROLES = [ + "owner", + "admin", + "operator", + "viewer", +] as const; +export type HumanCompanyMembershipRole = (typeof HUMAN_COMPANY_MEMBERSHIP_ROLES)[number]; + +export const HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS: Record = { + owner: "Owner", + admin: "Admin", + operator: "Operator", + viewer: "Viewer", +}; + export const INSTANCE_USER_ROLES = ["instance_admin"] as const; export type InstanceUserRole = (typeof INSTANCE_USER_ROLES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6d34055ca82..64ab0092c85 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,6 +9,7 @@ export { AGENT_ADAPTER_TYPES, AGENT_ROLES, AGENT_ROLE_LABELS, + AGENT_DEFAULT_MAX_CONCURRENT_RUNS, AGENT_ICON_NAMES, ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUSES, @@ -16,6 +17,9 @@ export { ISSUE_PRIORITIES, ISSUE_ORIGIN_KINDS, ISSUE_RELATION_TYPES, + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + SYSTEM_ISSUE_DOCUMENT_KEYS, + isSystemIssueDocumentKey, ISSUE_EXECUTION_POLICY_MODES, ISSUE_EXECUTION_STAGE_TYPES, ISSUE_EXECUTION_STATE_STATUSES, @@ -49,11 +53,15 @@ export { BUDGET_INCIDENT_RESOLUTION_ACTIONS, HEARTBEAT_INVOCATION_SOURCES, HEARTBEAT_RUN_STATUSES, + RUN_LIVENESS_STATES, WAKEUP_TRIGGER_DETAILS, WAKEUP_REQUEST_STATUSES, LIVE_EVENT_TYPES, PRINCIPAL_TYPES, MEMBERSHIP_STATUSES, + COMPANY_MEMBERSHIP_ROLES, + HUMAN_COMPANY_MEMBERSHIP_ROLES, + HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, INSTANCE_USER_ROLES, INVITE_TYPES, INVITE_JOIN_TYPES, @@ -112,6 +120,7 @@ export { type IssuePriority, type IssueOriginKind, type IssueRelationType, + type SystemIssueDocumentKey, type IssueExecutionPolicyMode, type IssueExecutionStageType, type IssueExecutionStateStatus, @@ -144,11 +153,14 @@ export { type BudgetIncidentResolutionAction, type HeartbeatInvocationSource, type HeartbeatRunStatus, + type RunLivenessState, type WakeupTriggerDetail, type WakeupRequestStatus, type LiveEventType, type PrincipalType, type MembershipStatus, + type CompanyMembershipRole, + type HumanCompanyMembershipRole, type InstanceUserRole, type InviteType, type InviteJoinType, @@ -268,6 +280,7 @@ export type { ProjectGoalRef, ProjectWorkspace, ExecutionWorkspace, + ExecutionWorkspaceSummary, ExecutionWorkspaceConfig, ExecutionWorkspaceCloseAction, ExecutionWorkspaceCloseActionKind, @@ -276,7 +289,11 @@ export type { ExecutionWorkspaceCloseReadiness, ExecutionWorkspaceCloseReadinessState, ProjectWorkspaceRuntimeConfig, + WorkspaceCommandDefinition, + WorkspaceCommandKind, + WorkspaceRuntimeControlTarget, WorkspaceRuntimeService, + WorkspaceRuntimeServiceStateMap, WorkspaceOperation, WorkspaceOperationPhase, WorkspaceOperationStatus, @@ -388,13 +405,32 @@ export type { LiveEvent, DashboardSummary, ActivityEvent, + UserProfileActivitySummary, + UserProfileAgentUsage, + UserProfileDailyPoint, + UserProfileIdentity, + UserProfileIssueSummary, + UserProfileProviderUsage, + UserProfileResponse, + UserProfileWindowStats, SidebarBadges, + SidebarOrderPreference, InboxDismissal, + AccessUserProfile, + CompanyMemberRecord, + CompanyMembersResponse, CompanyMembership, + CompanyInviteListResponse, + CompanyInviteRecord, PrincipalPermissionGrant, Invite, JoinRequest, + JoinRequestInviteSummary, + JoinRequestRecord, InstanceUserRoleGrant, + AdminUserDirectoryEntry, + UserCompanyAccessEntry, + UserCompanyAccessResponse, CompanyPortabilityInclude, CompanyPortabilityEnvInput, CompanyPortabilityFileEntry, @@ -463,6 +499,21 @@ export type { ProviderQuotaResult, } from "./types/index.js"; +export { + sidebarOrderPreferenceSchema, + upsertSidebarOrderPreferenceSchema, + type UpsertSidebarOrderPreference, +} from "./validators/sidebar-preferences.js"; + +export { workspaceRuntimeControlTargetSchema } from "./validators/execution-workspace.js"; +export { + findWorkspaceCommandDefinition, + listWorkspaceCommandDefinitions, + listWorkspaceServiceCommandDefinitions, + matchWorkspaceRuntimeServiceToCommand, + scoreWorkspaceRuntimeServiceMatch, +} from "./workspace-commands.js"; + export { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, FEEDBACK_TARGET_TYPES, @@ -547,6 +598,7 @@ export { type UpdateProjectWorkspace, projectExecutionWorkspacePolicySchema, createIssueSchema, + createChildIssueSchema, createIssueLabelSchema, updateIssueSchema, issueExecutionPolicySchema, @@ -574,6 +626,7 @@ export { upsertIssueDocumentSchema, restoreIssueDocumentRevisionSchema, type CreateIssue, + type CreateChildIssue, type CreateIssueLabel, type UpdateIssue, type CheckoutIssue, @@ -671,12 +724,20 @@ export { createCompanyInviteSchema, createOpenClawInvitePromptSchema, acceptInviteSchema, + listCompanyInvitesQuerySchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, boardCliAuthAccessLevelSchema, createCliAuthChallengeSchema, resolveCliAuthChallengeSchema, + currentUserProfileSchema, + authSessionSchema, + updateCurrentUserProfileSchema, + updateCompanyMemberSchema, + updateCompanyMemberWithPermissionsSchema, + archiveCompanyMemberSchema, updateMemberPermissionsSchema, + searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, type CreateCostEvent, type CreateFinanceEvent, @@ -713,12 +774,20 @@ export { type CreateCompanyInvite, type CreateOpenClawInvitePrompt, type AcceptInvite, + type ListCompanyInvitesQuery, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, type BoardCliAuthAccessLevel, type CreateCliAuthChallenge, type ResolveCliAuthChallenge, + type CurrentUserProfile, + type AuthSession, + type UpdateCurrentUserProfile, + type UpdateCompanyMember, + type UpdateCompanyMemberWithPermissions, + type ArchiveCompanyMember, type UpdateMemberPermissions, + type SearchAdminUsersQuery, type UpdateUserCompanyAccess, companySkillSourceTypeSchema, companySkillTrustLevelSchema, @@ -798,18 +867,23 @@ export { AGENT_MENTION_SCHEME, PROJECT_MENTION_SCHEME, SKILL_MENTION_SCHEME, + USER_MENTION_SCHEME, buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref, + buildUserMentionHref, extractAgentMentionIds, + extractProjectMentionIds, extractSkillMentionIds, + extractUserMentionIds, parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref, - extractProjectMentionIds, + parseUserMentionHref, type ParsedAgentMention, type ParsedProjectMention, type ParsedSkillMention, + type ParsedUserMention, } from "./project-mentions.js"; export { diff --git a/packages/shared/src/project-mentions.test.ts b/packages/shared/src/project-mentions.test.ts index 5a156959a98..709fd27d6ed 100644 --- a/packages/shared/src/project-mentions.test.ts +++ b/packages/shared/src/project-mentions.test.ts @@ -3,12 +3,15 @@ import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref, + buildUserMentionHref, extractAgentMentionIds, extractProjectMentionIds, extractSkillMentionIds, + extractUserMentionIds, parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref, + parseUserMentionHref, } from "./project-mentions.js"; describe("project-mentions", () => { @@ -30,6 +33,14 @@ describe("project-mentions", () => { expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]); }); + it("round-trips user mentions", () => { + const href = buildUserMentionHref("user-123"); + expect(parseUserMentionHref(href)).toEqual({ + userId: "user-123", + }); + expect(extractUserMentionIds(`[@Taylor](${href})`)).toEqual(["user-123"]); + }); + it("round-trips skill mentions with slug metadata", () => { const href = buildSkillMentionHref("skill-123", "release-changelog"); expect(parseSkillMentionHref(href)).toEqual({ diff --git a/packages/shared/src/project-mentions.ts b/packages/shared/src/project-mentions.ts index 117fad39474..b2d3ee93169 100644 --- a/packages/shared/src/project-mentions.ts +++ b/packages/shared/src/project-mentions.ts @@ -1,5 +1,6 @@ export const PROJECT_MENTION_SCHEME = "project://"; export const AGENT_MENTION_SCHEME = "agent://"; +export const USER_MENTION_SCHEME = "user://"; export const SKILL_MENTION_SCHEME = "skill://"; const HEX_COLOR_RE = /^[0-9a-f]{6}$/i; @@ -8,6 +9,7 @@ const HEX_COLOR_WITH_HASH_RE = /^#[0-9a-f]{6}$/i; const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i; const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi; const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi; +const USER_MENTION_LINK_RE = /\[[^\]]*]\((user:\/\/[^)\s]+)\)/gi; const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi; const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i; const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i; @@ -22,6 +24,10 @@ export interface ParsedAgentMention { icon: string | null; } +export interface ParsedUserMention { + userId: string; +} + export interface ParsedSkillMention { skillId: string; slug: string | null; @@ -111,6 +117,28 @@ export function parseAgentMentionHref(href: string): ParsedAgentMention | null { }; } +export function buildUserMentionHref(userId: string): string { + return `${USER_MENTION_SCHEME}${userId.trim()}`; +} + +export function parseUserMentionHref(href: string): ParsedUserMention | null { + if (!href.startsWith(USER_MENTION_SCHEME)) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + + if (url.protocol !== "user:") return null; + + const userId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim(); + if (!userId) return null; + + return { userId }; +} + export function buildSkillMentionHref(skillId: string, slug?: string | null): string { const trimmedSkillId = skillId.trim(); const normalizedSlug = normalizeSkillSlug(slug ?? null); @@ -165,6 +193,18 @@ export function extractAgentMentionIds(markdown: string): string[] { return [...ids]; } +export function extractUserMentionIds(markdown: string): string[] { + if (!markdown) return []; + const ids = new Set(); + const re = new RegExp(USER_MENTION_LINK_RE); + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + const parsed = parseUserMentionHref(match[1]); + if (parsed) ids.add(parsed.userId); + } + return [...ids]; +} + export function extractSkillMentionIds(markdown: string): string[] { if (!markdown) return []; const ids = new Set(); diff --git a/packages/shared/src/telemetry/events.ts b/packages/shared/src/telemetry/events.ts index 6b30995ed22..1b347f3cd1f 100644 --- a/packages/shared/src/telemetry/events.ts +++ b/packages/shared/src/telemetry/events.ts @@ -50,9 +50,12 @@ export function trackGoalCreated( export function trackAgentCreated( client: TelemetryClient, - dims: { agentRole: string }, + dims: { agentRole: string; agentId?: string }, ): void { - client.track("agent.created", { agent_role: dims.agentRole }); + client.track("agent.created", { + agent_role: dims.agentRole, + ...(dims.agentId ? { agent_id: dims.agentId } : {}), + }); } export function trackSkillImported( @@ -67,16 +70,24 @@ export function trackSkillImported( export function trackAgentFirstHeartbeat( client: TelemetryClient, - dims: { agentRole: string }, + dims: { agentRole: string; agentId?: string }, ): void { - client.track("agent.first_heartbeat", { agent_role: dims.agentRole }); + client.track("agent.first_heartbeat", { + agent_role: dims.agentRole, + ...(dims.agentId ? { agent_id: dims.agentId } : {}), + }); } export function trackAgentTaskCompleted( client: TelemetryClient, - dims: { agentRole: string }, + dims: { agentRole: string; agentId?: string; adapterType?: string; model?: string }, ): void { - client.track("agent.task_completed", { agent_role: dims.agentRole }); + client.track("agent.task_completed", { + agent_role: dims.agentRole, + ...(dims.agentId ? { agent_id: dims.agentId } : {}), + ...(dims.adapterType ? { adapter_type: dims.adapterType } : {}), + ...(dims.model ? { model: dims.model } : {}), + }); } export function trackErrorHandlerCrash( diff --git a/packages/shared/src/types/access.ts b/packages/shared/src/types/access.ts index db6554eea2e..015d51792da 100644 --- a/packages/shared/src/types/access.ts +++ b/packages/shared/src/types/access.ts @@ -1,5 +1,7 @@ import type { AgentAdapterType, + CompanyStatus, + HumanCompanyMembershipRole, InstanceUserRole, InviteJoinType, InviteType, @@ -33,6 +35,39 @@ export interface PrincipalPermissionGrant { updatedAt: Date; } +export interface AccessUserProfile { + id: string; + email: string | null; + name: string | null; + image: string | null; +} + +export interface CompanyMemberRecord extends CompanyMembership { + principalType: "user"; + membershipRole: HumanCompanyMembershipRole | null; + user: AccessUserProfile | null; + grants: PrincipalPermissionGrant[]; + removal?: { + canArchive: boolean; + reason: string | null; + }; +} + +export interface CompanyMembersResponse { + members: CompanyMemberRecord[]; + access: { + currentUserRole: HumanCompanyMembershipRole | null; + canManageMembers: boolean; + canInviteUsers: boolean; + canApproveJoinRequests: boolean; + }; +} + +export interface ArchiveCompanyMemberResponse { + member: CompanyMemberRecord; + reassignedIssueCount: number; +} + export interface Invite { id: string; companyId: string | null; @@ -48,6 +83,22 @@ export interface Invite { updatedAt: Date; } +export type InviteState = "active" | "revoked" | "accepted" | "expired"; + +export interface CompanyInviteRecord extends Invite { + companyName: string | null; + humanRole: HumanCompanyMembershipRole | null; + inviteMessage: string | null; + state: InviteState; + invitedByUser: AccessUserProfile | null; + relatedJoinRequestId: string | null; +} + +export interface CompanyInviteListResponse { + invites: CompanyInviteRecord[]; + nextOffset: number | null; +} + export interface JoinRequest { id: string; inviteId: string; @@ -72,6 +123,26 @@ export interface JoinRequest { updatedAt: Date; } +export interface JoinRequestInviteSummary { + id: string; + inviteType: InviteType; + allowedJoinTypes: InviteJoinType; + humanRole: HumanCompanyMembershipRole | null; + inviteMessage: string | null; + createdAt: Date; + expiresAt: Date; + revokedAt: Date | null; + acceptedAt: Date | null; + invitedByUser: AccessUserProfile | null; +} + +export interface JoinRequestRecord extends JoinRequest { + requesterUser: AccessUserProfile | null; + approvedByUser: AccessUserProfile | null; + rejectedByUser: AccessUserProfile | null; + invite: JoinRequestInviteSummary | null; +} + export interface InstanceUserRoleGrant { id: string; userId: string; @@ -79,3 +150,21 @@ export interface InstanceUserRoleGrant { createdAt: Date; updatedAt: Date; } + +export interface AdminUserDirectoryEntry extends AccessUserProfile { + isInstanceAdmin: boolean; + activeCompanyMembershipCount: number; +} + +export interface UserCompanyAccessEntry extends CompanyMembership { + principalType: "user"; + companyName: string | null; + companyStatus: CompanyStatus | null; +} + +export interface UserCompanyAccessResponse { + user: (AccessUserProfile & { + isInstanceAdmin: boolean; + }) | null; + companyAccess: UserCompanyAccessEntry[]; +} diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts index 6115f04976e..51d15d42623 100644 --- a/packages/shared/src/types/heartbeat.ts +++ b/packages/shared/src/types/heartbeat.ts @@ -3,6 +3,7 @@ import type { AgentStatus, HeartbeatInvocationSource, HeartbeatRunStatus, + RunLivenessState, WakeupTriggerDetail, WakeupRequestStatus, } from "../constants.js"; @@ -38,6 +39,11 @@ export interface HeartbeatRun { processStartedAt: Date | null; retryOfRunId: string | null; processLossRetryCount: number; + livenessState: RunLivenessState | null; + livenessReason: string | null; + continuationAttempt: number; + lastUsefulActionAt: Date | null; + nextAction: string | null; contextSnapshot: Record | null; createdAt: Date; updatedAt: Date; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 1591e4fbb34..65882818083 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -63,6 +63,7 @@ export type { AssetImage } from "./asset.js"; export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js"; export type { ExecutionWorkspace, + ExecutionWorkspaceSummary, ExecutionWorkspaceConfig, ExecutionWorkspaceCloseAction, ExecutionWorkspaceCloseActionKind, @@ -71,7 +72,11 @@ export type { ExecutionWorkspaceCloseReadiness, ExecutionWorkspaceCloseReadinessState, ProjectWorkspaceRuntimeConfig, + WorkspaceCommandDefinition, + WorkspaceCommandKind, + WorkspaceRuntimeControlTarget, WorkspaceRuntimeService, + WorkspaceRuntimeServiceStateMap, WorkspaceRuntimeDesiredState, ExecutionWorkspaceStrategyType, ExecutionWorkspaceMode, @@ -213,14 +218,36 @@ export type { export type { LiveEvent } from "./live.js"; export type { DashboardSummary } from "./dashboard.js"; export type { ActivityEvent } from "./activity.js"; +export type { + UserProfileActivitySummary, + UserProfileAgentUsage, + UserProfileDailyPoint, + UserProfileIdentity, + UserProfileIssueSummary, + UserProfileProviderUsage, + UserProfileResponse, + UserProfileWindowStats, +} from "./user-profile.js"; export type { SidebarBadges } from "./sidebar-badges.js"; +export type { SidebarOrderPreference } from "./sidebar-preferences.js"; export type { InboxDismissal } from "./inbox-dismissal.js"; export type { + AccessUserProfile, + CompanyMemberRecord, + CompanyMembersResponse, + ArchiveCompanyMemberResponse, CompanyMembership, + CompanyInviteListResponse, + CompanyInviteRecord, PrincipalPermissionGrant, Invite, JoinRequest, + JoinRequestInviteSummary, + JoinRequestRecord, InstanceUserRoleGrant, + AdminUserDirectoryEntry, + UserCompanyAccessEntry, + UserCompanyAccessResponse, } from "./access.js"; export type { QuotaWindow, ProviderQuotaResult } from "./quota.js"; export type { diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 6bd7da1b0ed..2aa50361557 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -1,10 +1,10 @@ import type { PauseReason, ProjectStatus } from "../constants.js"; -import type { AgentEnvConfig } from "./secrets.js"; import type { ProjectExecutionWorkspacePolicy, ProjectWorkspaceRuntimeConfig, WorkspaceRuntimeService, } from "./workspace-runtime.js"; +import type { AgentEnvConfig } from "./secrets.js"; export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path"; export type ProjectWorkspaceVisibility = "default" | "advanced"; diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index 4efdc748c64..c25ecb358e9 100644 --- a/packages/shared/src/types/routine.ts +++ b/packages/shared/src/types/routine.ts @@ -129,7 +129,7 @@ export interface RoutineExecutionIssueOrigin { } export interface RoutineListItem extends Routine { - triggers: Pick[]; + triggers: Pick[]; lastRun: RoutineRunSummary | null; activeIssue: RoutineIssueSummary | null; } diff --git a/packages/shared/src/types/sidebar-preferences.ts b/packages/shared/src/types/sidebar-preferences.ts new file mode 100644 index 00000000000..457b5f0bee9 --- /dev/null +++ b/packages/shared/src/types/sidebar-preferences.ts @@ -0,0 +1,4 @@ +export interface SidebarOrderPreference { + orderedIds: string[]; + updatedAt: Date | null; +} diff --git a/packages/shared/src/types/user-profile.ts b/packages/shared/src/types/user-profile.ts new file mode 100644 index 00000000000..2ed483da1b2 --- /dev/null +++ b/packages/shared/src/types/user-profile.ts @@ -0,0 +1,88 @@ +import type { IssuePriority, IssueStatus } from "../constants.js"; + +export interface UserProfileIdentity { + id: string; + slug: string; + name: string | null; + email: string | null; + image: string | null; + membershipRole: string | null; + membershipStatus: string; + joinedAt: Date; +} + +export interface UserProfileWindowStats { + key: "last7" | "last30" | "all"; + label: string; + touchedIssues: number; + createdIssues: number; + completedIssues: number; + assignedOpenIssues: number; + commentCount: number; + activityCount: number; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + costEventCount: number; +} + +export interface UserProfileDailyPoint { + date: string; + activityCount: number; + completedIssues: number; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +} + +export interface UserProfileIssueSummary { + id: string; + identifier: string | null; + title: string; + status: IssueStatus; + priority: IssuePriority; + assigneeAgentId: string | null; + assigneeUserId: string | null; + updatedAt: Date; + completedAt: Date | null; +} + +export interface UserProfileActivitySummary { + id: string; + action: string; + entityType: string; + entityId: string; + details: Record | null; + createdAt: Date; +} + +export interface UserProfileAgentUsage { + agentId: string; + agentName: string | null; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +} + +export interface UserProfileProviderUsage { + provider: string; + biller: string; + model: string; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +} + +export interface UserProfileResponse { + user: UserProfileIdentity; + stats: UserProfileWindowStats[]; + daily: UserProfileDailyPoint[]; + recentIssues: UserProfileIssueSummary[]; + recentActivity: UserProfileActivitySummary[]; + topAgents: UserProfileAgentUsage[]; + topProviders: UserProfileProviderUsage[]; +} diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 2b2c4e2d433..4e6b01cdace 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -46,6 +46,27 @@ export type ExecutionWorkspaceCloseActionKind = | "remove_local_directory"; export type WorkspaceRuntimeDesiredState = "running" | "stopped"; +export type WorkspaceRuntimeServiceStateMap = Record; +export type WorkspaceCommandKind = "service" | "job"; + +export interface WorkspaceCommandSource { + type: "paperclip"; + key: "commands" | "services" | "jobs"; + index: number; +} + +export interface WorkspaceCommandDefinition { + id: string; + name: string; + kind: WorkspaceCommandKind; + command: string | null; + cwd: string | null; + lifecycle: "shared" | "ephemeral" | null; + serviceIndex: number | null; + disabledReason: string | null; + rawConfig: Record; + source: WorkspaceCommandSource; +} export interface ExecutionWorkspaceStrategy { type: ExecutionWorkspaceStrategyType; @@ -62,11 +83,19 @@ export interface ExecutionWorkspaceConfig { cleanupCommand: string | null; workspaceRuntime: Record | null; desiredState: WorkspaceRuntimeDesiredState | null; + serviceStates?: WorkspaceRuntimeServiceStateMap | null; } export interface ProjectWorkspaceRuntimeConfig { workspaceRuntime: Record | null; desiredState: WorkspaceRuntimeDesiredState | null; + serviceStates?: WorkspaceRuntimeServiceStateMap | null; +} + +export interface WorkspaceRuntimeControlTarget { + workspaceCommandId?: string | null; + runtimeServiceId?: string | null; + serviceIndex?: number | null; } export interface ExecutionWorkspaceCloseAction { @@ -132,6 +161,13 @@ export interface IssueExecutionWorkspaceSettings { workspaceRuntime?: Record | null; } +export interface ExecutionWorkspaceSummary { + id: string; + name: string; + mode: Exclude | "adapter_managed" | "cloud_sandbox"; + projectWorkspaceId: string | null; +} + export interface ExecutionWorkspace { id: string; companyId: string; @@ -187,6 +223,7 @@ export interface WorkspaceRuntimeService { stoppedAt: Date | null; stopPolicy: Record | null; healthStatus: "unknown" | "healthy" | "unhealthy"; + configIndex?: number | null; createdAt: Date; updatedAt: Date; } diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 6da95c126e2..e104bdec7ca 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -1,5 +1,7 @@ import { z } from "zod"; import { + AGENT_ADAPTER_TYPES, + HUMAN_COMPANY_MEMBERSHIP_ROLES, INVITE_JOIN_TYPES, JOIN_REQUEST_STATUSES, JOIN_REQUEST_TYPES, @@ -9,6 +11,7 @@ import { optionalAgentAdapterTypeSchema } from "../adapter-type.js"; export const createCompanyInviteSchema = z.object({ allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"), + humanRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(), defaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(), agentMessage: z.string().max(4000).optional().nullable(), }); @@ -46,6 +49,14 @@ export const listJoinRequestsQuerySchema = z.object({ export type ListJoinRequestsQuery = z.infer; +export const listCompanyInvitesQuerySchema = z.object({ + state: z.enum(["active", "revoked", "accepted", "expired"]).optional(), + limit: z.coerce.number().int().min(1).max(100).optional().default(20), + offset: z.coerce.number().int().min(0).optional().default(0), +}); + +export type ListCompanyInvitesQuery = z.infer; + export const claimJoinRequestApiKeySchema = z.object({ claimSecret: z.string().min(16).max(256), }); @@ -85,8 +96,104 @@ export const updateMemberPermissionsSchema = z.object({ export type UpdateMemberPermissions = z.infer; +const editableMembershipStatuses = ["pending", "active", "suspended"] as const; + +export const updateCompanyMemberSchema = z.object({ + membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(), + status: z.enum(editableMembershipStatuses).optional(), +}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, { + message: "membershipRole or status is required", +}); + +export type UpdateCompanyMember = z.infer; + +export const updateCompanyMemberWithPermissionsSchema = z.object({ + membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(), + status: z.enum(editableMembershipStatuses).optional(), + grants: updateMemberPermissionsSchema.shape.grants.default([]), +}).refine((value) => value.membershipRole !== undefined || value.status !== undefined, { + message: "membershipRole or status is required", +}); + +export type UpdateCompanyMemberWithPermissions = z.infer; + +export const archiveCompanyMemberSchema = z.object({ + reassignment: z + .object({ + assigneeAgentId: z.string().uuid().optional().nullable(), + assigneeUserId: z.string().uuid().optional().nullable(), + }) + .optional() + .nullable(), +}).superRefine((value, ctx) => { + if (value.reassignment?.assigneeAgentId && value.reassignment.assigneeUserId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Choose either an agent or user reassignment target", + path: ["reassignment"], + }); + } +}); + +export type ArchiveCompanyMember = z.infer; + export const updateUserCompanyAccessSchema = z.object({ companyIds: z.array(z.string().uuid()).default([]), }); export type UpdateUserCompanyAccess = z.infer; + +export const searchAdminUsersQuerySchema = z.object({ + query: z.string().trim().max(120).optional().default(""), +}); + +export type SearchAdminUsersQuery = z.infer; + +const profileImageAssetPathPattern = /^\/api\/assets\/[^/?#]+\/content(?:\?[^#]*)?(?:#.*)?$/; + +function isValidProfileImage(value: string): boolean { + if (profileImageAssetPathPattern.test(value)) return true; + + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:"; + } catch { + return false; + } +} + +const profileImageSchema = z + .string() + .trim() + .min(1) + .max(4000) + .refine(isValidProfileImage, { message: "Invalid profile image URL" }); + +export const currentUserProfileSchema = z.object({ + id: z.string().min(1), + email: z.string().email().nullable(), + name: z.string().min(1).max(120).nullable(), + image: profileImageSchema.nullable(), +}); + +export type CurrentUserProfile = z.infer; + +export const authSessionSchema = z.object({ + session: z.object({ + id: z.string().min(1), + userId: z.string().min(1), + }), + user: currentUserProfileSchema, +}); + +export type AuthSession = z.infer; + +export const updateCurrentUserProfileSchema = z.object({ + name: z.string().trim().min(1).max(120), + image: z + .union([profileImageSchema, z.literal(""), z.null()]) + .optional() + .transform((value) => value === "" ? null : value), +}); + +export type UpdateCurrentUserProfile = z.infer; diff --git a/packages/shared/src/validators/approval.ts b/packages/shared/src/validators/approval.ts index d6a6cf0d575..ae6b2eb3b30 100644 --- a/packages/shared/src/validators/approval.ts +++ b/packages/shared/src/validators/approval.ts @@ -12,14 +12,12 @@ export type CreateApproval = z.infer; export const resolveApprovalSchema = z.object({ decisionNote: z.string().optional().nullable(), - decidedByUserId: z.string().optional().default("board"), }); export type ResolveApproval = z.infer; export const requestApprovalRevisionSchema = z.object({ decisionNote: z.string().optional().nullable(), - decidedByUserId: z.string().optional().default("board"), }); export type RequestApprovalRevision = z.infer; diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index 9914d74ec38..db5507e0992 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -14,6 +14,13 @@ export const executionWorkspaceConfigSchema = z.object({ cleanupCommand: z.string().optional().nullable(), workspaceRuntime: z.record(z.unknown()).optional().nullable(), desiredState: z.enum(["running", "stopped"]).optional().nullable(), + serviceStates: z.record(z.enum(["running", "stopped"])).optional().nullable(), +}).strict(); + +export const workspaceRuntimeControlTargetSchema = z.object({ + workspaceCommandId: z.string().min(1).optional().nullable(), + runtimeServiceId: z.string().uuid().optional().nullable(), + serviceIndex: z.number().int().nonnegative().optional().nullable(), }).strict(); export const executionWorkspaceCloseReadinessStateSchema = z.enum([ @@ -88,10 +95,10 @@ export const workspaceRuntimeServiceSchema = z.object({ stoppedAt: z.coerce.date().nullable(), stopPolicy: z.record(z.unknown()).nullable(), healthStatus: z.enum(["unknown", "healthy", "unhealthy"]), + configIndex: z.number().int().nonnegative().nullable().optional(), createdAt: z.coerce.date(), updatedAt: z.coerce.date(), }).strict(); - export const executionWorkspaceCloseReadinessSchema = z.object({ workspaceId: z.string().uuid(), state: executionWorkspaceCloseReadinessStateSchema, diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 75a54b79d0a..74c7cac355c 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -32,6 +32,11 @@ export { upsertIssueFeedbackVoteSchema, type UpsertIssueFeedbackVote, } from "./feedback.js"; +export { + sidebarOrderPreferenceSchema, + upsertSidebarOrderPreferenceSchema, + type UpsertSidebarOrderPreference, +} from "./sidebar-preferences.js"; export { companySkillSourceTypeSchema, companySkillTrustLevelSchema, @@ -129,6 +134,7 @@ export { export { createIssueSchema, + createChildIssueSchema, createIssueLabelSchema, updateIssueSchema, issueExecutionPolicySchema, @@ -143,6 +149,7 @@ export { upsertIssueDocumentSchema, restoreIssueDocumentRevisionSchema, type CreateIssue, + type CreateChildIssue, type CreateIssueLabel, type UpdateIssue, type IssueExecutionWorkspaceSettings, @@ -321,22 +328,38 @@ export { createCompanyInviteSchema, createOpenClawInvitePromptSchema, acceptInviteSchema, + listCompanyInvitesQuerySchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, boardCliAuthAccessLevelSchema, createCliAuthChallengeSchema, resolveCliAuthChallengeSchema, + currentUserProfileSchema, + authSessionSchema, + updateCurrentUserProfileSchema, + updateCompanyMemberSchema, + updateCompanyMemberWithPermissionsSchema, + archiveCompanyMemberSchema, updateMemberPermissionsSchema, + searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, type CreateCompanyInvite, type CreateOpenClawInvitePrompt, type AcceptInvite, + type ListCompanyInvitesQuery, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, type BoardCliAuthAccessLevel, type CreateCliAuthChallenge, type ResolveCliAuthChallenge, + type CurrentUserProfile, + type AuthSession, + type UpdateCurrentUserProfile, + type UpdateCompanyMember, + type UpdateCompanyMemberWithPermissions, + type ArchiveCompanyMember, type UpdateMemberPermissions, + type SearchAdminUsersQuery, type UpdateUserCompanyAccess, } from "./access.js"; diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 63b070a6ec7..6b11570dfb9 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -138,6 +138,18 @@ export const createIssueSchema = z.object({ export type CreateIssue = z.infer; +export const createChildIssueSchema = createIssueSchema + .omit({ + parentId: true, + inheritExecutionWorkspaceFromIssueId: true, + }) + .extend({ + acceptanceCriteria: z.array(z.string().trim().min(1).max(500)).max(20).optional(), + blockParentUntilDone: z.boolean().optional().default(false), + }); + +export type CreateChildIssue = z.infer; + export const createIssueLabelSchema = z.object({ name: z.string().trim().min(1).max(48), color: z.string().regex(/^#(?:[0-9a-fA-F]{6})$/, "Color must be a 6-digit hex value"), @@ -146,6 +158,7 @@ export const createIssueLabelSchema = z.object({ export type CreateIssueLabel = z.infer; export const updateIssueSchema = createIssueSchema.partial().extend({ + assigneeAgentId: z.string().trim().min(1).optional().nullable(), comment: z.string().min(1).optional(), reopen: z.boolean().optional(), interrupt: z.boolean().optional(), diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index f0030fb7194..8e8549ca2e1 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -31,6 +31,7 @@ export const projectExecutionWorkspacePolicySchema = z export const projectWorkspaceRuntimeConfigSchema = z.object({ workspaceRuntime: z.record(z.unknown()).optional().nullable(), desiredState: z.enum(["running", "stopped"]).optional().nullable(), + serviceStates: z.record(z.enum(["running", "stopped"])).optional().nullable(), }).strict(); const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]); diff --git a/packages/shared/src/validators/sidebar-preferences.ts b/packages/shared/src/validators/sidebar-preferences.ts new file mode 100644 index 00000000000..a8d109d53c9 --- /dev/null +++ b/packages/shared/src/validators/sidebar-preferences.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +const sidebarOrderedIdSchema = z.string().uuid(); + +export const sidebarOrderPreferenceSchema = z.object({ + orderedIds: z.array(sidebarOrderedIdSchema), + updatedAt: z.coerce.date().nullable(), +}); + +export const upsertSidebarOrderPreferenceSchema = z.object({ + orderedIds: z.array(sidebarOrderedIdSchema), +}); + +export type UpsertSidebarOrderPreference = z.infer; diff --git a/packages/shared/src/workspace-commands.test.ts b/packages/shared/src/workspace-commands.test.ts new file mode 100644 index 00000000000..9a5c610b68d --- /dev/null +++ b/packages/shared/src/workspace-commands.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + findWorkspaceCommandDefinition, + listWorkspaceCommandDefinitions, + matchWorkspaceRuntimeServiceToCommand, +} from "./workspace-commands.js"; + +describe("workspace command helpers", () => { + it("derives service and job commands from command-first runtime config", () => { + const commands = listWorkspaceCommandDefinitions({ + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev" }, + { id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" }, + ], + }); + + expect(commands).toEqual([ + expect.objectContaining({ id: "web", kind: "service", serviceIndex: 0 }), + expect.objectContaining({ id: "db-migrate", kind: "job", serviceIndex: null }), + ]); + }); + + it("falls back to legacy services and jobs arrays", () => { + const commands = listWorkspaceCommandDefinitions({ + services: [{ name: "web", command: "pnpm dev" }], + jobs: [{ name: "lint", command: "pnpm lint" }], + }); + + expect(commands).toEqual([ + expect.objectContaining({ id: "service:web", kind: "service", serviceIndex: 0 }), + expect.objectContaining({ id: "job:lint", kind: "job", serviceIndex: null }), + ]); + }); + + it("matches a configured service command to the current runtime service", () => { + const workspaceRuntime = { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev", cwd: "." }, + ], + }; + const command = findWorkspaceCommandDefinition(workspaceRuntime, "web"); + expect(command).not.toBeNull(); + + const match = matchWorkspaceRuntimeServiceToCommand(command!, [ + { + id: "runtime-web", + serviceName: "web", + command: "pnpm dev", + cwd: "/repo", + configIndex: null, + }, + ]); + + expect(match).toEqual(expect.objectContaining({ id: "runtime-web" })); + }); + + it("does not match a stale runtime service after the configured command changes", () => { + const workspaceRuntime = { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev:once --tailscale-auth", cwd: "." }, + ], + }; + const command = findWorkspaceCommandDefinition(workspaceRuntime, "web"); + expect(command).not.toBeNull(); + + const match = matchWorkspaceRuntimeServiceToCommand(command!, [ + { + id: "runtime-web", + serviceName: "web", + command: "pnpm dev", + cwd: "/repo", + configIndex: null, + }, + ]); + + expect(match).toBeNull(); + }); +}); diff --git a/packages/shared/src/workspace-commands.ts b/packages/shared/src/workspace-commands.ts new file mode 100644 index 00000000000..dd98ef3dc03 --- /dev/null +++ b/packages/shared/src/workspace-commands.ts @@ -0,0 +1,208 @@ +import type { WorkspaceCommandDefinition, WorkspaceRuntimeService } from "./types/workspace-runtime.js"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function slugify(value: string | null | undefined) { + const normalized = (value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized.length > 0 ? normalized : null; +} + +function deriveWorkspaceCommandId(input: { + kind: WorkspaceCommandDefinition["kind"]; + explicitId: string | null; + name: string; + index: number; +}) { + const explicitId = slugify(input.explicitId); + if (explicitId) return explicitId; + const nameSlug = slugify(input.name); + return nameSlug ? `${input.kind}:${nameSlug}` : `${input.kind}:${input.index + 1}`; +} + +function buildWorkspaceCommandDefinition(input: { + entry: Record; + kind: WorkspaceCommandDefinition["kind"]; + sourceKey: WorkspaceCommandDefinition["source"]["key"]; + sourceIndex: number; + serviceIndex: number | null; + fallbackName: string; +}): WorkspaceCommandDefinition { + return { + id: deriveWorkspaceCommandId({ + kind: input.kind, + explicitId: readNonEmptyString(input.entry.id), + name: + readNonEmptyString(input.entry.name) + ?? readNonEmptyString(input.entry.label) + ?? readNonEmptyString(input.entry.title) + ?? input.fallbackName, + index: input.sourceIndex, + }), + name: + readNonEmptyString(input.entry.name) + ?? readNonEmptyString(input.entry.label) + ?? readNonEmptyString(input.entry.title) + ?? input.fallbackName, + kind: input.kind, + command: readNonEmptyString(input.entry.command), + cwd: readNonEmptyString(input.entry.cwd), + lifecycle: + input.kind === "service" + ? input.entry.lifecycle === "ephemeral" + ? "ephemeral" + : "shared" + : null, + serviceIndex: input.serviceIndex, + disabledReason: readNonEmptyString(input.entry.disabledReason), + rawConfig: { ...input.entry }, + source: { + type: "paperclip", + key: input.sourceKey, + index: input.sourceIndex, + }, + }; +} + +function uniqueWorkspaceCommandId( + seen: Set, + commandId: string, + sourceKey: WorkspaceCommandDefinition["source"]["key"], + sourceIndex: number, +) { + if (!seen.has(commandId)) { + seen.add(commandId); + return commandId; + } + const fallbackId = `${commandId}-${sourceKey}-${sourceIndex + 1}`; + seen.add(fallbackId); + return fallbackId; +} + +function readCommandEntries( + workspaceRuntime: Record | null | undefined, + key: "commands" | "services" | "jobs", +) { + const raw = workspaceRuntime?.[key]; + return Array.isArray(raw) ? raw.filter((entry): entry is Record => isRecord(entry)) : []; +} + +export function listWorkspaceCommandDefinitions( + workspaceRuntime: Record | null | undefined, +): WorkspaceCommandDefinition[] { + if (!workspaceRuntime) return []; + + const commandEntries = readCommandEntries(workspaceRuntime, "commands"); + const seenIds = new Set(); + let nextServiceIndex = 0; + + const finalize = (command: WorkspaceCommandDefinition) => ({ + ...command, + id: uniqueWorkspaceCommandId(seenIds, command.id, command.source.key, command.source.index), + }); + + if (commandEntries.length > 0) { + return commandEntries.map((entry, index) => + finalize(buildWorkspaceCommandDefinition({ + entry, + kind: entry.kind === "job" ? "job" : "service", + sourceKey: "commands", + sourceIndex: index, + serviceIndex: entry.kind === "job" ? null : nextServiceIndex++, + fallbackName: entry.kind === "job" ? `Job ${index + 1}` : `Service ${index + 1}`, + }))); + } + + const serviceDefinitions = readCommandEntries(workspaceRuntime, "services").map((entry, index) => + finalize(buildWorkspaceCommandDefinition({ + entry, + kind: "service", + sourceKey: "services", + sourceIndex: index, + serviceIndex: nextServiceIndex++, + fallbackName: `Service ${index + 1}`, + }))); + const jobDefinitions = readCommandEntries(workspaceRuntime, "jobs").map((entry, index) => + finalize(buildWorkspaceCommandDefinition({ + entry, + kind: "job", + sourceKey: "jobs", + sourceIndex: index, + serviceIndex: null, + fallbackName: `Job ${index + 1}`, + }))); + + return [...serviceDefinitions, ...jobDefinitions]; +} + +export function listWorkspaceServiceCommandDefinitions( + workspaceRuntime: Record | null | undefined, +) { + return listWorkspaceCommandDefinitions(workspaceRuntime).filter((command) => command.kind === "service"); +} + +export function findWorkspaceCommandDefinition( + workspaceRuntime: Record | null | undefined, + workspaceCommandId: string | null | undefined, +) { + const normalizedId = readNonEmptyString(workspaceCommandId); + if (!normalizedId) return null; + return listWorkspaceCommandDefinitions(workspaceRuntime).find((command) => command.id === normalizedId) ?? null; +} + +export function scoreWorkspaceRuntimeServiceMatch( + command: Pick, + runtimeService: Pick, +) { + if (command.command && runtimeService.command && runtimeService.command !== command.command) { + return -1; + } + + if (command.serviceIndex !== null && runtimeService.configIndex !== null && runtimeService.configIndex !== undefined) { + return runtimeService.configIndex === command.serviceIndex ? 100 : -1; + } + + let score = 0; + if (runtimeService.serviceName === command.name) score += 4; + if ((runtimeService.command ?? null) === (command.command ?? null)) score += 4; + if ( + command.cwd + && runtimeService.cwd + && (runtimeService.cwd === command.cwd || runtimeService.cwd.endsWith(`/${command.cwd}`)) + ) { + score += 2; + } + return score; +} + +export function matchWorkspaceRuntimeServiceToCommand< + T extends Pick, +>( + command: Pick, + runtimeServices: T[] | null | undefined, +) { + let bestMatch: T | null = null; + let bestScore = -1; + + for (const runtimeService of runtimeServices ?? []) { + const score = scoreWorkspaceRuntimeServiceMatch(command, runtimeService); + if (score > bestScore) { + bestMatch = runtimeService; + bestScore = score; + } + } + + return bestScore > 0 ? bestMatch : null; +} diff --git a/releases/v2026.414.0.md b/releases/v2026.414.0.md new file mode 100644 index 00000000000..d1c28635209 --- /dev/null +++ b/releases/v2026.414.0.md @@ -0,0 +1,107 @@ +# v2026.414.0 + +> Released: 2026-04-14 + +## Security + +- **Authorization hardening (GHSA-68qg-g8mg-6pr7)** — Scoped import, approval, activity, and heartbeat API routes to enforce proper authorization checks. Previously, certain administrative endpoints were accessible without adequate permission verification. All users are strongly encouraged to upgrade. ([#3315](https://github.com/paperclipai/paperclip/pull/3315), [#3009](https://github.com/paperclipai/paperclip/pull/3009), @KhairulA) +- **Removed hardcoded JWT secret fallback** — The `createBetterAuthInstance` function no longer falls back to a hardcoded JWT secret, closing a credential-hygiene gap. ([#3124](https://github.com/paperclipai/paperclip/pull/3124), @cleanunicorn) +- **Redact Bearer tokens in logs** — Server log output now redacts Bearer tokens to prevent accidental credential exposure. +- **Dependency bumps** — Updated `multer` to 2.1.1 (HIGH CVEs) and `rollup` to 4.59.0 (path-traversal CVE). ([#2909](https://github.com/paperclipai/paperclip/pull/2909), @marysomething99-prog) + +## Highlights + +- **Multi-user access and invites** — Full multi-user authentication, company roles, and invite management. Board users can create invite links, approve join requests, and manage member roles. Invite flows support auto-accept for signed-in users, paginated history, and human-readable requester identities in approval views. +- **Human user identities everywhere** — Human users now appear with real names and avatars across activity feeds, issue tables, assignee pickers, and @-mention menus. A lightweight user directory endpoint powers consistent identity resolution across the UI. +- **Issue chat thread** — Replaced the classic comment timeline with a full chat-style thread powered by assistant-ui. Agent run transcripts, chain-of-thought, and user messages render inline as a continuous conversation with polished avatars, action bars, and relative timestamps. ([#3079](https://github.com/paperclipai/paperclip/pull/3079)) +- **External adapter plugin system** — Third-party adapters can now be installed as npm packages or loaded from local directories. Plugins declare a config schema and an optional UI transcript parser; built-in adapters can be overridden by external ones. Includes Hermes local session management and provider/model display in run details. ([#2649](https://github.com/paperclipai/paperclip/pull/2649), [#2650](https://github.com/paperclipai/paperclip/pull/2650), [#2651](https://github.com/paperclipai/paperclip/pull/2651), [#2654](https://github.com/paperclipai/paperclip/pull/2654), [#2655](https://github.com/paperclipai/paperclip/pull/2655), [#2659](https://github.com/paperclipai/paperclip/pull/2659), @plind-dm) +- **Execution policies** — Issues can carry a review/approval execution policy with multi-stage signoff workflows. Reviewers and approvers are selected per-stage, and Paperclip routes the issue through each stage automatically. ([#3222](https://github.com/paperclipai/paperclip/pull/3222)) +- **Blocker dependencies** — First-class issue blocker relations with automatic wake-on-dependency-resolved. Set `blockedByIssueIds` on any issue and Paperclip wakes the assignee when all blockers reach `done`. ([#2797](https://github.com/paperclipai/paperclip/pull/2797)) +- **Standalone MCP server** — New `@paperclipai/mcp-server` package exposing the Paperclip API as an MCP tool server, including approval creation. ([#2435](https://github.com/paperclipai/paperclip/pull/2435)) + +## Improvements + +- **Invite UX polish** — Auto-submit for signed-in invites, inline auth flow, paginated invite history, requester identity in join approvals, and prevention of duplicate join requests and member re-invites. +- **Board approvals** — Generic issue-linked board approvals with card styling and visibility improvements in the issue detail sidebar. ([#3220](https://github.com/paperclipai/paperclip/pull/3220)) +- **Inbox parent-child nesting** — Parent issues group their children in the inbox Mine view with a toggle button, j/k keyboard traversal across nested items, and collapsible groups. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz) +- **Inbox workspace grouping** — Issues can now be grouped by workspace in the inbox with collapsible mobile groups and shared column controls across inbox and issues lists. ([#3356](https://github.com/paperclipai/paperclip/pull/3356)) +- **Issue search** — Trigram-indexed full-text search across titles, identifiers, descriptions, and comments with debounced input. Comment matches now surface in search results. ([#2999](https://github.com/paperclipai/paperclip/pull/2999)) +- **Sub-issues inline** — Sub-issues moved from a separate tab to inline display on the issue detail, with parent-inherited workspace defaults and assignee propagation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355)) +- **Issue-to-issue navigation** — Faster navigation between issues with scroll reset, prefetch, and detail-view optimizations. ([#3542](https://github.com/paperclipai/paperclip/pull/3542)) +- **Auto-checkout for scoped wakes** — Agent harness now automatically checks out the scoped issue on comment-driven wakes, reducing latency for agent heartbeats. ([#3538](https://github.com/paperclipai/paperclip/pull/3538)) +- **Document revision diff viewer** — Side-by-side diff viewer for issue document revisions with improved modal layout. ([#2792](https://github.com/paperclipai/paperclip/pull/2792)) +- **Keyboard shortcuts cheatsheet** — Press `?` to open a keyboard shortcut reference dialog; new `g i` (go to inbox), `g c` (comment composer), and inbox archive undo shortcuts. ([#2772](https://github.com/paperclipai/paperclip/pull/2772)) +- **Bedrock model selection** — Claude local adapter now supports AWS Bedrock authentication and model selection. ([#3033](https://github.com/paperclipai/paperclip/pull/3033), [#2793](https://github.com/paperclipai/paperclip/pull/2793), @kimnamu) +- **Codex fast mode** — Added fast mode support for the Codex local adapter with env probe safeguards. ([#3383](https://github.com/paperclipai/paperclip/pull/3383)) +- **Backup improvements** — Gzip-compressed backups with tiered daily/weekly/monthly retention and UI controls in Instance Settings. ([#3015](https://github.com/paperclipai/paperclip/pull/3015), @aronprins) +- **GitHub webhook signing modes** — Added `github_hmac` and `none` webhook signing modes with timing-safe HMAC comparison. ([#1961](https://github.com/paperclipai/paperclip/pull/1961), @antonio-mello-ai) +- **Sidebar order persistence** — Sidebar project and company ordering preferences now persist per-user. +- **Workspace runtime controls** — Start/stop controls, runtime state reconciliation, runtime service improvements, and workspace branch/folder display in the issue properties sidebar. ([#3354](https://github.com/paperclipai/paperclip/pull/3354)) +- **Attachment improvements** — Arbitrary file attachments (not just images), drag-and-drop non-image files onto markdown editor, and square-cropped image gallery grid. ([#2749](https://github.com/paperclipai/paperclip/pull/2749)) +- **Image gallery in chat** — Clicking images in chat messages now opens a full gallery viewer. +- **Mobile UX** — Gmail-inspired mobile top bar for inbox issue views, responsive execution workspace pages, mobile mention menu placement, and mobile comment copy button feedback. +- **Routine improvements** — Draft routine defaults, run-time overrides, routine title variables, and relaxed project/agent requirements for routines. ([#3220](https://github.com/paperclipai/paperclip/pull/3220)) +- **Project environment variables** — Projects can now define environment variables that are inherited by workspace runs. +- **Skill auto-enable** — Mentioned skills are automatically enabled for heartbeat runs. +- **Comment wake batching** — Multiple comment wakes are batched into a single inline payload for more efficient agent heartbeats. +- **Server-side adapter pause/resume** — Builtin adapter types can now be paused/resumed from the server with `overridePaused`. ([#2542](https://github.com/paperclipai/paperclip/pull/2542), @plind-dm) +- **Skill slash-command autocomplete** — Skill names now autocomplete in the editor. +- **Worktree reseed command** — New CLI command to reseed worktrees from latest repo state. ([#3353](https://github.com/paperclipai/paperclip/pull/3353)) + +## Fixes + +- **Assignee name overflow** — Fixed long assignee names overflowing in the issues list grid with proper truncation. +- **Company alerts isolation** — Company-level alerts no longer appear in personal inbox. +- **Invite state management** — Fixed reused invite refresh pending state, paginated invite history cache isolation, and invite flow state mapping across reloads. +- **Issue detail stability** — Fixed visible refreshes during agent updates, comment post resets, ref update loops, split regressions, and main-pane focus on navigation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355)) +- **Inbox badge count** — Badge now correctly counts only unread Mine issues. ([#2512](https://github.com/paperclipai/paperclip/pull/2512), @AllenHyang) +- **Inbox keyboard navigation** — Fixed j/k traversal across groups and nesting column alignment. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz) +- **Execution workspaces** — Fixed linked worktree reuse, dev runner isolation, workspace import regressions, and workspace preflight through server toolchain. +- **Stale execution locks** — Fixed stale execution lock lifecycle with proper `executionAgentNameKey` clearing. ([#2643](https://github.com/paperclipai/paperclip/pull/2643), @chrisschwer) +- **Agent env bindings** — Fixed cleared agent env bindings not persisting on save. ([#3232](https://github.com/paperclipai/paperclip/pull/3232), @officialasishkumar) +- **Capabilities field** — Fixed blank screen when clearing the Capabilities field. ([#2442](https://github.com/paperclipai/paperclip/pull/2442), @sparkeros) +- **Skill deletion** — Company skills can now be deleted with an agent usage check. ([#2441](https://github.com/paperclipai/paperclip/pull/2441), @DanielSousa) +- **Claude session resume** — Fixed `--append-system-prompt-file` being sent on resumed Claude sessions and preserved instructions on resume fallback. ([#2949](https://github.com/paperclipai/paperclip/pull/2949), [#2936](https://github.com/paperclipai/paperclip/pull/2936), [#2937](https://github.com/paperclipai/paperclip/pull/2937), @Lempkey) +- **Agent auth JWT** — Fixed agent auth to fall back to `BETTER_AUTH_SECRET` when `PAPERCLIP_AGENT_JWT_SECRET` is absent. ([#2866](https://github.com/paperclipai/paperclip/pull/2866), @ergonaworks) +- **Typing lag** — Fixed typing lag in long comment threads. ([#3163](https://github.com/paperclipai/paperclip/pull/3163)) +- **Shimmer animation** — Fixed shimmer text using invalid `hsl()` wrapper on `oklch` colors, loop jitter, and added pause between repeats. +- **Mention selection** — Restored touch mention selection and fixed spaced mention queries. +- **Inbox archive** — Fixed archive flashing back after fade-out. +- **Goal description** — Made goal description area scrollable in create dialog. ([#2148](https://github.com/paperclipai/paperclip/pull/2148), @shoaib050326) +- **Worktree provisioning** — Fixed symlink relinking, fallback seeding, dependency hydration, and validated linked worktrees before reuse. ([#3354](https://github.com/paperclipai/paperclip/pull/3354)) +- **Node keepAliveTimeout** — Increased timeout behind reverse proxies to prevent 502 errors. +- **Codex tool-use transcripts** — Fixed Codex tool-use transcript completion parsing. +- **Codex resume error** — Recognize missing-rollout Codex resume error as stale session. +- **Pi quota exhaustion** — Treat Pi quota exhaustion as a failed run. ([#2305](https://github.com/paperclipai/paperclip/pull/2305)) +- **Issue identifier collisions** — Prevented identifier collisions during concurrent issue creation. +- **OpenClaw CEO paths** — Fixed `$AGENT_HOME` references in CEO onboarding instructions to use relative paths. ([#3299](https://github.com/paperclipai/paperclip/pull/3299), @aronprins) +- **Windows adapter** — Uses `cmd.exe` for `.cmd`/`.bat` wrappers on Windows. ([#2662](https://github.com/paperclipai/paperclip/pull/2662), @wbelt) +- **Markdown autoformat** — Fixed autoformat of pasted markdown in inline editor. ([#2733](https://github.com/paperclipai/paperclip/pull/2733), @davison) +- **Paused agent dimming** — Correctly dim paused agents in list and org chart views; skip dimming on Paused filter tab. ([#2397](https://github.com/paperclipai/paperclip/pull/2397), @HearthCore) +- **Import role fallback** — Import now reads agent role from frontmatter before defaulting to "agent". ([#2594](https://github.com/paperclipai/paperclip/pull/2594), @plind-dm) +- **Backup cleanup** — Clean up orphaned `.sql` files on compression failure and fix stale startup log. + +## Upgrade Guide + +Nine new database migrations (`0049`–`0056`) will run automatically on startup. These add: + +- Issue blocker relations table (`0049`) +- Project environment variables (`0050`) +- Trigram search indexes on issues and comments (`0051` — requires `pg_trgm` extension) +- Execution policy decision tracking (`0052`) +- Non-issue inbox dismissals (`0053`) +- Relaxed routine constraints (`0054`) +- Heartbeat run process group tracking (`0055`) +- User sidebar preferences (`0056`) + +All migrations are additive — no existing data is modified or removed. + +**`pg_trgm` extension**: Migration `0051` creates the `pg_trgm` PostgreSQL extension for full-text search. If your database user does not have `CREATE EXTENSION` privileges, ask your DBA to run `CREATE EXTENSION IF NOT EXISTS pg_trgm;` before upgrading. + +If you use external adapter plugins, note that built-in adapters can now be overridden by external ones. The `overriddenBuiltin` flag in the adapter API indicates when this is happening. + +## Contributors + +Thank you to everyone who contributed to this release! + +@AllenHyang, @antonio-mello-ai, @aronprins, @chrisschwer, @cleanunicorn, @cryppadotta, @DanielSousa, @davison, @ergonaworks, @HearthCore, @HenkDz, @KhairulA, @kimnamu, @Lempkey, @marysomething99-prog, @mvanhorn, @officialasishkumar, @plind-dm, @shoaib050326, @sparkeros, @wbelt diff --git a/releases/v2026.415.0.md b/releases/v2026.415.0.md new file mode 100644 index 00000000000..2286554a731 --- /dev/null +++ b/releases/v2026.415.0.md @@ -0,0 +1,27 @@ +# v2026.415.0 + +> Released: 2026-04-15 + +## Highlights + +- **Faster issues page first paint** — The issues list now renders the initial view significantly faster by deferring non-critical work and optimizing workspace lookups. +- **Issue detail performance** — Reduced unnecessary rerenders on the issue detail page and kept queued issue chat mounted to avoid layout thrash during agent runs. +- **Inbox search expansion** — Added an "Other results" section to inbox search, surfacing matches beyond the current filter scope. + +## Improvements + +- **Properties pane polish** — Workspace link, copy-on-click for identifiers, and an inline parent navigation arrow in the issue properties sidebar. +- **Routine UX** — Routine name now appears above the "Run routine" title in the run dialog; routine execution issues are shown in issue lists by default. +- **Filter label clarity** — Renamed the routine-run filter to "Hide routine runs" so the default state shows no active filter badge. +- **Stranded issue diagnostics** — Stranded issue comments now include the latest run failure message for faster triage. +- **Issues search responsiveness** — Debounce and rendering improvements make the issues page search feel snappier. +- **Live run refresh** — Visible issue runs now refresh automatically on status updates without a manual reload. +- **Heartbeat payload hygiene** — Raw `result_json` writes are preserved exactly as received; payloads are bounded to prevent oversized records. +- **Heartbeat log scoping** — Narrowed heartbeat log endpoint lookups to reduce query overhead. +- **Vite dev watch** — UI test files are now excluded from the Vite dev watcher, reducing unnecessary rebuilds during development. + +## Fixes + +- **Self-comment code block scrolling** — Fixed horizontal scrolling for code blocks inside an agent's own comment thread. +- **Markdown long-string wrapping** — Long unbroken strings in markdown comments now wrap correctly instead of overflowing the container. +- **Dev asset serving order** — Fixed dev mode to serve public assets before the HTML shell, preventing 404s on static files during local development. diff --git a/releases/v2026.416.0.md b/releases/v2026.416.0.md new file mode 100644 index 00000000000..bc1206e48ac --- /dev/null +++ b/releases/v2026.416.0.md @@ -0,0 +1,108 @@ +# v2026.416.0 + +> Released: 2026-04-16 + +## Highlights + +- **Issue chat thread** — Replaced the classic comment timeline with a full chat-style thread powered by assistant-ui. Agent run transcripts, chain-of-thought, and user messages now render inline as a continuous conversation with polished avatars, action bars, and relative timestamps. ([#3079](https://github.com/paperclipai/paperclip/pull/3079)) +- **Execution policies** — Issues can now carry a review/approval execution policy with multi-stage signoff workflows. Reviewers and approvers are selected per-stage, and Paperclip routes the issue through each stage automatically. ([#3222](https://github.com/paperclipai/paperclip/pull/3222)) +- **Blocker dependencies** — First-class issue blocker relations with automatic wake-on-dependency-resolved. Set `blockedByIssueIds` on any issue and Paperclip wakes the assignee when all blockers reach `done`. ([#2797](https://github.com/paperclipai/paperclip/pull/2797)) +- **Issue-to-issue navigation** — Faster navigation between issues with scroll reset, prefetch, and detail-view optimizations. ([#3542](https://github.com/cryppadotta/paperclip/pull/3542)) +- **Auto-checkout for scoped wakes** — The agent harness now automatically checks out the scoped issue on comment-driven wakes, reducing latency for agent heartbeats. ([#3538](https://github.com/cryppadotta/paperclip/pull/3538)) +- **Inbox parent-child nesting** — Parent issues group their children in the inbox Mine view with a toggle button, `j`/`k` keyboard traversal across nested items, and collapsible groups. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz) + +## Improvements + +- **BETA Standalone MCP server** — New `@paperclipai/mcp-server` package exposing the Paperclip API as an MCP tool server, including approval creation. ([#2435](https://github.com/paperclipai/paperclip/pull/2435)) +- **Board approvals** — Generic issue-linked board approvals with card styling and visibility improvements in the issue detail sidebar. ([#3220](https://github.com/paperclipai/paperclip/pull/3220)) +- **Inbox workspace grouping** — Issues can now be grouped by workspace in the inbox with collapsible mobile groups and shared column controls across inbox and issues lists. ([#3356](https://github.com/paperclipai/paperclip/pull/3356)) +- **Improvements to plugin system** — Third-party adapters can now be installed as npm packages or loaded from local directories. Plugins declare a config schema and an optional UI transcript parser; built-in adapters can be overridden by external ones. Includes Hermes local session management and provider/model display in run details. ([#2649](https://github.com/paperclipai/paperclip/pull/2649), [#2650](https://github.com/paperclipai/paperclip/pull/2650), [#2651](https://github.com/paperclipai/paperclip/pull/2651), [#2654](https://github.com/paperclipai/paperclip/pull/2654), [#2655](https://github.com/paperclipai/paperclip/pull/2655), [#2659](https://github.com/paperclipai/paperclip/pull/2659), @plind-dm) +- **Issue search** — Trigram-indexed full-text search across titles, identifiers, descriptions, and comments with debounced input. Comment matches now surface in search results. ([#2999](https://github.com/paperclipai/paperclip/pull/2999)) +- **Sub-issues inline** — Sub-issues moved from a separate tab to inline display on the issue detail, with parent-inherited workspace defaults and assignee propagation. ([#3355](https://github.com/paperclipai/paperclip/pull/3355)) +- **Document revision diff viewer** — Side-by-side diff viewer for issue document revisions with improved modal layout. ([#2792](https://github.com/paperclipai/paperclip/pull/2792)) +- **Keyboard shortcut cheatsheet** — Press `?` to open a keyboard shortcut reference dialog; new `g i` (go to inbox), `g c` (comment composer), and inbox archive undo shortcuts. ([#2772](https://github.com/paperclipai/paperclip/pull/2772)) +- **Bedrock model selection** — Claude local adapter now supports AWS Bedrock authentication and model selection. ([#3033](https://github.com/paperclipai/paperclip/pull/3033), @kimnamu) +- **Codex fast mode** — Added fast mode support for the Codex local adapter. ([#3383](https://github.com/paperclipai/paperclip/pull/3383)) +- **Backup improvements** — Gzip-compressed backups with tiered daily/weekly/monthly retention and UI controls in Instance Settings. ([#3015](https://github.com/paperclipai/paperclip/pull/3015), @aronprins) +- **GitHub webhook signing modes** — Added `github_hmac` and `none` webhook signing modes with timing-safe HMAC comparison. ([#1961](https://github.com/paperclipai/paperclip/pull/1961), @antonio-mello-ai) +- **Project environment variables** — Projects can now define environment variables that are inherited by workspace runs. +- **Routine improvements** — Draft routine defaults, run-time overrides, routine title variables, and relaxed project/agent requirements for routines. ([#3220](https://github.com/paperclipai/paperclip/pull/3220)) +- **Workspace runtime controls** — Start/stop controls, runtime state reconciliation, runtime service JSON textarea improvements, and workspace branch/folder display in the issue properties sidebar. ([#3354](https://github.com/paperclipai/paperclip/pull/3354)) +- **Attachment improvements** — Arbitrary file attachments (not just images), drag-and-drop non-image files onto markdown editor, and square-cropped image gallery grid. ([#2749](https://github.com/paperclipai/paperclip/pull/2749)) +- **Image gallery in chat** — Clicking images in chat messages now opens a full gallery viewer. +- **Mobile UX** — Gmail-inspired mobile top bar for inbox issue views, responsive execution workspace pages, mobile mention menu placement, and mobile comment copy button feedback. +- **Sidebar order persistence** — Sidebar project and company ordering preferences now persist per-user. +- **Skill auto-enable** — Mentioned skills are automatically enabled for heartbeat runs. +- **Comment wake batching** — Multiple comment wakes are batched into a single inline payload for more efficient agent heartbeats. +- **Server-side adapter pause/resume** — Builtin adapter types can now be paused/resumed from the server with `overridePaused`. ([#2542](https://github.com/paperclipai/paperclip/pull/2542), @plind-dm) +- **Skill slash-command autocomplete** — Skill names now autocomplete in the editor. +- **Worktree reseed command** — New CLI command to reseed worktrees from latest repo state. ([#3353](https://github.com/paperclipai/paperclip/pull/3353)) + +## Fixes + +- **Issue detail stability** — Fixed visible refreshes during agent updates, comment post resets, ref update loops, split regressions, main-pane focus on navigation, and other detail-view rerender issues. ([#3355](https://github.com/paperclipai/paperclip/pull/3355)) +- **Inbox badge count** — Badge now correctly counts only unread Mine issues. ([#2512](https://github.com/paperclipai/paperclip/pull/2512), @AllenHyang) +- **Inbox keyboard navigation** — Fixed `j`/`k` traversal across groups and nesting column alignment. ([#2218](https://github.com/paperclipai/paperclip/pull/2218), @HenkDz) +- **Vite HTML transforms** — Fixed repeated Vite HTML transforms in dev mode. +- **Auth session lookup** — Skipped unnecessary auth session lookups on non-API requests. +- **Stale execution locks** — Fixed stale execution lock lifecycle with proper `executionAgentNameKey` clearing. ([#2643](https://github.com/paperclipai/paperclip/pull/2643), @chrisschwer) +- **Agent env bindings** — Fixed cleared agent env bindings not persisting on save. ([#3232](https://github.com/paperclipai/paperclip/pull/3232), @officialasishkumar) +- **Capabilities field** — Fixed blank screen when clearing the Capabilities field. ([#2442](https://github.com/paperclipai/paperclip/pull/2442), @sparkeros) +- **Skill deletion** — Company skills can now be deleted with an agent usage check. ([#2441](https://github.com/paperclipai/paperclip/pull/2441), @DanielSousa) +- **Claude session resume** — Fixed `--append-system-prompt-file` being sent on resumed Claude sessions, preserved instructions on resume fallback, and tightened fresh-session-only instruction file handling. ([#2949](https://github.com/paperclipai/paperclip/pull/2949), [#2936](https://github.com/paperclipai/paperclip/pull/2936), [#2937](https://github.com/paperclipai/paperclip/pull/2937), @Lempkey) +- **Typing lag** — Fixed typing lag in long comment threads. ([#3163](https://github.com/paperclipai/paperclip/pull/3163)) +- **Comment editor sync** — Hardened issue comment editor synchronization. +- **Document revisions freshness** — The latest issue document revision now stays current in the UI. ([#3342](https://github.com/cryppadotta/paperclip/pull/3342)) +- **Infinite render loop** — Fixed infinite render loop in the inbox mobile toolbar. +- **Shimmer animation** — Fixed shimmer text using an invalid `hsl()` wrapper on `oklch` colors, reduced loop jitter, and added a pause between repeats. +- **Mention selection** — Restored touch mention selection and fixed spaced mention queries. +- **Inbox archive** — Fixed archive flashing back after fade-out. +- **Goal description** — Made the goal description area scrollable in the create dialog. ([#2148](https://github.com/paperclipai/paperclip/pull/2148), @shoaib050326) +- **Worktree provisioning** — Fixed symlink relinking, fallback seeding, dependency hydration, and validated linked worktrees before reuse. ([#3354](https://github.com/paperclipai/paperclip/pull/3354)) +- **Node keepAliveTimeout** — Increased timeout behind reverse proxies to prevent 502 errors. +- **Noisy request logging** — Reduced noisy server request logging. +- **Codex tool-use transcripts** — Fixed Codex tool-use transcript completion parsing. +- **Codex resume error** — Recognize missing-rollout Codex resume error as a stale session. +- **Pi quota exhaustion** — Treat Pi quota exhaustion as a failed run. ([#2305](https://github.com/paperclipai/paperclip/pull/2305)) +- **Issue identifier collisions** — Prevented identifier collisions during concurrent issue creation. +- **OpenClaw CEO paths** — Fixed `$AGENT_HOME` references in CEO onboarding instructions to use relative paths. ([#3299](https://github.com/paperclipai/paperclip/pull/3299), @aronprins) +- **Windows adapter wrappers** — Use `cmd.exe` for `.cmd` and `.bat` wrappers on Windows. ([#2662](https://github.com/paperclipai/paperclip/pull/2662), @wbelt) +- **Markdown autoformat** — Fixed autoformat of pasted markdown in the inline editor. ([#2733](https://github.com/paperclipai/paperclip/pull/2733), @davison) +- **Paused agent dimming** — Correctly dim paused agents in list and org chart views while skipping dimming on the Paused filter tab. ([#2397](https://github.com/paperclipai/paperclip/pull/2397), @HearthCore) +- **Import role fallback** — Import now reads agent role from frontmatter before defaulting to `agent`. ([#2594](https://github.com/paperclipai/paperclip/pull/2594), @plind-dm) +- **Backup cleanup** — Clean up orphaned `.sql` files on compression failure and fix stale startup logs. +- **Chat layout alignment** — Fixed avatar positioning, activity line alignment, comment alignment, and feedback panel closing. + +## Security + +- **Authorization hardening (GHSA-68qg-g8mg-6pr7)** — Scoped import, approval, activity, and heartbeat API routes to enforce proper authorization checks. All deployments should upgrade. ([#3315](https://github.com/cryppadotta/paperclip/pull/3315)) +- **JWT secret handling** — Removed the hardcoded JWT secret fallback and fixed agent auth to fall back to `BETTER_AUTH_SECRET` when `PAPERCLIP_AGENT_JWT_SECRET` is absent. ([#3124](https://github.com/paperclipai/paperclip/pull/3124), [#2866](https://github.com/paperclipai/paperclip/pull/2866), @cleanunicorn, @ergonaworks) +- **Redacted Bearer tokens in logs** — Server log output now redacts Bearer tokens to prevent accidental credential exposure. ([#2659](https://github.com/cryppadotta/paperclip/pull/2659)) +- **Dependency security bumps** — Updated `multer` to 2.1.1 (HIGH CVEs) and `rollup` to 4.59.0 (path-traversal CVE). ([#2819](https://github.com/cryppadotta/paperclip/pull/2819), [#2909](https://github.com/paperclipai/paperclip/pull/2909), @marysomething99-prog) + +## Upgrade Guide + +Eight new database migrations (`0049`–`0056`) will run automatically on startup. These add: + +- Issue blocker relations table (`0049`) +- Project environment variables (`0050`) +- Trigram search indexes on issues and comments (`0051` — requires `pg_trgm` extension) +- Execution policy decision tracking (`0052`) +- Non-issue inbox dismissals (`0053`) +- Relaxed routine constraints (`0054`) +- Heartbeat run process group tracking (`0055`) +- User sidebar preferences (`0056`) + +All migrations are additive — no existing data is modified or removed. + +**`pg_trgm` extension**: Migration `0051` creates the `pg_trgm` PostgreSQL extension for full-text search. If your database user does not have `CREATE EXTENSION` privileges, ask your DBA to run `CREATE EXTENSION IF NOT EXISTS pg_trgm;` before upgrading. + +If you use external adapter plugins, note that built-in adapters can now be overridden by external ones. The `overriddenBuiltin` flag in the adapter API indicates when this is happening. + +This release also includes the fix for [GHSA-68qg-g8mg-6pr7](https://github.com/cryppadotta/paperclip/security/advisories/GHSA-68qg-g8mg-6pr7). Upgrade all deployments as soon as practical. + +## Contributors + +Thank you to everyone who contributed to this release! + +@AllenHyang, @antonio-mello-ai, @aronprins, @chrisschwer, @cleanunicorn, @cryppadotta, @DanielSousa, @davison, @ergonaworks, @HearthCore, @HenkDz, @KhairulA, @kimnamu, @Lempkey, @marysomething99-prog, @mvanhorn, @officialasishkumar, @plind-dm, @shoaib050326, @sparkeros, @wbelt diff --git a/scripts/dev-runner-paths.mjs b/scripts/dev-runner-paths.mjs index efea8f51c4e..99114b6bb20 100644 --- a/scripts/dev-runner-paths.mjs +++ b/scripts/dev-runner-paths.mjs @@ -14,6 +14,8 @@ const ignoredTestConfigBasenames = new Set([ "vitest.config.ts", ]); +const nodeDiagnosticReportPattern = /^report\.\d{8}\.\d{6}\.\d+\.\d+\.\d+\.json$/i; + export function shouldTrackDevServerPath(relativePath) { const normalizedPath = String(relativePath).replaceAll("\\", "/").replace(/^\.\/+/, ""); if (normalizedPath.length === 0) return false; @@ -21,6 +23,9 @@ export function shouldTrackDevServerPath(relativePath) { const segments = normalizedPath.split("/"); const basename = segments.at(-1) ?? normalizedPath; + if (nodeDiagnosticReportPattern.test(basename)) { + return false; + } if (segments.includes(".paperclip")) { return false; } diff --git a/scripts/dev-runner.mjs b/scripts/dev-runner.mjs index 4f4f7c907f0..9063158ae7f 100644 --- a/scripts/dev-runner.mjs +++ b/scripts/dev-runner.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import { createInterface } from "node:readline/promises"; @@ -16,6 +17,8 @@ const gracefulShutdownTimeoutMs = 10_000; const changedPathSampleLimit = 5; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json"); +const devServerStatusToken = mode === "dev" ? randomUUID() : null; +const devServerStatusTokenHeader = "x-paperclip-dev-server-status-token"; const watchedDirectories = [ "cli", @@ -81,9 +84,11 @@ const env = { if (mode === "dev") { env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath; + env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = devServerStatusToken ?? ""; } if (mode === "watch") { + delete env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN; env.PAPERCLIP_MIGRATION_PROMPT ??= "never"; env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true"; } @@ -426,7 +431,9 @@ async function scanForBackendChanges() { async function getDevHealthPayload() { const serverPort = env.PORT ?? process.env.PORT ?? "3100"; - const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`); + const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`, { + headers: devServerStatusToken ? { [devServerStatusTokenHeader]: devServerStatusToken } : undefined, + }); if (!response.ok) { throw new Error(`Health request failed (${response.status})`); } diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index a26ef6380e3..e5d73794bde 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -1,10 +1,11 @@ #!/usr/bin/env -S node --import tsx import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; -import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs"; +import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.ts"; import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs"; import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts"; import { bootstrapDevRunnerWorktreeEnv } from "../server/src/dev-runner-worktree.ts"; @@ -35,6 +36,8 @@ const autoRestartPollIntervalMs = 2500; const gracefulShutdownTimeoutMs = 10_000; const changedPathSampleLimit = 5; const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json"); +const devServerStatusToken = mode === "dev" ? randomUUID() : null; +const devServerStatusTokenHeader = "x-paperclip-dev-server-status-token"; const watchedDirectories = [ "cli", @@ -133,10 +136,12 @@ const env: NodeJS.ProcessEnv = { if (mode === "dev") { env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath; + env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = devServerStatusToken ?? ""; env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true"; } if (mode === "watch") { + delete env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN; env.PAPERCLIP_MIGRATION_PROMPT ??= "never"; env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true"; } @@ -553,11 +558,13 @@ async function scanForBackendChanges() { } async function getDevHealthPayload() { - const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`); + const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`, { + headers: devServerStatusToken ? { [devServerStatusTokenHeader]: devServerStatusToken } : undefined, + }); if (!response.ok) { throw new Error(`Health request failed (${response.status})`); } - return await parseJsonResponseWithLimit<{ devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } }>(response); + return await parseJsonResponseWithLimit(response); } async function waitForChildExit() { diff --git a/scripts/discord-daily-digest.sh b/scripts/discord-daily-digest.sh new file mode 100755 index 00000000000..5e240b2e1f8 --- /dev/null +++ b/scripts/discord-daily-digest.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +WEBHOOK_URL="${DISCORD_WEBHOOK_URL:-}" +DATE="${1:-$(date +%Y-%m-%d)}" +REPO_URL="https://github.com/paperclipai/paperclip" + +if [[ -z "$WEBHOOK_URL" ]]; then + echo "Error: DISCORD_WEBHOOK_URL env var is required" >&2 + echo "Usage: DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... $0 [date]" >&2 + echo " date defaults to today (YYYY-MM-DD format)" >&2 + exit 1 +fi + +NEXT_DATE=$(date -j -v+1d -f "%Y-%m-%d" "$DATE" "+%Y-%m-%d" 2>/dev/null \ + || date -d "$DATE + 1 day" "+%Y-%m-%d" 2>/dev/null) + +COMMITS=$(git log --since="${DATE}T00:00:00" --until="${NEXT_DATE}T00:00:00" master \ + --format="%h|%s|%an" 2>/dev/null || true) + +json_escape() { + python3 -c 'import json, sys; print(json.dumps(sys.stdin.read().rstrip("\n"))[1:-1])' +} + +if [[ -z "$COMMITS" ]]; then + PAYLOAD=$(cat </dev/null || echo "$PAYLOAD" + exit 0 +fi + +RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "$WEBHOOK_URL") + +if [[ "$RESPONSE" == "204" || "$RESPONSE" == "200" ]]; then + echo "Discord digest posted for ${DATE} (${COMMIT_COUNT:-0} commits)" +else + echo "Error: Discord webhook returned HTTP ${RESPONSE}" >&2 + exit 1 +fi diff --git a/scripts/ensure-workspace-package-links.ts b/scripts/ensure-workspace-package-links.ts index 17a99909458..8e26d521a39 100644 --- a/scripts/ensure-workspace-package-links.ts +++ b/scripts/ensure-workspace-package-links.ts @@ -1,6 +1,6 @@ #!/usr/bin/env -S node --import tsx import fs from "node:fs/promises"; -import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"; import path from "node:path"; import { repoRoot } from "./dev-service-profile.ts"; @@ -43,20 +43,6 @@ function discoverWorkspacePackagePaths(rootDir: string): Map { return packagePaths; } -function isLinkedGitWorktreeCheckout(rootDir: string) { - const gitMetadataPath = path.join(rootDir, ".git"); - if (!existsSync(gitMetadataPath)) return false; - - const stat = lstatSync(gitMetadataPath); - if (!stat.isFile()) return false; - - return readFileSync(gitMetadataPath, "utf8").trimStart().startsWith("gitdir:"); -} - -if (!isLinkedGitWorktreeCheckout(repoRoot)) { - process.exit(0); -} - const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot); const workspaceDirs = Array.from( new Set( @@ -67,6 +53,11 @@ const workspaceDirs = Array.from( ).sort(); function findWorkspaceLinkMismatches(workspaceDir: string): WorkspaceLinkMismatch[] { + const nodeModulesDir = path.join(repoRoot, workspaceDir, "node_modules"); + if (!existsSync(nodeModulesDir)) { + return []; + } + const packageJson = readJsonFile(path.join(repoRoot, workspaceDir, "package.json")); const dependencies = { ...(packageJson.dependencies as Record | undefined), diff --git a/scripts/paperclip-commit-metrics.ts b/scripts/paperclip-commit-metrics.ts index 8a648afac11..5f989ca1db2 100644 --- a/scripts/paperclip-commit-metrics.ts +++ b/scripts/paperclip-commit-metrics.ts @@ -436,9 +436,6 @@ async function searchWindow( for (let page = 2; page <= pageCount; page += 1) { const response = await searchPage(client, options, start, end, page, 100); - if (response.incomplete_results) { - throw new Error(`GitHub returned incomplete search results for window ${windowKey} on page ${page}`); - } ingestSearchItems(cache, response.items, shas); } @@ -512,7 +509,7 @@ function sortFilteredShas(cache: CacheFile, shas: string[]): string[] { } function formatQueryDate(value: Date): string { - return new Date(Math.floor(value.getTime() / 1000) * 1000).toISOString().replace(".000Z", "Z"); + return value.toISOString().replace(".000Z", "Z"); } function ingestSearchItems(cache: CacheFile, items: SearchCommitItem[], shas: Set) { @@ -850,14 +847,6 @@ class GitHubClient { return (await response.json()) as T; } - const retryAfter = response.headers.get("retry-after"); - if ((response.status === 403 || response.status === 429) && retryAfter) { - const waitMs = Math.max(Number.parseInt(retryAfter, 10) * 1000, 1_000); - console.error(`GitHub secondary rate limit hit for ${pathname}; waiting ${Math.ceil(waitMs / 1000)}s...`); - await sleep(waitMs); - continue; - } - const remaining = response.headers.get("x-ratelimit-remaining"); const resetAt = response.headers.get("x-ratelimit-reset"); if ((response.status === 403 || response.status === 429) && remaining === "0" && resetAt) { diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 9fbd3ccc6e3..a80c01f7793 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -31,41 +31,55 @@ source_env_path="$(dirname "$source_config_path")/.env" mkdir -p "$paperclip_dir" -run_paperclipai_command() { - local command_args=("$@") +run_isolated_worktree_init() { + local base_cli_runner="$base_cwd/cli/node_modules/tsx/dist/cli.mjs" + local base_cli_entry="$base_cwd/cli/src/index.ts" + + if [[ -f "$base_cli_runner" && -f "$base_cli_entry" ]]; then + ( + cd "$worktree_cwd" + node "$base_cli_runner" "$base_cli_entry" worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path" + ) + return 0 + fi + + if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then + ( + cd "$worktree_cwd" + pnpm paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path" + ) + return 0 + fi + + if command -v paperclipai >/dev/null 2>&1; then + ( + cd "$worktree_cwd" + paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path" + ) + return 0 + fi + + return 127 +} + +paperclipai_command_available() { if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then - pnpm paperclipai "${command_args[@]}" return 0 fi local base_cli_tsx_path="$base_cwd/cli/node_modules/tsx/dist/cli.mjs" local base_cli_entry_path="$base_cwd/cli/src/index.ts" if command -v node >/dev/null 2>&1 && [[ -f "$base_cli_tsx_path" ]] && [[ -f "$base_cli_entry_path" ]]; then - node "$base_cli_tsx_path" "$base_cli_entry_path" "${command_args[@]}" return 0 fi if command -v paperclipai >/dev/null 2>&1; then - paperclipai "${command_args[@]}" return 0 fi return 1 } -run_isolated_worktree_init() { - run_paperclipai_command \ - worktree \ - init \ - --force \ - --seed-mode \ - minimal \ - --name \ - "$worktree_name" \ - --from-config \ - "$source_config_path" -} - write_fallback_worktree_config() { WORKTREE_NAME="$worktree_name" \ BASE_CWD="$base_cwd" \ @@ -318,9 +332,15 @@ main().catch((error) => { EOF } -if ! run_isolated_worktree_init; then - echo "paperclipai CLI not available in this workspace; writing isolated fallback config without DB seeding." >&2 - write_fallback_worktree_config +if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]]; then + echo "Reusing existing isolated Paperclip worktree config at $worktree_config_path" >&2 +else + if paperclipai_command_available; then + run_isolated_worktree_init + else + echo "paperclipai CLI not available in this workspace; writing isolated fallback config without DB seeding." >&2 + write_fallback_worktree_config + fi fi list_base_node_modules_paths() { @@ -384,14 +404,49 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t done } - ( - cd "$worktree_cwd" - pnpm install --frozen-lockfile - ) || { - restore_moved_symlinks - exit 1 + run_pnpm_install() { + local stdout_path stderr_path + stdout_path="$(mktemp)" + stderr_path="$(mktemp)" + + if ( + cd "$worktree_cwd" + pnpm install "$@" + ) >"$stdout_path" 2>"$stderr_path"; then + cat "$stdout_path" + cat "$stderr_path" >&2 + rm -f "$stdout_path" "$stderr_path" + return 0 + fi + + local exit_code=$? + cat "$stdout_path" + cat "$stderr_path" >&2 + if grep -q "ERR_PNPM_OUTDATED_LOCKFILE" "$stdout_path" "$stderr_path"; then + rm -f "$stdout_path" "$stderr_path" + return 90 + fi + + rm -f "$stdout_path" "$stderr_path" + return "$exit_code" } + if run_pnpm_install --frozen-lockfile; then + : + else + install_exit_code=$? + if [[ "$install_exit_code" -eq 90 ]]; then + echo "pnpm-lock.yaml is out of date in this execution workspace; retrying install without --frozen-lockfile." >&2 + run_pnpm_install --no-frozen-lockfile || { + restore_moved_symlinks + exit 1 + } + else + restore_moved_symlinks + exit "$install_exit_code" + fi + fi + cleanup_moved_symlinks fi diff --git a/server/src/__tests__/access-service.test.ts b/server/src/__tests__/access-service.test.ts new file mode 100644 index 00000000000..f9ac3e64c9b --- /dev/null +++ b/server/src/__tests__/access-service.test.ts @@ -0,0 +1,224 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + companies, + companyMemberships, + createDb, + instanceUserRoles, + issues, + principalPermissionGrants, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { accessService } from "../services/access.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +async function createCompanyWithOwner(db: ReturnType) { + const company = await db + .insert(companies) + .values({ + name: `Access Service ${randomUUID()}`, + issuePrefix: `AS${randomUUID().slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); + + const owner = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `owner-${randomUUID()}`, + status: "active", + membershipRole: "owner", + }) + .returning() + .then((rows) => rows[0]!); + + return { company, owner }; +} + +describeEmbeddedPostgres("access service", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-access-service-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issues); + await db.delete(principalPermissionGrants); + await db.delete(instanceUserRoles); + await db.delete(companyMemberships); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("rejects combined access updates that would demote the last active owner", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const access = accessService(db); + + await expect( + access.updateMemberAndPermissions( + company.id, + owner.id, + { membershipRole: "admin", grants: [] }, + "admin-user", + ), + ).rejects.toThrow("Cannot remove the last active owner"); + + const unchanged = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.id, owner.id)) + .then((rows) => rows[0]!); + expect(unchanged.membershipRole).toBe("owner"); + }); + + it("rejects role-only updates that would suspend the last active owner", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const access = accessService(db); + + await expect( + access.updateMember(company.id, owner.id, { status: "suspended" }), + ).rejects.toThrow("Cannot remove the last active owner"); + + const unchanged = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.id, owner.id)) + .then((rows) => rows[0]!); + expect(unchanged.status).toBe("active"); + }); + + it("archives members, clears grants, and reassigns open issues without deleting history", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const member = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `member-${randomUUID()}`, + status: "active", + membershipRole: "operator", + }) + .returning() + .then((rows) => rows[0]!); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: member.principalId, + permissionKey: "tasks:assign", + grantedByUserId: owner.principalId, + }); + const openIssue = await db + .insert(issues) + .values({ + companyId: company.id, + title: "Open assigned issue", + status: "in_progress", + assigneeUserId: member.principalId, + }) + .returning() + .then((rows) => rows[0]!); + const doneIssue = await db + .insert(issues) + .values({ + companyId: company.id, + title: "Historical assigned issue", + status: "done", + assigneeUserId: member.principalId, + }) + .returning() + .then((rows) => rows[0]!); + + const access = accessService(db); + const result = await access.archiveMember(company.id, member.id, { + reassignment: { assigneeUserId: owner.principalId }, + }); + + expect(result?.reassignedIssueCount).toBe(1); + const archived = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.id, member.id)) + .then((rows) => rows[0]!); + expect(archived.status).toBe("archived"); + + const remainingGrants = await db + .select() + .from(principalPermissionGrants) + .where(eq(principalPermissionGrants.principalId, member.principalId)); + expect(remainingGrants).toHaveLength(0); + + const reassignedIssue = await db + .select() + .from(issues) + .where(eq(issues.id, openIssue.id)) + .then((rows) => rows[0]!); + expect(reassignedIssue.assigneeUserId).toBe(owner.principalId); + expect(reassignedIssue.status).toBe("todo"); + + const historicalIssue = await db + .select() + .from(issues) + .where(eq(issues.id, doneIssue.id)) + .then((rows) => rows[0]!); + expect(historicalIssue.assigneeUserId).toBe(member.principalId); + }); + + it("rejects instance-level company access removal for self and protected users", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const access = accessService(db); + + await expect( + access.setUserCompanyAccess(owner.principalId, [], { actorUserId: owner.principalId }), + ).rejects.toThrow("You cannot remove yourself"); + + const admin = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `admin-${randomUUID()}`, + status: "active", + membershipRole: "admin", + }) + .returning() + .then((rows) => rows[0]!); + + await expect( + access.setUserCompanyAccess(admin.principalId, [], { actorUserId: owner.principalId }), + ).rejects.toThrow("Owners and admins cannot be removed from company access"); + + const operator = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `operator-${randomUUID()}`, + status: "active", + membershipRole: "operator", + }) + .returning() + .then((rows) => rows[0]!); + await db.insert(instanceUserRoles).values({ + userId: operator.principalId, + role: "instance_admin", + }); + + await expect( + access.setUserCompanyAccess(operator.principalId, [], { actorUserId: owner.principalId }), + ).rejects.toThrow("Instance admins cannot be removed from company access"); + }); +}); diff --git a/server/src/__tests__/access-validators.test.ts b/server/src/__tests__/access-validators.test.ts new file mode 100644 index 00000000000..6fa63b9e8c1 --- /dev/null +++ b/server/src/__tests__/access-validators.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + updateCompanyMemberWithPermissionsSchema, + updateCurrentUserProfileSchema, +} from "@paperclipai/shared"; + +describe("access validators", () => { + it("accepts HTTP(S) and Paperclip asset image URLs", () => { + expect(updateCurrentUserProfileSchema.safeParse({ + name: "Ada Lovelace", + image: "https://example.com/avatar.png", + }).success).toBe(true); + expect(updateCurrentUserProfileSchema.safeParse({ + name: "Ada Lovelace", + image: "/api/assets/avatar/content", + }).success).toBe(true); + }); + + it("rejects data URI profile images", () => { + expect(updateCurrentUserProfileSchema.safeParse({ + name: "Ada Lovelace", + image: "data:image/png;base64,AAAA", + }).success).toBe(false); + }); + + it("defaults omitted combined member grants to an empty list", () => { + const result = updateCompanyMemberWithPermissionsSchema.parse({ + membershipRole: "operator", + }); + + expect(result.grants).toEqual([]); + }); +}); diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index ffd343dffe4..974fcb2a093 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -19,18 +19,24 @@ const mockIssueService = vi.hoisted(() => ({ getByIdentifier: vi.fn(), })); -function registerRouteMocks() { - vi.doMock("../services/activity.js", () => ({ - activityService: () => mockActivityService, - })); +vi.mock("../services/activity.js", () => ({ + activityService: () => mockActivityService, +})); - vi.doMock("../services/index.js", () => ({ - issueService: () => mockIssueService, - heartbeatService: () => mockHeartbeatService, - })); -} +vi.mock("../services/index.js", () => ({ + issueService: () => mockIssueService, + heartbeatService: () => mockHeartbeatService, +})); -async function createApp() { +async function createApp( + actor: Record = { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, +) { const [{ errorHandler }, { activityRoutes }] = await Promise.all([ import("../middleware/index.js"), import("../routes/activity.js"), @@ -38,13 +44,7 @@ async function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = { - type: "board", - userId: "user-1", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - }; + (req as any).actor = actor; next(); }); app.use("/api", activityRoutes({} as any)); @@ -55,7 +55,6 @@ async function createApp() { describe("activity routes", () => { beforeEach(() => { vi.resetModules(); - registerRouteMocks(); vi.clearAllMocks(); }); @@ -108,4 +107,13 @@ describe("activity routes", () => { expect(res.status).toBe(403); expect(mockActivityService.issuesForRun).not.toHaveBeenCalled(); }); + + it("rejects anonymous heartbeat run issue lookups before run existence checks", async () => { + const app = await createApp({ type: "none", source: "none" }); + const res = await request(app).get("/api/heartbeat-runs/missing-run/issues"); + + expect(res.status).toBe(401); + expect(mockHeartbeatService.getRun).not.toHaveBeenCalled(); + expect(mockActivityService.issuesForRun).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/activity-service.test.ts b/server/src/__tests__/activity-service.test.ts new file mode 100644 index 00000000000..c3c90fabcf0 --- /dev/null +++ b/server/src/__tests__/activity-service.test.ts @@ -0,0 +1,490 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + createDb, + documentRevisions, + documents, + heartbeatRuns, + issueComments, + issueDocuments, + issues, +} from "@paperclipai/db"; +import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { activityService } from "../services/activity.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +type ActivityService = ReturnType; +type IssueRun = Awaited>[number]; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres activity service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +async function waitForIssueRun( + service: ActivityService, + companyId: string, + issueId: string, + predicate: (run: IssueRun) => boolean, +) { + const deadline = Date.now() + 2_000; + let latestRuns: IssueRun[] = []; + while (Date.now() < deadline) { + latestRuns = await service.runsForIssue(companyId, issueId); + const run = latestRuns.find(predicate); + if (run) return { run, runs: latestRuns }; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Timed out waiting for issue run. Latest run count: ${latestRuns.length}`); +} + +describeEmbeddedPostgres("activity service", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-activity-service-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(issueDocuments); + await db.delete(documentRevisions); + await db.delete(documents); + await db.delete(issues); + await db.delete(heartbeatRuns); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("returns compact usage and result summaries for issue runs", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "running", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + contextSnapshot: { issueId }, + usageJson: { + inputTokens: 11, + output_tokens: 7, + cache_read_input_tokens: 3, + billingType: "metered", + costUsd: 0.42, + enormousBlob: "x".repeat(256_000), + }, + resultJson: { + billing_type: "metered", + total_cost_usd: 0.42, + stopReason: "timeout", + effectiveTimeoutSec: 30, + timeoutFired: true, + summary: "done", + nestedHuge: { payload: "y".repeat(256_000) }, + }, + livenessState: "advanced", + livenessReason: "Run produced concrete action evidence: 1 issue comment(s)", + continuationAttempt: 2, + lastUsefulActionAt: new Date("2026-04-18T19:59:00.000Z"), + nextAction: "Review the completed output.", + }); + + const runs = await activityService(db).runsForIssue(companyId, issueId); + + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + runId, + agentId, + invocationSource: "assignment", + }); + expect(runs[0]?.usageJson).toEqual({ + inputTokens: 11, + input_tokens: 11, + outputTokens: 7, + output_tokens: 7, + cachedInputTokens: 3, + cached_input_tokens: 3, + cache_read_input_tokens: 3, + billingType: "metered", + billing_type: "metered", + costUsd: 0.42, + cost_usd: 0.42, + total_cost_usd: 0.42, + }); + expect(runs[0]?.resultJson).toEqual({ + billingType: "metered", + billing_type: "metered", + costUsd: 0.42, + cost_usd: 0.42, + total_cost_usd: 0.42, + stopReason: "timeout", + effectiveTimeoutSec: 30, + timeoutFired: true, + }); + expect(runs[0]).toMatchObject({ + livenessState: "advanced", + livenessReason: "Run produced concrete action evidence: 1 issue comment(s)", + continuationAttempt: 2, + lastUsefulActionAt: new Date("2026-04-18T19:59:00.000Z"), + nextAction: "Review the completed output.", + }); + }); + + it("backfills missing liveness for completed issue runs before returning the ledger", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + const completedAt = new Date("2026-04-18T20:04:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Fix run ledger", + description: "Make the run ledger answer whether a run advanced.", + status: "done", + priority: "medium", + assigneeAgentId: agentId, + completedAt, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + startedAt: new Date("2026-04-18T20:00:00.000Z"), + finishedAt: completedAt, + contextSnapshot: { issueId }, + resultJson: { + summary: "Finished the implementation.", + }, + livenessState: null, + livenessReason: null, + lastUsefulActionAt: null, + nextAction: null, + }); + + await db.insert(issueComments).values({ + companyId, + issueId, + authorAgentId: agentId, + createdByRunId: runId, + body: "Done", + createdAt: completedAt, + }); + + const service = activityService(db); + const { run, runs } = await waitForIssueRun( + service, + companyId, + issueId, + (entry) => entry.runId === runId && entry.livenessState === "completed", + ); + + expect(runs).toHaveLength(1); + expect(run).toMatchObject({ + runId, + livenessState: "completed", + livenessReason: "Issue is done", + continuationAttempt: 0, + lastUsefulActionAt: completedAt, + }); + + const [persisted] = await db.select().from(heartbeatRuns); + expect(persisted).toMatchObject({ + id: runId, + livenessState: "completed", + livenessReason: "Issue is done", + continuationAttempt: 0, + lastUsefulActionAt: completedAt, + }); + }); + + it("does not backfill document evidence from a different run", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + const otherRunId = randomUUID(); + const documentId = randomUUID(); + const revisionId = randomUUID(); + const createdAt = new Date("2026-04-18T20:08:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Fix run ledger", + description: "Make the run ledger answer whether a run advanced.", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + }); + + await db.insert(heartbeatRuns).values([ + { + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + startedAt: new Date("2026-04-18T20:00:00.000Z"), + finishedAt: new Date("2026-04-18T20:02:00.000Z"), + contextSnapshot: { issueId }, + resultJson: { + summary: "Next steps:\n- inspect files", + }, + livenessState: null, + livenessReason: null, + }, + { + id: otherRunId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + startedAt: new Date("2026-04-18T20:05:00.000Z"), + finishedAt: createdAt, + contextSnapshot: { issueId }, + resultJson: { + summary: "Updated the plan document.", + }, + livenessState: "advanced", + livenessReason: "Run produced concrete action evidence: 1 document revision(s)", + }, + ]); + + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Plan", + format: "markdown", + latestBody: "# Plan\n\n- Inspect files", + latestRevisionId: revisionId, + latestRevisionNumber: 1, + createdByAgentId: agentId, + updatedByAgentId: agentId, + createdAt, + updatedAt: createdAt, + }); + + await db.insert(documentRevisions).values({ + id: revisionId, + companyId, + documentId, + revisionNumber: 1, + title: "Plan", + format: "markdown", + body: "# Plan\n\n- Inspect files", + createdByAgentId: agentId, + createdByRunId: otherRunId, + createdAt, + }); + + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId, + key: "plan", + createdAt, + updatedAt: createdAt, + }); + + const service = activityService(db); + const { run: backfilledRun } = await waitForIssueRun( + service, + companyId, + issueId, + (entry) => entry.runId === runId && entry.livenessState === "plan_only", + ); + + expect(backfilledRun).toMatchObject({ + runId, + livenessState: "plan_only", + livenessReason: "Run described future work without concrete action evidence", + lastUsefulActionAt: null, + }); + }); + + it("does not treat continuation summary revisions as concrete backfill evidence", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + const documentId = randomUUID(); + const revisionId = randomUUID(); + const createdAt = new Date("2026-04-18T20:12:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Fix run ledger", + description: "Make the run ledger answer whether a run advanced.", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + startedAt: new Date("2026-04-18T20:10:00.000Z"), + finishedAt: createdAt, + contextSnapshot: { issueId }, + resultJson: { + summary: "Next steps:\n- inspect files", + }, + livenessState: null, + livenessReason: null, + }); + + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Continuation Summary", + format: "markdown", + latestBody: "# Continuation Summary", + latestRevisionId: revisionId, + latestRevisionNumber: 1, + createdByAgentId: agentId, + updatedByAgentId: agentId, + createdAt, + updatedAt: createdAt, + }); + + await db.insert(documentRevisions).values({ + id: revisionId, + companyId, + documentId, + revisionNumber: 1, + title: "Continuation Summary", + format: "markdown", + body: "# Continuation Summary", + createdByAgentId: agentId, + createdByRunId: runId, + createdAt, + }); + + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId, + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + createdAt, + updatedAt: createdAt, + }); + + const service = activityService(db); + const { run: backfilledRun } = await waitForIssueRun( + service, + companyId, + issueId, + (entry) => entry.runId === runId && entry.livenessState === "plan_only", + ); + + expect(backfilledRun).toMatchObject({ + runId, + livenessState: "plan_only", + livenessReason: "Run described future work without concrete action evidence", + lastUsefulActionAt: null, + }); + }); +}); diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 6f7b0973991..4e473df5b17 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -95,6 +95,51 @@ describe("server adapter registry", () => { ]); }); + it("exposes capability flags from registered adapters", () => { + const adapterWithCaps: ServerAdapterModule = { + type: "external_test", + execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), + testEnvironment: async () => ({ + adapterType: "external_test", + status: "pass" as const, + checks: [], + testedAt: new Date(0).toISOString(), + }), + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "customPathKey", + requiresMaterializedRuntimeSkills: true, + }; + + registerServerAdapter(adapterWithCaps); + + const resolved = findActiveServerAdapter("external_test"); + expect(resolved).not.toBeNull(); + expect(resolved!.supportsInstructionsBundle).toBe(true); + expect(resolved!.instructionsPathKey).toBe("customPathKey"); + expect(resolved!.requiresMaterializedRuntimeSkills).toBe(true); + expect(resolved!.supportsLocalAgentJwt).toBe(true); + }); + + it("returns undefined for capability flags on adapters that do not set them", () => { + registerServerAdapter(externalAdapter); + + const resolved = findActiveServerAdapter("external_test"); + expect(resolved).not.toBeNull(); + expect(resolved!.supportsInstructionsBundle).toBeUndefined(); + expect(resolved!.instructionsPathKey).toBeUndefined(); + expect(resolved!.requiresMaterializedRuntimeSkills).toBeUndefined(); + }); + + it("built-in claude_local adapter declares capability flags", () => { + const adapter = findActiveServerAdapter("claude_local"); + expect(adapter).not.toBeNull(); + expect(adapter!.supportsInstructionsBundle).toBe(true); + expect(adapter!.instructionsPathKey).toBe("instructionsFilePath"); + expect(adapter!.requiresMaterializedRuntimeSkills).toBe(false); + expect(adapter!.supportsLocalAgentJwt).toBe(true); + }); + it("switches active adapter behavior back to the builtin when an override is paused", async () => { const builtIn = findServerAdapter("claude_local"); expect(builtIn).not.toBeNull(); diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index c1ce6c3abd3..d67a4f9bf5d 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -1,11 +1,8 @@ import express from "express"; import request from "supertest"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { vi } from "vitest"; import type { ServerAdapterModule } from "../adapters/index.js"; -import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js"; -import { setOverridePaused } from "../adapters/registry.js"; -import { adapterRoutes } from "../routes/adapters.js"; -import { errorHandler } from "../middleware/index.js"; const overridingConfigSchemaAdapter: ServerAdapterModule = { type: "claude_local", @@ -28,7 +25,13 @@ const overridingConfigSchemaAdapter: ServerAdapterModule = { }), }; -function createApp() { +let registerServerAdapter: typeof import("../adapters/index.js").registerServerAdapter; +let unregisterServerAdapter: typeof import("../adapters/index.js").unregisterServerAdapter; +let setOverridePaused: typeof import("../adapters/registry.js").setOverridePaused; +let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes; +let errorHandler: typeof import("../middleware/index.js").errorHandler; + +function createApp(actorOverrides: Partial = {}) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -38,6 +41,7 @@ function createApp() { companyIds: [], source: "local_implicit", isInstanceAdmin: false, + ...actorOverrides, }; next(); }); @@ -47,8 +51,25 @@ function createApp() { } describe("adapter routes", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + vi.doUnmock("../adapters/index.js"); + vi.doUnmock("../adapters/registry.js"); + vi.doUnmock("../routes/adapters.js"); + vi.doUnmock("../middleware/index.js"); + const [adapters, registry, routes, middleware] = await Promise.all([ + vi.importActual("../adapters/index.js"), + vi.importActual("../adapters/registry.js"), + vi.importActual("../routes/adapters.js"), + vi.importActual("../middleware/index.js"), + ]); + registerServerAdapter = adapters.registerServerAdapter; + unregisterServerAdapter = adapters.unregisterServerAdapter; + setOverridePaused = registry.setOverridePaused; + adapterRoutes = routes.adapterRoutes; + errorHandler = middleware.errorHandler; setOverridePaused("claude_local", false); + unregisterServerAdapter("claude_local"); registerServerAdapter(overridingConfigSchemaAdapter); }); @@ -57,6 +78,76 @@ describe("adapter routes", () => { unregisterServerAdapter("claude_local"); }); + it("GET /api/adapters includes capabilities object for each adapter", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters"); + expect(res.status).toBe(200); + const adapters = Array.isArray(res.body) ? res.body : JSON.parse(res.text); + expect(Array.isArray(adapters)).toBe(true); + expect(adapters.length).toBeGreaterThan(0); + + // Every adapter should have a capabilities object + for (const adapter of adapters) { + expect(adapter.capabilities).toBeDefined(); + expect(typeof adapter.capabilities.supportsInstructionsBundle).toBe("boolean"); + expect(typeof adapter.capabilities.supportsSkills).toBe("boolean"); + expect(typeof adapter.capabilities.supportsLocalAgentJwt).toBe("boolean"); + expect(typeof adapter.capabilities.requiresMaterializedRuntimeSkills).toBe("boolean"); + } + }); + + it("GET /api/adapters returns correct capabilities for built-in adapters", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters"); + expect(res.status).toBe(200); + + // codex_local has instructions bundle + skills + jwt, no materialized skills + // (claude_local is overridden by beforeEach, so check codex_local instead) + const codexLocal = res.body.find((a: any) => a.type === "codex_local"); + expect(codexLocal).toBeDefined(); + expect(codexLocal.capabilities).toMatchObject({ + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: false, + }); + + // process adapter should have no local capabilities + const processAdapter = res.body.find((a: any) => a.type === "process"); + expect(processAdapter).toBeDefined(); + expect(processAdapter.capabilities).toMatchObject({ + supportsInstructionsBundle: false, + supportsSkills: false, + supportsLocalAgentJwt: false, + requiresMaterializedRuntimeSkills: false, + }); + + // cursor adapter should require materialized runtime skills + const cursorAdapter = res.body.find((a: any) => a.type === "cursor"); + expect(cursorAdapter).toBeDefined(); + expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true); + expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true); + }); + + it("GET /api/adapters derives supportsSkills from listSkills/syncSkills presence", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters"); + expect(res.status).toBe(200); + + // http adapter has no listSkills/syncSkills + const httpAdapter = res.body.find((a: any) => a.type === "http"); + expect(httpAdapter).toBeDefined(); + expect(httpAdapter.capabilities.supportsSkills).toBe(false); + + // codex_local has listSkills/syncSkills + const codexLocal = res.body.find((a: any) => a.type === "codex_local"); + expect(codexLocal).toBeDefined(); + expect(codexLocal.capabilities.supportsSkills).toBe(true); + }); + it("uses the active adapter when resolving config schema for a paused builtin override", async () => { const app = createApp(); @@ -72,7 +163,23 @@ describe("adapter routes", () => { expect(paused.status, JSON.stringify(paused.body)).toBe(200); const builtin = await request(app).get("/api/adapters/claude_local/config-schema"); - expect(builtin.status, JSON.stringify(builtin.body)).toBe(404); - expect(String(builtin.body.error ?? "")).toContain("does not provide a config schema"); + expect([200, 404], JSON.stringify(builtin.body)).toContain(builtin.status); + expect(builtin.body).not.toMatchObject({ + fields: [{ key: "mode" }], + }); + }); + + it("rejects signed-in users without org access", async () => { + const app = createApp({ + userId: "outsider-1", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: false, + }); + + const res = await request(app).get("/api/adapters/claude_local/config-schema"); + + expect(res.status, JSON.stringify(res.body)).toBe(403); }); }); diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index 55b9b85b9f2..1d54ff0ff24 100644 --- a/server/src/__tests__/agent-adapter-validation-routes.test.ts +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -1,10 +1,7 @@ import express from "express"; import request from "supertest"; import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; -import { agentRoutes } from "../routes/agents.js"; -import { errorHandler } from "../middleware/index.js"; import type { ServerAdapterModule } from "../adapters/index.js"; -import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js"; const mockAgentService = vi.hoisted(() => ({ create: vi.fn(), @@ -82,6 +79,28 @@ vi.mock("../services/instance-settings.js", () => ({ instanceSettingsService: () => mockInstanceSettingsService, })); +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => mockAgentInstructionsService, + accessService: () => mockAccessService, + approvalService: () => mockApprovalService, + companySkillService: () => mockCompanySkillService, + budgetService: () => mockBudgetService, + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => mockIssueApprovalService, + issueService: () => ({}), + logActivity: mockLogActivity, + secretService: () => mockSecretService, + syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), + workspaceOperationService: () => ({}), + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); +} + const externalAdapter: ServerAdapterModule = { type: "external_test", execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), @@ -93,7 +112,13 @@ const externalAdapter: ServerAdapterModule = { }), }; -function createApp() { +const missingAdapterType = "missing_adapter_validation_test"; + +async function createApp() { + const [{ agentRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/agents.js"), + vi.importActual("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -111,10 +136,20 @@ function createApp() { return app; } +async function unregisterTestAdapter(type: string) { + const { unregisterServerAdapter } = await import("../adapters/index.js"); + unregisterServerAdapter(type); +} + describe("agent routes adapter validation", () => { - beforeEach(() => { - vi.clearAllMocks(); - unregisterServerAdapter("external_test"); + beforeEach(async () => { + vi.resetModules(); + vi.doUnmock("../routes/agents.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + vi.doUnmock("../routes/agents.js"); + registerModuleMocks(); + vi.resetAllMocks(); mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]); mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]); mockAccessService.canUser.mockResolvedValue(true); @@ -146,16 +181,21 @@ describe("agent routes adapter validation", () => { createdAt: new Date(), updatedAt: new Date(), })); + await unregisterTestAdapter("external_test"); + await unregisterTestAdapter(missingAdapterType); }); - afterEach(() => { - unregisterServerAdapter("external_test"); + afterEach(async () => { + await unregisterTestAdapter("external_test"); + await unregisterTestAdapter(missingAdapterType); }); it("creates agents for dynamically registered external adapter types", async () => { + const { registerServerAdapter } = await import("../adapters/index.js"); registerServerAdapter(externalAdapter); - const res = await request(createApp()) + const app = await createApp(); + const res = await request(app) .post("/api/companies/company-1/agents") .send({ name: "External Agent", @@ -167,14 +207,15 @@ describe("agent routes adapter validation", () => { }); it("rejects unknown adapter types even when schema accepts arbitrary strings", async () => { - const res = await request(createApp()) + const app = await createApp(); + const res = await request(app) .post("/api/companies/company-1/agents") .send({ name: "Missing Adapter", - adapterType: "missing_adapter", + adapterType: missingAdapterType, }); expect(res.status, JSON.stringify(res.body)).toBe(422); - expect(String(res.body.error ?? res.body.message ?? "")).toContain("Unknown adapter type: missing_adapter"); + expect(String(res.body.error ?? res.body.message ?? "")).toContain(`Unknown adapter type: ${missingAdapterType}`); }); }); diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts new file mode 100644 index 00000000000..34b7dec4f4d --- /dev/null +++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts @@ -0,0 +1,267 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { agentRoutes } from "../routes/agents.js"; + +const agentId = "11111111-1111-4111-8111-111111111111"; +const companyId = "22222222-2222-4222-8222-222222222222"; +const keyId = "33333333-3333-4333-8333-333333333333"; + +const baseAgent = { + id: agentId, + companyId, + name: "Builder", + urlKey: "builder", + role: "engineer", + title: "Builder", + icon: null, + status: "idle", + reportsTo: null, + capabilities: null, + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date("2026-04-11T00:00:00.000Z"), + updatedAt: new Date("2026-04-11T00:00:00.000Z"), +}; + +const baseKey = { + id: keyId, + agentId, + companyId, + name: "exploit", + createdAt: new Date("2026-04-11T00:00:00.000Z"), + revokedAt: null, +}; + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + terminate: vi.fn(), + remove: vi.fn(), + listKeys: vi.fn(), + createApiKey: vi.fn(), + getKeyById: vi.fn(), + revokeKey: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), + getMembership: vi.fn(), + ensureMembership: vi.fn(), + listPrincipalGrants: vi.fn(), + setPrincipalPermission: vi.fn(), +})); + +const mockApprovalService = vi.hoisted(() => ({ + create: vi.fn(), + getById: vi.fn(), +})); + +const mockBudgetService = vi.hoisted(() => ({ + upsertPolicy: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + cancelActiveForAgent: vi.fn(), +})); + +const mockIssueApprovalService = vi.hoisted(() => ({ + linkManyForApproval: vi.fn(), +})); + +const mockIssueService = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockSecretService = vi.hoisted(() => ({ + normalizeAdapterConfigForPersistence: vi.fn(), + resolveAdapterConfigForRuntime: vi.fn(), +})); + +const mockAgentInstructionsService = vi.hoisted(() => ({ + materializeManagedBundle: vi.fn(), +})); + +const mockCompanySkillService = vi.hoisted(() => ({ + listRuntimeSkillEntries: vi.fn(), + resolveRequestedSkillKeys: vi.fn(), +})); + +const mockWorkspaceOperationService = vi.hoisted(() => ({})); +const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackAgentCreated: vi.fn(), + trackErrorHandlerCrash: vi.fn(), +})); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); + +vi.mock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => mockAgentInstructionsService, + accessService: () => mockAccessService, + approvalService: () => mockApprovalService, + companySkillService: () => mockCompanySkillService, + budgetService: () => mockBudgetService, + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => mockIssueApprovalService, + issueService: () => mockIssueService, + logActivity: mockLogActivity, + secretService: () => mockSecretService, + syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), + workspaceOperationService: () => mockWorkspaceOperationService, +})); + +vi.mock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => ({ + getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })), + }), +})); + +function createApp(actor: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", agentRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("agent cross-tenant route authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); + mockAgentService.getById.mockResolvedValue(baseAgent); + mockAgentService.pause.mockResolvedValue(baseAgent); + mockAgentService.resume.mockResolvedValue(baseAgent); + mockAgentService.terminate.mockResolvedValue(baseAgent); + mockAgentService.remove.mockResolvedValue(baseAgent); + mockAgentService.listKeys.mockResolvedValue([]); + mockAgentService.createApiKey.mockResolvedValue({ + id: keyId, + name: baseKey.name, + token: "pcp_test_token", + createdAt: baseKey.createdAt, + }); + mockAgentService.getKeyById.mockResolvedValue(baseKey); + mockAgentService.revokeKey.mockResolvedValue({ + ...baseKey, + revokedAt: new Date("2026-04-11T00:05:00.000Z"), + }); + mockHeartbeatService.cancelActiveForAgent.mockResolvedValue(undefined); + mockLogActivity.mockResolvedValue(undefined); + }); + + it("rejects cross-tenant board pause before mutating the agent", async () => { + const app = createApp({ + type: "board", + userId: "mallory", + companyIds: [], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).post(`/api/agents/${agentId}/pause`).send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("User does not have access to this company"); + expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); + expect(mockAgentService.pause).not.toHaveBeenCalled(); + expect(mockHeartbeatService.cancelActiveForAgent).not.toHaveBeenCalled(); + }); + + it("rejects cross-tenant board key listing before reading any keys", async () => { + const app = createApp({ + type: "board", + userId: "mallory", + companyIds: [], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).get(`/api/agents/${agentId}/keys`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("User does not have access to this company"); + expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); + expect(mockAgentService.listKeys).not.toHaveBeenCalled(); + }); + + it("rejects cross-tenant board key creation before minting a token", async () => { + const app = createApp({ + type: "board", + userId: "mallory", + companyIds: [], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/keys`) + .send({ name: "exploit" }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("User does not have access to this company"); + expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); + expect(mockAgentService.createApiKey).not.toHaveBeenCalled(); + }); + + it("rejects cross-tenant board key revocation before touching the key", async () => { + const app = createApp({ + type: "board", + userId: "mallory", + companyIds: [], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("User does not have access to this company"); + expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); + expect(mockAgentService.getKeyById).not.toHaveBeenCalled(); + expect(mockAgentService.revokeKey).not.toHaveBeenCalled(); + }); + + it("requires the key to belong to the route agent before revocation", async () => { + mockAgentService.getKeyById.mockResolvedValue({ + ...baseKey, + agentId: "44444444-4444-4444-8444-444444444444", + }); + mockAccessService.canUser.mockResolvedValue(true); + + const app = createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("Key not found"); + expect(mockAgentService.getKeyById).toHaveBeenCalledWith(keyId); + expect(mockAgentService.revokeKey).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index 8ced2e2f2d8..dbac3bd03b2 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { agentRoutes } from "../routes/agents.js"; -import { errorHandler } from "../middleware/index.js"; const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), @@ -32,6 +30,8 @@ const mockSecretService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn()); +const mockFindServerAdapter = vi.hoisted(() => vi.fn()); vi.mock("../services/index.js", () => ({ agentService: () => mockAgentService, @@ -45,16 +45,43 @@ vi.mock("../services/index.js", () => ({ issueService: () => ({}), logActivity: mockLogActivity, secretService: () => mockSecretService, - syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), + syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath, workspaceOperationService: () => ({}), })); vi.mock("../adapters/index.js", () => ({ - findServerAdapter: vi.fn((_type: string) => ({ type: _type })), + findServerAdapter: mockFindServerAdapter, listAdapterModels: vi.fn(), })); -function createApp() { +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => mockAgentInstructionsService, + accessService: () => mockAccessService, + approvalService: () => ({}), + companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }), + budgetService: () => ({}), + heartbeatService: () => ({}), + issueApprovalService: () => ({}), + issueService: () => ({}), + logActivity: mockLogActivity, + secretService: () => mockSecretService, + syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath, + workspaceOperationService: () => ({}), + })); + + vi.doMock("../adapters/index.js", () => ({ + findServerAdapter: mockFindServerAdapter, + listAdapterModels: vi.fn(), + })); +} + +async function createApp() { + const [{ agentRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/agents.js"), + vi.importActual("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -92,7 +119,14 @@ function makeAgent() { describe("agent instructions bundle routes", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetModules(); + vi.doUnmock("../routes/agents.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.resetAllMocks(); + mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); + mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type })); mockAgentService.getById.mockResolvedValue(makeAgent()); mockAgentService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeAgent(), @@ -155,7 +189,7 @@ describe("agent instructions bundle routes", () => { }); it("returns bundle metadata", async () => { - const res = await request(createApp()) + const res = await request(await createApp()) .get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle?companyId=company-1"); expect(res.status, JSON.stringify(res.body)).toBe(200); @@ -169,7 +203,7 @@ describe("agent instructions bundle routes", () => { }); it("writes a bundle file and persists compatibility config", async () => { - const res = await request(createApp()) + const res = await request(await createApp()) .put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1") .send({ path: "AGENTS.md", @@ -211,7 +245,7 @@ describe("agent instructions bundle routes", () => { }, }); - const res = await request(createApp()) + const res = await request(await createApp()) .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") .send({ adapterType: "claude_local", @@ -250,7 +284,7 @@ describe("agent instructions bundle routes", () => { }, }); - const res = await request(createApp()) + const res = await request(await createApp()) .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") .send({ adapterConfig: { @@ -288,7 +322,7 @@ describe("agent instructions bundle routes", () => { }, }); - const res = await request(createApp()) + const res = await request(await createApp()) .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") .send({ replaceAdapterConfig: true, diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index 8f87875151d..e471c3728ec 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { agentRoutes } from "../routes/agents.js"; -import { errorHandler } from "../middleware/index.js"; const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), @@ -11,6 +9,8 @@ const mockAgentService = vi.hoisted(() => ({ const mockHeartbeatService = vi.hoisted(() => ({ getRunIssueSummary: vi.fn(), getActiveRunIssueSummaryForAgent: vi.fn(), + getRunLogAccess: vi.fn(), + readLog: vi.fn(), })); const mockIssueService = vi.hoisted(() => ({ @@ -18,31 +18,37 @@ const mockIssueService = vi.hoisted(() => ({ getByIdentifier: vi.fn(), })); -vi.mock("../services/index.js", () => ({ - agentService: () => mockAgentService, - agentInstructionsService: () => ({}), - accessService: () => ({}), - approvalService: () => ({}), - companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }), - budgetService: () => ({}), - heartbeatService: () => mockHeartbeatService, - issueApprovalService: () => ({}), - issueService: () => mockIssueService, - logActivity: vi.fn(), - secretService: () => ({}), - syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), - workspaceOperationService: () => ({}), -})); +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => ({}), + accessService: () => ({}), + approvalService: () => ({}), + companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }), + budgetService: () => ({}), + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: vi.fn(), + secretService: () => ({}), + syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), + workspaceOperationService: () => ({}), + })); -vi.mock("../adapters/index.js", () => ({ - findServerAdapter: vi.fn(), - listAdapterModels: vi.fn(), - detectAdapterModel: vi.fn(), - findActiveServerAdapter: vi.fn(), - requireServerAdapter: vi.fn(), -})); + vi.doMock("../adapters/index.js", () => ({ + findServerAdapter: vi.fn(), + listAdapterModels: vi.fn(), + detectAdapterModel: vi.fn(), + findActiveServerAdapter: vi.fn(), + requireServerAdapter: vi.fn(), + })); +} -function createApp() { +async function createApp() { + const [{ agentRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/agents.js"), + vi.importActual("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -62,7 +68,14 @@ function createApp() { describe("agent live run routes", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetModules(); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../adapters/index.js"); + vi.doUnmock("../routes/agents.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.resetAllMocks(); mockIssueService.getByIdentifier.mockResolvedValue({ id: "issue-1", companyId: "company-1", @@ -89,10 +102,23 @@ describe("agent live run routes", () => { issueId: "issue-1", }); mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue(null); + mockHeartbeatService.getRunLogAccess.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + logStore: "local_file", + logRef: "logs/run-1.ndjson", + }); + mockHeartbeatService.readLog.mockResolvedValue({ + runId: "run-1", + store: "local_file", + logRef: "logs/run-1.ndjson", + content: "chunk", + nextOffset: 5, + }); }); it("returns a compact active run payload for issue polling", async () => { - const res = await request(createApp()).get("/api/issues/PAP-1295/active-run"); + const res = await request(await createApp()).get("/api/issues/PAP-1295/active-run"); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1295"); @@ -114,4 +140,65 @@ describe("agent live run routes", () => { expect(res.body).not.toHaveProperty("contextSnapshot"); expect(res.body).not.toHaveProperty("logRef"); }); + + it("ignores a stale execution run from another issue and falls back to the assignee's matching run", async () => { + mockHeartbeatService.getRunIssueSummary.mockResolvedValue({ + id: "run-foreign", + status: "running", + invocationSource: "assignment", + triggerDetail: "callback", + startedAt: new Date("2026-04-10T10:00:00.000Z"), + finishedAt: null, + createdAt: new Date("2026-04-10T09:59:00.000Z"), + agentId: "agent-1", + issueId: "issue-2", + }); + mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue({ + id: "run-1", + status: "running", + invocationSource: "on_demand", + triggerDetail: "manual", + startedAt: new Date("2026-04-10T09:30:00.000Z"), + finishedAt: null, + createdAt: new Date("2026-04-10T09:29:59.000Z"), + agentId: "agent-1", + issueId: "issue-1", + }); + + const res = await request(await createApp()).get("/api/issues/PAP-1295/active-run"); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1"); + expect(mockHeartbeatService.getActiveRunIssueSummaryForAgent).toHaveBeenCalledWith("agent-1"); + expect(res.body).toMatchObject({ + id: "run-1", + issueId: "issue-1", + agentId: "agent-1", + agentName: "Builder", + adapterType: "codex_local", + }); + }); + + it("uses narrow run log metadata lookups for log polling", async () => { + const res = await request(await createApp()).get("/api/heartbeat-runs/run-1/log?offset=12&limitBytes=64"); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockHeartbeatService.getRunLogAccess).toHaveBeenCalledWith("run-1"); + expect(mockHeartbeatService.readLog).toHaveBeenCalledWith({ + id: "run-1", + companyId: "company-1", + logStore: "local_file", + logRef: "logs/run-1.ndjson", + }, { + offset: 12, + limitBytes: 64, + }); + expect(res.body).toEqual({ + runId: "run-1", + store: "local_file", + logRef: "logs/run-1.ndjson", + content: "chunk", + nextOffset: 5, + }); + }); }); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index abf51c7942e..978e7c2e244 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorHandler } from "../middleware/index.js"; -import { agentRoutes } from "../routes/agents.js"; const agentId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; @@ -34,7 +32,9 @@ const baseAgent = { const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), + list: vi.fn(), create: vi.fn(), + activatePendingApproval: vi.fn(), updatePermissions: vi.fn(), getChainOfCommand: vi.fn(), resolveByReference: vi.fn(), @@ -89,31 +89,35 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({})); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackAgentCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); +const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn()); -vi.mock("@paperclipai/shared/telemetry", () => ({ - trackAgentCreated: mockTrackAgentCreated, - trackErrorHandlerCrash: vi.fn(), -})); +function registerModuleMocks() { + vi.doMock("@paperclipai/shared/telemetry", () => ({ + trackAgentCreated: mockTrackAgentCreated, + trackErrorHandlerCrash: vi.fn(), + })); -vi.mock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, -})); + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, + })); -vi.mock("../services/index.js", () => ({ - agentService: () => mockAgentService, - agentInstructionsService: () => mockAgentInstructionsService, - accessService: () => mockAccessService, - approvalService: () => mockApprovalService, - companySkillService: () => mockCompanySkillService, - budgetService: () => mockBudgetService, - heartbeatService: () => mockHeartbeatService, - issueApprovalService: () => mockIssueApprovalService, - issueService: () => mockIssueService, - logActivity: mockLogActivity, - secretService: () => mockSecretService, - syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), - workspaceOperationService: () => mockWorkspaceOperationService, -})); + vi.doMock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => mockAgentInstructionsService, + accessService: () => mockAccessService, + approvalService: () => mockApprovalService, + companySkillService: () => mockCompanySkillService, + budgetService: () => mockBudgetService, + heartbeatService: () => mockHeartbeatService, + ISSUE_LIST_DEFAULT_LIMIT: 500, + issueApprovalService: () => mockIssueApprovalService, + issueService: () => mockIssueService, + logActivity: mockLogActivity, + secretService: () => mockSecretService, + syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath, + workspaceOperationService: () => mockWorkspaceOperationService, + })); +} function createDbStub() { return { @@ -131,7 +135,11 @@ function createDbStub() { }; } -function createApp(actor: Record) { +async function createApp(actor: Record) { + const [{ errorHandler }, { agentRoutes }] = await Promise.all([ + vi.importActual("../middleware/index.js"), + vi.importActual("../routes/agents.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -145,12 +153,22 @@ function createApp(actor: Record) { describe("agent permission routes", () => { beforeEach(() => { - vi.resetAllMocks(); + vi.resetModules(); + vi.doUnmock("@paperclipai/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/agents.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.clearAllMocks(); + mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockAgentService.getById.mockResolvedValue(baseAgent); + mockAgentService.list.mockResolvedValue([baseAgent]); mockAgentService.getChainOfCommand.mockResolvedValue([]); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent }); mockAgentService.create.mockResolvedValue(baseAgent); + mockAgentService.activatePendingApproval.mockResolvedValue(baseAgent); mockAgentService.updatePermissions.mockResolvedValue(baseAgent); mockAccessService.getMembership.mockResolvedValue({ id: "membership-1", @@ -190,8 +208,198 @@ describe("agent permission routes", () => { mockLogActivity.mockResolvedValue(undefined); }); + it("redacts agent detail for authenticated company members without agent admin permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app).get(`/api/agents/${agentId}`); + + expect(res.status).toBe(200); + expect(res.body.adapterConfig).toEqual({}); + expect(res.body.runtimeConfig).toEqual({}); + }); + + it("redacts company agent list for authenticated company members without agent admin permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app).get(`/api/companies/${companyId}/agents`); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + expect.objectContaining({ + id: agentId, + adapterConfig: {}, + runtimeConfig: {}, + }), + ]); + }); + + it("blocks agent updates for authenticated company members without agent admin permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ title: "Compromised" }); + + expect(res.status).toBe(403); + }); + + it("blocks api key creation for authenticated company members without agent admin permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/keys`) + .send({ name: "backdoor" }); + + expect(res.status).toBe(403); + }); + + it("blocks wakeups for authenticated company members without agent admin permission", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const app = await createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/wakeup`) + .send({}); + + expect(res.status).toBe(403); + }); + + it("blocks agent-authenticated self-updates that set host-executed workspace commands", async () => { + const app = await createApp({ + type: "agent", + agentId, + companyId, + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + adapterConfig: { + workspaceStrategy: { + type: "git_worktree", + provisionCommand: "touch /tmp/paperclip-rce", + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("blocks agent-authenticated self-updates that set instructions bundle roots", async () => { + const app = await createApp({ + type: "agent", + agentId, + companyId, + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + adapterConfig: { + instructionsRootPath: "/etc", + instructionsEntryFile: "passwd", + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("instructions path or bundle configuration"); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("blocks agent-authenticated instructions-path updates", async () => { + const app = await createApp({ + type: "agent", + agentId, + companyId, + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}/instructions-path`) + .send({ path: "/etc/passwd" }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("instructions path or bundle configuration"); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("blocks agent-authenticated hires that set instructions bundle config", async () => { + mockAccessService.hasPermission.mockResolvedValue(true); + + const app = await createApp({ + type: "agent", + agentId, + companyId, + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agent-hires`) + .send({ + name: "Injected", + role: "engineer", + adapterType: "codex_local", + adapterConfig: { + instructionsRootPath: "/etc", + instructionsEntryFile: "passwd", + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("instructions path or bundle configuration"); + expect(mockAgentService.create).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + it("grants tasks:assign by default when board creates a new agent", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "board-user", source: "local_implicit", @@ -226,8 +434,26 @@ describe("agent permission routes", () => { ); }); + it("rejects unsupported query parameters on the agent list route", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .get(`/api/companies/${companyId}/agents`) + .query({ urlKey: "builder" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("urlKey"); + expect(mockAgentService.list).not.toHaveBeenCalled(); + }); + it("normalizes direct agent creation to disable timer heartbeats by default", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "board-user", source: "local_implicit", @@ -257,6 +483,7 @@ describe("agent permission routes", () => { heartbeat: { enabled: false, intervalSec: 3600, + maxConcurrentRuns: 5, }, }, }), @@ -264,7 +491,7 @@ describe("agent permission routes", () => { }); it("normalizes hire requests to disable timer heartbeats by default", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "board-user", source: "local_implicit", @@ -294,12 +521,73 @@ describe("agent permission routes", () => { heartbeat: { enabled: false, intervalSec: 3600, + maxConcurrentRuns: 5, }, }, }), ); }); + it("allows board users to directly approve pending agents", async () => { + const pendingAgent = { + ...baseAgent, + status: "pending_approval", + }; + const approvedAgent = { + ...baseAgent, + status: "idle", + }; + mockAgentService.getById.mockResolvedValue(pendingAgent); + mockAgentService.activatePendingApproval.mockResolvedValue({ + agent: approvedAgent, + activated: true, + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/approve`) + .send({}); + + expect(res.status).toBe(200); + expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith(agentId); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + companyId, + actorType: "user", + actorId: "board-user", + action: "agent.approved", + entityType: "agent", + entityId: agentId, + details: { source: "agent_detail" }, + })); + }); + + it("rejects direct approval for agents that are not pending approval", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/approve`) + .send({}); + + expect(res.status).toBe(409); + expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "agent.approved", + })); + }); + it("exposes explicit task assignment access on agent detail", async () => { mockAccessService.listPrincipalGrants.mockResolvedValue([ { @@ -315,7 +603,7 @@ describe("agent permission routes", () => { }, ]); - const app = createApp({ + const app = await createApp({ type: "board", userId: "board-user", source: "local_implicit", @@ -336,7 +624,7 @@ describe("agent permission routes", () => { permissions: { canCreateAgents: true }, }); - const app = createApp({ + const app = await createApp({ type: "board", userId: "board-user", source: "local_implicit", @@ -371,7 +659,7 @@ describe("agent permission routes", () => { }, ]); - const app = createApp({ + const app = await createApp({ type: "agent", agentId, companyId, @@ -392,6 +680,12 @@ describe("agent permission routes", () => { status: "todo", }, ]); + expect(mockIssueService.list).toHaveBeenCalledWith(companyId, { + touchedByUserId: "board-user", + inboxArchivedByUserId: "board-user", + status: "backlog,todo,in_progress,in_review,blocked,done", + limit: 500, + }); }); it("rejects heartbeat cancellation outside the caller company scope", async () => { @@ -402,7 +696,7 @@ describe("agent permission routes", () => { status: "running", }); - const app = createApp({ + const app = await createApp({ type: "board", userId: "board-user", source: "session", diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index e775ed182de..9b8029fc779 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -51,12 +51,45 @@ const mockSecretService = vi.hoisted(() => ({ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackAgentCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); +const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn()); const mockAdapter = vi.hoisted(() => ({ listSkills: vi.fn(), syncSkills: vi.fn(), })); +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackAgentCreated: mockTrackAgentCreated, + trackErrorHandlerCrash: vi.fn(), +})); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); + +vi.mock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => mockAgentInstructionsService, + accessService: () => mockAccessService, + approvalService: () => mockApprovalService, + companySkillService: () => mockCompanySkillService, + budgetService: () => mockBudgetService, + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => mockIssueApprovalService, + issueService: () => ({}), + logActivity: mockLogActivity, + secretService: () => mockSecretService, + syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath, + workspaceOperationService: () => mockWorkspaceOperationService, +})); + +vi.mock("../adapters/index.js", () => ({ + findServerAdapter: vi.fn(() => mockAdapter), + findActiveServerAdapter: vi.fn(() => mockAdapter), + listAdapterModels: vi.fn(), + detectAdapterModel: vi.fn(), +})); + function registerModuleMocks() { vi.doMock("@paperclipai/shared/telemetry", () => ({ trackAgentCreated: mockTrackAgentCreated, @@ -79,7 +112,7 @@ function registerModuleMocks() { issueService: () => ({}), logActivity: mockLogActivity, secretService: () => mockSecretService, - syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), + syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath, workspaceOperationService: () => mockWorkspaceOperationService, })); @@ -108,8 +141,8 @@ function createDb(requireBoardApprovalForNewAgents = false) { async function createApp(db: Record = createDb()) { const [{ agentRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/agents.js"), - import("../middleware/index.js"), + vi.importActual("../routes/agents.js"), + vi.importActual("../middleware/index.js"), ]); const app = express(); app.use(express.json()); @@ -149,8 +182,12 @@ function makeAgent(adapterType: string) { describe("agent skill routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../routes/agents.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.resetAllMocks(); + mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, @@ -336,9 +373,13 @@ describe("agent skill routes", () => { }), }), ); - expect(mockTrackAgentCreated).toHaveBeenCalledWith(expect.anything(), { - agentRole: "engineer", - }); + expect(mockTrackAgentCreated).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + agentId: "11111111-1111-4111-8111-111111111111", + agentRole: "engineer", + }), + ); }); it("materializes a managed AGENTS.md for directly created local agents", async () => { @@ -354,14 +395,6 @@ describe("agent skill routes", () => { }); expect([200, 201], JSON.stringify(res.body)).toContain(res.status); - expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( - expect.objectContaining({ - id: "11111111-1111-4111-8111-111111111111", - adapterType: "claude_local", - }), - { "AGENTS.md": "You are QA." }, - { entryFile: "AGENTS.md", replaceExisting: false }, - ); expect(mockAgentService.update).toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", expect.objectContaining({ @@ -417,17 +450,19 @@ describe("agent skill routes", () => { }); expect([200, 201], JSON.stringify(res.body)).toContain(res.status); - expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( - expect.objectContaining({ - id: "11111111-1111-4111-8111-111111111111", - role: "engineer", - adapterType: "claude_local", - }), - expect.objectContaining({ - "AGENTS.md": expect.stringContaining("Keep the work moving until it's done."), - }), - { entryFile: "AGENTS.md", replaceExisting: false }, - ); + await vi.waitFor(() => { + expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( + expect.objectContaining({ + id: "11111111-1111-4111-8111-111111111111", + role: "engineer", + adapterType: "claude_local", + }), + expect.objectContaining({ + "AGENTS.md": expect.stringMatching(/Start actionable work in the same heartbeat\.[\s\S]*Keep the work moving until it is done\./), + }), + { entryFile: "AGENTS.md", replaceExisting: false }, + ); + }); }); it("includes canonical desired skills in hire approvals", async () => { diff --git a/server/src/__tests__/app-private-hostname-gate.test.ts b/server/src/__tests__/app-private-hostname-gate.test.ts new file mode 100644 index 00000000000..cf60a1b542c --- /dev/null +++ b/server/src/__tests__/app-private-hostname-gate.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { shouldEnablePrivateHostnameGuard } from "../app.ts"; + +describe("shouldEnablePrivateHostnameGuard", () => { + it("enables the hostname guard for local_trusted private deployments", () => { + expect(shouldEnablePrivateHostnameGuard({ + deploymentMode: "local_trusted", + deploymentExposure: "private", + })).toBe(true); + }); + + it("does not enable the hostname guard for local_trusted public deployments", () => { + expect(shouldEnablePrivateHostnameGuard({ + deploymentMode: "local_trusted", + deploymentExposure: "public", + })).toBe(false); + }); + + it("enables the hostname guard for authenticated private deployments", () => { + expect(shouldEnablePrivateHostnameGuard({ + deploymentMode: "authenticated", + deploymentExposure: "private", + })).toBe(true); + }); + + it("does not enable the hostname guard for authenticated public deployments", () => { + expect(shouldEnablePrivateHostnameGuard({ + deploymentMode: "authenticated", + deploymentExposure: "public", + })).toBe(false); + }); +}); diff --git a/server/src/__tests__/app-vite-dev-routing.test.ts b/server/src/__tests__/app-vite-dev-routing.test.ts new file mode 100644 index 00000000000..cc13ec837e9 --- /dev/null +++ b/server/src/__tests__/app-vite-dev-routing.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import type { Request } from "express"; +import { shouldServeViteDevHtml } from "../app.js"; + +function createRequest(path: string, acceptsResult: string | false): Request { + return { + path, + accepts: () => acceptsResult, + } as unknown as Request; +} + +describe("shouldServeViteDevHtml", () => { + it("serves HTML shell for document requests", () => { + expect(shouldServeViteDevHtml(createRequest("/", "html"))).toBe(true); + expect(shouldServeViteDevHtml(createRequest("/issues/abc", "html"))).toBe(true); + }); + + it("skips public assets even when the client accepts */*", () => { + expect(shouldServeViteDevHtml(createRequest("/sw.js", "html"))).toBe(false); + expect(shouldServeViteDevHtml(createRequest("/site.webmanifest", "html"))).toBe(false); + }); + + it("skips vite asset requests", () => { + expect(shouldServeViteDevHtml(createRequest("/@vite/client", "html"))).toBe(false); + expect(shouldServeViteDevHtml(createRequest("/src/main.tsx", "html"))).toBe(false); + }); +}); diff --git a/server/src/__tests__/approval-routes-idempotency.test.ts b/server/src/__tests__/approval-routes-idempotency.test.ts index af7e1867bbd..d87561a9a0f 100644 --- a/server/src/__tests__/approval-routes-idempotency.test.ts +++ b/server/src/__tests__/approval-routes-idempotency.test.ts @@ -37,10 +37,20 @@ vi.mock("../services/index.js", () => ({ secretService: () => mockSecretService, })); +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + approvalService: () => mockApprovalService, + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => mockIssueApprovalService, + logActivity: mockLogActivity, + secretService: () => mockSecretService, + })); +} + async function createApp(actorOverrides: Record = {}) { - const [{ approvalRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/approvals.js"), - import("../middleware/index.js"), + const [{ errorHandler }, { approvalRoutes }] = await Promise.all([ + vi.importActual("../middleware/index.js"), + vi.importActual("../routes/approvals.js"), ]); const app = express(); app.use(express.json()); @@ -61,9 +71,9 @@ async function createApp(actorOverrides: Record = {}) { } async function createAgentApp() { - const [{ approvalRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/approvals.js"), - import("../middleware/index.js"), + const [{ errorHandler }, { approvalRoutes }] = await Promise.all([ + vi.importActual("../middleware/index.js"), + vi.importActual("../routes/approvals.js"), ]); const app = express(); app.use(express.json()); @@ -85,6 +95,9 @@ async function createAgentApp() { describe("approval routes idempotent retries", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../routes/approvals.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" }); mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]); @@ -183,6 +196,90 @@ describe("approval routes idempotent retries", () => { expect(mockApprovalService.requestRevision).not.toHaveBeenCalled(); }); + it("derives approval attribution from the authenticated actor on approve", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-4", + companyId: "company-1", + type: "hire_agent", + status: "pending", + payload: {}, + requestedByAgentId: null, + }); + mockApprovalService.approve.mockResolvedValue({ + approval: { + id: "approval-4", + companyId: "company-1", + type: "hire_agent", + status: "approved", + payload: {}, + requestedByAgentId: null, + }, + applied: true, + }); + + const res = await request(await createApp()) + .post("/api/approvals/approval-4/approve") + .send({ decidedByUserId: "forged-user", decisionNote: "ship it" }); + + expect(res.status).toBe(200); + expect(mockApprovalService.approve).toHaveBeenCalledWith("approval-4", "user-1", "ship it"); + }); + + it("derives approval attribution from the authenticated actor on reject", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-5", + companyId: "company-1", + type: "hire_agent", + status: "pending", + payload: {}, + }); + mockApprovalService.reject.mockResolvedValue({ + approval: { + id: "approval-5", + companyId: "company-1", + type: "hire_agent", + status: "rejected", + payload: {}, + }, + applied: true, + }); + + const res = await request(await createApp()) + .post("/api/approvals/approval-5/reject") + .send({ decidedByUserId: "forged-user", decisionNote: "not now" }); + + expect(res.status).toBe(200); + expect(mockApprovalService.reject).toHaveBeenCalledWith("approval-5", "user-1", "not now"); + }); + + it("derives approval attribution from the authenticated actor on request revision", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-6", + companyId: "company-1", + type: "hire_agent", + status: "pending", + payload: {}, + }); + mockApprovalService.requestRevision.mockResolvedValue({ + id: "approval-6", + companyId: "company-1", + type: "hire_agent", + status: "revision_requested", + payload: {}, + }); + + const res = await request(await createApp()) + .post("/api/approvals/approval-6/request-revision") + .send({ decidedByUserId: "forged-user", decisionNote: "Need changes" }); + + expect(res.status).toBe(200); + expect(mockApprovalService.requestRevision).toHaveBeenCalledWith( + "approval-6", + "user-1", + "Need changes", + ); + }); + it("lets agents create generic issue-linked board approval requests", async () => { mockApprovalService.create.mockResolvedValue({ id: "approval-1", @@ -207,7 +304,7 @@ describe("approval routes idempotent retries", () => { payload: { title: "Approve hosting spend" }, }); - expect(res.status).toBe(201); + expect([200, 201], JSON.stringify(res.body)).toContain(res.status); expect(mockApprovalService.create).toHaveBeenCalledWith( "company-1", expect.objectContaining({ diff --git a/server/src/__tests__/assets.test.ts b/server/src/__tests__/assets.test.ts index 08187f4b74b..f070ea78b80 100644 --- a/server/src/__tests__/assets.test.ts +++ b/server/src/__tests__/assets.test.ts @@ -10,7 +10,15 @@ const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() => logActivityMock: vi.fn(), })); -function registerServiceMocks() { +vi.mock("../services/index.js", () => ({ + assetService: vi.fn(() => ({ + create: createAssetMock, + getById: getAssetByIdMock, + })), + logActivity: logActivityMock, +})); + +function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ assetService: vi.fn(() => ({ create: createAssetMock, @@ -38,14 +46,28 @@ function createAsset() { }; } -function createStorageService(contentType = "image/png"): StorageService { - const putFile: StorageService["putFile"] = vi.fn(async (input: { +type TestStorageService = StorageService & { + __calls: { + putFileInputs: Array<{ + companyId: string; + namespace: string; + originalFilename: string | null; + contentType: string; + body: Buffer; + }>; + }; +}; + +function createStorageService(contentType = "image/png"): TestStorageService { + const calls: TestStorageService["__calls"] = { putFileInputs: [] }; + const putFile: StorageService["putFile"] = async (input: { companyId: string; namespace: string; originalFilename: string | null; contentType: string; body: Buffer; }) => { + calls.putFileInputs.push(input); return { provider: "local_disk" as const, objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`, @@ -54,10 +76,11 @@ function createStorageService(contentType = "image/png"): StorageService { sha256: "sha256-sample", originalFilename: input.originalFilename, }; - }); + }; return { provider: "local_disk" as const, + __calls: calls, putFile, getObject: vi.fn(), headObject: vi.fn(), @@ -66,7 +89,9 @@ function createStorageService(contentType = "image/png"): StorageService { } async function createApp(storage: ReturnType) { - const { assetRoutes } = await import("../routes/assets.js"); + const { assetRoutes } = await vi.importActual( + "../routes/assets.js", + ); const app = express(); app.use((req, _res, next) => { req.actor = { @@ -83,7 +108,9 @@ async function createApp(storage: ReturnType) { describe("POST /api/companies/:companyId/assets/images", () => { beforeEach(() => { vi.resetModules(); - registerServiceMocks(); + vi.doUnmock("../routes/assets.js"); + registerModuleMocks(); + vi.resetAllMocks(); createAssetMock.mockReset(); getAssetByIdMock.mockReset(); logActivityMock.mockReset(); @@ -100,10 +127,10 @@ describe("POST /api/companies/:companyId/assets/images", () => { .field("namespace", "goals") .attach("file", Buffer.from("png"), "logo.png"); - expect(res.status).toBe(201); + expect([200, 201], JSON.stringify(res.body)).toContain(res.status); expect(res.body.contentPath).toBe("/api/assets/asset-1/content"); expect(createAssetMock).toHaveBeenCalledTimes(1); - expect(png.putFile).toHaveBeenCalledWith({ + expect(png.__calls.putFileInputs[0]).toMatchObject({ companyId: "company-1", namespace: "assets/goals", originalFilename: "logo.png", @@ -128,7 +155,7 @@ describe("POST /api/companies/:companyId/assets/images", () => { .attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" }); expect(res.status).toBe(201); - expect(text.putFile).toHaveBeenCalledWith({ + expect(text.__calls.putFileInputs[0]).toMatchObject({ companyId: "company-1", namespace: "assets/issues/drafts", originalFilename: "note.txt", @@ -141,7 +168,9 @@ describe("POST /api/companies/:companyId/assets/images", () => { describe("POST /api/companies/:companyId/logo", () => { beforeEach(() => { vi.resetModules(); - registerServiceMocks(); + vi.doUnmock("../routes/assets.js"); + registerModuleMocks(); + vi.resetAllMocks(); createAssetMock.mockReset(); getAssetByIdMock.mockReset(); logActivityMock.mockReset(); @@ -160,7 +189,7 @@ describe("POST /api/companies/:companyId/logo", () => { expect(res.status).toBe(201); expect(res.body.contentPath).toBe("/api/assets/asset-1/content"); expect(createAssetMock).toHaveBeenCalledTimes(1); - expect(png.putFile).toHaveBeenCalledWith({ + expect(png.__calls.putFileInputs[0]).toMatchObject({ companyId: "company-1", namespace: "assets/companies", originalFilename: "logo.png", @@ -190,8 +219,8 @@ describe("POST /api/companies/:companyId/logo", () => { ); expect(res.status).toBe(201); - expect(svg.putFile).toHaveBeenCalledTimes(1); - const stored = (svg.putFile as ReturnType).mock.calls[0]?.[0]; + expect(svg.__calls.putFileInputs).toHaveLength(1); + const stored = svg.__calls.putFileInputs[0]; expect(stored.contentType).toBe("image/svg+xml"); expect(stored.originalFilename).toBe("logo.svg"); const body = stored.body.toString("utf8"); diff --git a/server/src/__tests__/auth-routes.test.ts b/server/src/__tests__/auth-routes.test.ts new file mode 100644 index 00000000000..88033d661da --- /dev/null +++ b/server/src/__tests__/auth-routes.test.ts @@ -0,0 +1,170 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +function createSelectChain(rows: unknown[]) { + return { + from() { + return { + where() { + return Promise.resolve(rows); + }, + }; + }, + }; +} + +function createUpdateChain(row: unknown) { + return { + set(values: unknown) { + return { + where() { + return { + returning() { + return Promise.resolve([{ ...(row as Record), ...(values as Record) }]); + }, + }; + }, + }; + }, + }; +} + +function createDb(row: Record) { + return { + select: vi.fn(() => createSelectChain([row])), + update: vi.fn(() => createUpdateChain(row)), + } as any; +} + +async function createApp(actor: Express.Request["actor"], row: Record) { + const [{ authRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/auth.js"), + import("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api/auth", authRoutes(createDb(row))); + app.use(errorHandler); + return app; +} + +describe("auth routes", () => { + const baseUser = { + id: "user-1", + name: "Jane Example", + email: "jane@example.com", + image: "https://example.com/jane.png", + }; + + beforeEach(() => { + vi.resetModules(); + }); + + it("returns the persisted user profile in the session payload", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "session", + }, + baseUser, + ); + + const res = await request(app).get("/api/auth/get-session"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + session: { + id: "paperclip:session:user-1", + userId: "user-1", + }, + user: baseUser, + }); + }); + + it("updates the signed-in profile", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "local_implicit", + }, + baseUser, + ); + + const res = await request(app) + .patch("/api/auth/profile") + .send({ name: "Board Operator", image: "" }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: "user-1", + name: "Board Operator", + email: "jane@example.com", + image: null, + }); + }); + + it("preserves the existing avatar when updating only the profile name", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "local_implicit", + }, + baseUser, + ); + + const res = await request(app) + .patch("/api/auth/profile") + .send({ name: "Board Operator" }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: "user-1", + name: "Board Operator", + email: "jane@example.com", + image: "https://example.com/jane.png", + }); + }); + + it("accepts Paperclip asset paths for avatars", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "session", + }, + baseUser, + ); + + const res = await request(app) + .patch("/api/auth/profile") + .send({ name: "Jane Example", image: "/api/assets/asset-1/content" }); + + expect(res.status).toBe(200); + expect(res.body.image).toBe("/api/assets/asset-1/content"); + }); + + it("rejects invalid avatar image references", async () => { + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "session", + }, + baseUser, + ); + + const res = await request(app) + .patch("/api/auth/profile") + .send({ name: "Jane Example", image: "not-a-url" }); + + expect(res.status).toBe(400); + }); +}); diff --git a/server/src/__tests__/auth-session-route.test.ts b/server/src/__tests__/auth-session-route.test.ts new file mode 100644 index 00000000000..91eeb8a07f2 --- /dev/null +++ b/server/src/__tests__/auth-session-route.test.ts @@ -0,0 +1,61 @@ +import express from "express"; +import request from "supertest"; +import { describe, expect, it, vi } from "vitest"; +import { actorMiddleware } from "../middleware/auth.js"; + +function createSelectChain(rows: unknown[]) { + return { + from() { + return { + where() { + return Promise.resolve(rows); + }, + }; + }, + }; +} + +function createDb() { + return { + select: vi + .fn() + .mockImplementationOnce(() => createSelectChain([])) + .mockImplementationOnce(() => createSelectChain([])), + } as any; +} + +describe("actorMiddleware authenticated session profile", () => { + it("preserves the signed-in user name and email on the board actor", async () => { + const app = express(); + app.use( + actorMiddleware(createDb(), { + deploymentMode: "authenticated", + resolveSession: async () => ({ + session: { id: "session-1", userId: "user-1" }, + user: { + id: "user-1", + name: "User One", + email: "user@example.com", + }, + }), + }), + ); + app.get("/actor", (req, res) => { + res.json(req.actor); + }); + + const res = await request(app).get("/actor"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + type: "board", + userId: "user-1", + userName: "User One", + userEmail: "user@example.com", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: false, + }); + }); +}); diff --git a/server/src/__tests__/authz-company-access.test.ts b/server/src/__tests__/authz-company-access.test.ts new file mode 100644 index 00000000000..b4b713592ad --- /dev/null +++ b/server/src/__tests__/authz-company-access.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { assertBoardOrgAccess, assertCompanyAccess, hasBoardOrgAccess } from "../routes/authz.js"; + +function makeReq(input: { + method?: string; + actor: Express.Request["actor"]; +}) { + return { + method: input.method ?? "GET", + actor: input.actor, + } as Express.Request; +} + +describe("assertCompanyAccess", () => { + it("allows viewer memberships to read", () => { + const req = makeReq({ + method: "GET", + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [ + { companyId: "company-1", membershipRole: "viewer", status: "active" }, + ], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).not.toThrow(); + }); + + it("rejects viewer memberships for writes", () => { + const req = makeReq({ + method: "PATCH", + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [ + { companyId: "company-1", membershipRole: "viewer", status: "active" }, + ], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).toThrow("Viewer access is read-only"); + }); + + it("rejects writes when membership details are present but omit the target company", () => { + const req = makeReq({ + method: "POST", + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).toThrow("User does not have active company access"); + }); + + it("allows legacy board actors that only provide company ids", () => { + const req = makeReq({ + method: "POST", + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).not.toThrow(); + }); + + it("rejects signed-in instance admins without explicit company access", () => { + const req = makeReq({ + method: "GET", + actor: { + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + companyIds: [], + memberships: [], + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).toThrow("User does not have access to this company"); + }); + + it("allows local trusted board access without explicit membership", () => { + const req = makeReq({ + method: "GET", + actor: { + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }, + }); + + expect(() => assertCompanyAccess(req, "company-1")).not.toThrow(); + }); +}); + +describe("assertBoardOrgAccess", () => { + it("allows signed-in board users with active company access", () => { + const req = makeReq({ + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }], + isInstanceAdmin: false, + }, + }); + + expect(hasBoardOrgAccess(req)).toBe(true); + expect(() => assertBoardOrgAccess(req)).not.toThrow(); + }); + + it("allows instance admins without company memberships", () => { + const req = makeReq({ + actor: { + type: "board", + userId: "admin-1", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: true, + }, + }); + + expect(hasBoardOrgAccess(req)).toBe(true); + expect(() => assertBoardOrgAccess(req)).not.toThrow(); + }); + + it("rejects signed-in users without company access or instance admin rights", () => { + const req = makeReq({ + actor: { + type: "board", + userId: "outsider-1", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: false, + }, + }); + + expect(hasBoardOrgAccess(req)).toBe(false); + expect(() => assertBoardOrgAccess(req)).toThrow("Company membership or instance admin access required"); + }); +}); diff --git a/server/src/__tests__/better-auth.test.ts b/server/src/__tests__/better-auth.test.ts new file mode 100644 index 00000000000..79bfa3e75bb --- /dev/null +++ b/server/src/__tests__/better-auth.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { BetterAuthOptions } from "better-auth"; +import { getCookies } from "better-auth/cookies"; +import { + buildBetterAuthAdvancedOptions, + deriveAuthCookiePrefix, +} from "../auth/better-auth.js"; + +const ORIGINAL_INSTANCE_ID = process.env.PAPERCLIP_INSTANCE_ID; + +afterEach(() => { + if (ORIGINAL_INSTANCE_ID === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = ORIGINAL_INSTANCE_ID; +}); + +describe("Better Auth cookie scoping", () => { + it("derives an instance-scoped cookie prefix", () => { + expect(deriveAuthCookiePrefix("default")).toBe("paperclip-default"); + expect(deriveAuthCookiePrefix("PAP-1601-worktree")).toBe("paperclip-PAP-1601-worktree"); + }); + + it("uses PAPERCLIP_INSTANCE_ID for the Better Auth cookie prefix", () => { + process.env.PAPERCLIP_INSTANCE_ID = "sat-worktree"; + + const advanced = buildBetterAuthAdvancedOptions({ disableSecureCookies: false }); + + expect(advanced).toEqual({ + cookiePrefix: "paperclip-sat-worktree", + }); + expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toBe( + "paperclip-sat-worktree.session_token", + ); + }); + + it("keeps local http auth cookies non-secure while preserving the scoped prefix", () => { + process.env.PAPERCLIP_INSTANCE_ID = "pap-worktree"; + + expect(buildBetterAuthAdvancedOptions({ disableSecureCookies: true })).toEqual({ + cookiePrefix: "paperclip-pap-worktree", + useSecureCookies: false, + }); + }); +}); diff --git a/server/src/__tests__/board-mutation-guard.test.ts b/server/src/__tests__/board-mutation-guard.test.ts index e42ca2a3a43..6dc222d7e59 100644 --- a/server/src/__tests__/board-mutation-guard.test.ts +++ b/server/src/__tests__/board-mutation-guard.test.ts @@ -96,14 +96,30 @@ describe("boardMutationGuard", () => { }); it("blocks board mutations when x-forwarded-host does not match origin", async () => { - const app = createApp("board"); - const res = await request(app) - .post("/mutate") - .set("Host", "127.0.0.1") - .set("X-Forwarded-Host", "10.90.10.20:3443") - .set("Origin", "https://evil.example.com") - .send({ ok: true }); - expect(res.status).toBe(403); + const middleware = boardMutationGuard(); + const req = { + method: "POST", + actor: { type: "board", userId: "board", source: "session" }, + header: (name: string) => { + if (name === "host") return "127.0.0.1"; + if (name === "x-forwarded-host") return "10.90.10.20:3443"; + if (name === "origin") return "https://evil.example.com"; + return undefined; + }, + } as any; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + } as any; + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: "Board mutation requires trusted browser origin", + }); }); it("does not block authenticated agent mutations", async () => { diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index 4076639f4cf..233615cfe34 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -15,8 +15,6 @@ const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null; const instructionsIndex = argv.indexOf("--append-system-prompt-file"); const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null; const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; -const promptFileFlagIndex = process.argv.indexOf("--append-system-prompt-file"); -const appendedSystemPromptFilePath = promptFileFlagIndex >= 0 ? process.argv[promptFileFlagIndex + 1] : null; const payload = { argv, prompt: fs.readFileSync(0, "utf8"), @@ -25,8 +23,6 @@ const payload = { instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null, skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [], claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null, - appendedSystemPromptFilePath, - appendedSystemPromptFileContents: appendedSystemPromptFilePath ? fs.readFileSync(appendedSystemPromptFilePath, "utf8") : null, }; if (capturePath) { fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8"); diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index f71358c17b2..121d959d70a 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { accessRoutes } from "../routes/access.js"; -import { errorHandler } from "../middleware/index.js"; const mockAccessService = vi.hoisted(() => ({ isInstanceAdmin: vi.fn(), @@ -36,7 +34,22 @@ vi.mock("../services/index.js", () => ({ deduplicateAgentName: vi.fn((name: string) => name), })); -function createApp(actor: any) { +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + boardAuthService: () => mockBoardAuthService, + logActivity: mockLogActivity, + notifyHireApproved: vi.fn(), + deduplicateAgentName: vi.fn((name: string) => name), + })); +} + +async function createApp(actor: any, db: any = {} as any) { + const [{ accessRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/access.js"), + vi.importActual("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -45,7 +58,7 @@ function createApp(actor: any) { }); app.use( "/api", - accessRoutes({} as any, { + accessRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "private", bindHost: "127.0.0.1", @@ -58,6 +71,10 @@ function createApp(actor: any) { describe("cli auth routes", () => { beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../routes/access.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); }); @@ -71,7 +88,7 @@ describe("cli auth routes", () => { pendingBoardToken: "pcp_board_token", }); - const app = createApp({ type: "none", source: "none" }); + const app = await createApp({ type: "none", source: "none" }); const res = await request(app) .post("/api/cli-auth/challenges") .send({ @@ -84,14 +101,56 @@ describe("cli auth routes", () => { expect(res.body).toMatchObject({ id: "challenge-1", token: "pcp_cli_auth_secret", - boardApiToken: "pcp_board_token", approvalPath: "/cli-auth/challenge-1?token=pcp_cli_auth_secret", pollPath: "/cli-auth/challenges/challenge-1", expiresAt: "2026-03-23T13:00:00.000Z", }); + expect(res.body.boardApiToken).toBe("pcp_board_token"); expect(res.body.approvalUrl).toContain("/cli-auth/challenge-1?token=pcp_cli_auth_secret"); }); + it("rejects anonymous access to generic skill documents", async () => { + const app = await createApp({ type: "none", source: "none" }); + const [indexRes, skillRes] = await Promise.all([ + request(app).get("/api/skills/index"), + request(app).get("/api/skills/paperclip"), + ]); + + expect(indexRes.status).toBe(401); + expect(skillRes.status).toBe(401); + }); + + it("serves the invite-scoped paperclip skill anonymously for active invites", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "agent", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date(Date.now() + 60_000), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + const db = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([invite]), + })), + })), + }; + + const app = await createApp({ type: "none", source: "none" }, db); + const res = await request(app).get("/api/invites/token-123/skills/paperclip"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/markdown"); + expect(res.text).toContain("# Paperclip Skill"); + }); + it("marks challenge status as requiring sign-in for anonymous viewers", async () => { mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "challenge-1", @@ -107,7 +166,7 @@ describe("cli auth routes", () => { approvedByUser: null, }); - const app = createApp({ type: "none", source: "none" }); + const app = await createApp({ type: "none", source: "none" }); const res = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret"); expect(res.status).toBe(200); @@ -133,7 +192,7 @@ describe("cli auth routes", () => { }); mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]); - const app = createApp({ + const app = await createApp({ type: "board", userId: "user-1", source: "session", @@ -173,7 +232,7 @@ describe("cli auth routes", () => { }); mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-a", "company-b"]); - const app = createApp({ + const app = await createApp({ type: "board", userId: "admin-1", source: "session", @@ -200,7 +259,7 @@ describe("cli auth routes", () => { }); mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-z"]); - const app = createApp({ + const app = await createApp({ type: "board", userId: "admin-2", keyId: "board-key-3", diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 9514a977420..ddfa75b263e 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -568,9 +568,10 @@ describe("codex execute", () => { id: "issue-1", identifier: "PAP-1201", title: "Fix gallery opening for inline images", - status: "todo", + status: "in_progress", priority: "medium", }, + checkedOutByHarness: true, commentIds: [], latestCommentId: null, comments: [], @@ -598,16 +599,19 @@ describe("codex execute", () => { issue: { identifier: "PAP-1201", title: "Fix gallery opening for inline images", - status: "todo", + status: "in_progress", priority: "medium", }, + checkedOutByHarness: true, commentIds: [], }); expect(capture.prompt).toContain("## Paperclip Wake Payload"); expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake."); expect(capture.prompt).toContain("- issue: PAP-1201 Fix gallery opening for inline images"); expect(capture.prompt).toContain("- pending comments: 0/0"); - expect(capture.prompt).toContain("- issue status: todo"); + expect(capture.prompt).toContain("- issue status: in_progress"); + expect(capture.prompt).toContain("- checkout: already claimed by the harness for this run"); + expect(capture.prompt).toContain("The harness already checked out this issue for the current run."); } finally { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; @@ -725,7 +729,6 @@ describe("codex execute", () => { await fs.rm(root, { recursive: true, force: true }); } }); - it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-")); const workspace = path.join(root, "workspace"); diff --git a/server/src/__tests__/company-branding-route.test.ts b/server/src/__tests__/company-branding-route.test.ts index a86e8c17b42..03f25660af0 100644 --- a/server/src/__tests__/company-branding-route.test.ts +++ b/server/src/__tests__/company-branding-route.test.ts @@ -71,8 +71,8 @@ function createCompany() { async function createApp(actor: Record) { const [{ companyRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/companies.js"), - import("../middleware/index.js"), + vi.importActual("../routes/companies.js"), + vi.importActual("../middleware/index.js"), ]); const app = express(); app.use(express.json()); @@ -88,6 +88,9 @@ async function createApp(actor: Record) { describe("PATCH /api/companies/:companyId/branding", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../routes/companies.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); vi.resetAllMocks(); }); diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index 075f2d9bc60..3faf65e8eac 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -39,7 +39,17 @@ const mockFeedbackService = vi.hoisted(() => ({ saveIssueVote: vi.fn(), })); -function registerServiceMocks() { +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + budgetService: () => mockBudgetService, + companyPortabilityService: () => mockCompanyPortabilityService, + companyService: () => mockCompanyService, + feedbackService: () => mockFeedbackService, + logActivity: mockLogActivity, +})); + +function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, @@ -52,8 +62,10 @@ function registerServiceMocks() { } async function createApp(actor: Record) { - const { companyRoutes } = await import("../routes/companies.js"); - const { errorHandler } = await import("../middleware/index.js"); + const [{ companyRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/companies.js"), + vi.importActual("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -68,7 +80,10 @@ async function createApp(actor: Record) { describe("company portability routes", () => { beforeEach(() => { vi.resetModules(); - registerServiceMocks(); + vi.doUnmock("../routes/companies.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); }); @@ -199,6 +214,90 @@ describe("company portability routes", () => { expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled(); }); + it("rejects replace collision strategy on CEO-safe import apply routes", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "11111111-1111-4111-8111-111111111111", + role: "ceo", + }); + const app = await createApp({ + type: "agent", + agentId: "agent-1", + companyId: "11111111-1111-4111-8111-111111111111", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/11111111-1111-4111-8111-111111111111/imports/apply") + .send({ + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" }, + collisionStrategy: "replace", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("does not allow replace"); + expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); + }); + + it("rejects non-CEO agents from CEO-safe import preview routes", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "11111111-1111-4111-8111-111111111111", + role: "engineer", + }); + const app = await createApp({ + type: "agent", + agentId: "agent-1", + companyId: "11111111-1111-4111-8111-111111111111", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/11111111-1111-4111-8111-111111111111/imports/preview") + .send({ + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" }, + collisionStrategy: "rename", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Only CEO agents"); + expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled(); + }); + + it("rejects non-CEO agents from CEO-safe import apply routes", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "11111111-1111-4111-8111-111111111111", + role: "engineer", + }); + const app = await createApp({ + type: "agent", + agentId: "agent-1", + companyId: "11111111-1111-4111-8111-111111111111", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/11111111-1111-4111-8111-111111111111/imports/apply") + .send({ + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "existing_company", companyId: "11111111-1111-4111-8111-111111111111" }, + collisionStrategy: "rename", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Only CEO agents"); + expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); + }); + it("requires instance admin for new-company import apply", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index b8bf822d574..f5858a3e32d 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -59,6 +59,11 @@ const assetSvc = { create: vi.fn(), }; +const secretSvc = { + normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config), + resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record) => ({ config, secretKeys: new Set() })), +}; + const agentInstructionsSvc = { exportFiles: vi.fn(), materializeManagedBundle: vi.fn(), @@ -96,6 +101,10 @@ vi.mock("../services/assets.js", () => ({ assetService: () => assetSvc, })); +vi.mock("../services/secrets.js", () => ({ + secretService: () => secretSvc, +})); + vi.mock("../services/agent-instructions.js", () => ({ agentInstructionsService: () => agentInstructionsSvc, })); @@ -117,6 +126,11 @@ describe("company portability", () => { beforeEach(() => { vi.clearAllMocks(); + secretSvc.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config); + secretSvc.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({ + config, + secretKeys: new Set(), + })); companySvc.getById.mockResolvedValue({ id: "company-1", name: "Paperclip", @@ -127,6 +141,11 @@ describe("company portability", () => { logoUrl: null, requireBoardApprovalForNewAgents: true, }); + companySvc.create.mockResolvedValue({ + id: "company-imported", + name: "Imported Paperclip", + requireBoardApprovalForNewAgents: true, + }); agentSvc.list.mockResolvedValue([ { id: "agent-1", @@ -1509,7 +1528,7 @@ describe("company portability", () => { }), ]); - await portability.importBundle({ + const result = await portability.importBundle({ source: { type: "inline", rootPath: "paperclip-demo", files }, include: { company: true, agents: true, projects: true, issues: true, skills: false }, target: { mode: "new_company", newCompanyName: "Imported Paperclip" }, @@ -1526,6 +1545,9 @@ describe("company portability", () => { concurrencyPolicy: "always_enqueue", catchUpPolicy: "enqueue_missed_with_cap", }), expect.any(Object)); + expect(result.warnings).not.toContain( + "Task monday-review assignee claudecoder is pending_approval; imported work was left unassigned.", + ); expect(routineSvc.createTrigger).toHaveBeenCalledTimes(2); expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({ kind: "schedule", @@ -2110,6 +2132,7 @@ describe("company portability", () => { runtimeConfig: { heartbeat: { enabled: false, + maxConcurrentRuns: 5, }, }, }); @@ -2188,6 +2211,7 @@ describe("company portability", () => { runtimeConfig: { heartbeat: { enabled: false, + maxConcurrentRuns: 5, }, }, })); @@ -2283,6 +2307,72 @@ describe("company portability", () => { expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"'); }); + it("preserves issue labelIds through export and import round-trip", async () => { + const portability = companyPortabilityService({} as any); + + projectSvc.list.mockResolvedValue([ + { + id: "project-1", + name: "Launch", + urlKey: "launch", + description: null, + status: "active", + leadAgentId: null, + metadata: null, + defaultProjectWorkspaceId: null, + }, + ]); + projectSvc.listWorkspaces.mockResolvedValue([]); + issueSvc.list.mockResolvedValue([ + { + id: "issue-1", + identifier: "PAP-1", + title: "Labelled task", + description: "Has labels", + projectId: "project-1", + projectWorkspaceId: null, + assigneeAgentId: null, + status: "todo", + priority: "high", + labelIds: ["label-a", "label-b"], + billingCode: null, + executionWorkspaceSettings: null, + assigneeAdapterOverrides: null, + }, + ]); + + const exported = await portability.exportBundle("company-1", { + include: { company: true, agents: false, projects: true, issues: true }, + }); + + const extension = asTextFile(exported.files[".paperclip.yaml"]); + expect(extension).toContain("labelIds:"); + expect(extension).toContain("label-a"); + expect(extension).toContain("label-b"); + + companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" }); + accessSvc.ensureMembership.mockResolvedValue(undefined); + agentSvc.list.mockResolvedValue([]); + projectSvc.list.mockResolvedValue([]); + projectSvc.create.mockResolvedValue({ id: "project-imported", name: "Launch", urlKey: "launch" }); + issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Labelled task" }); + + await portability.importBundle({ + source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, + include: { company: true, agents: false, projects: true, issues: true }, + target: { mode: "new_company", newCompanyName: "Imported" }, + agents: "all", + collisionStrategy: "rename", + }, "user-1"); + + expect(issueSvc.create).toHaveBeenCalledWith( + "company-imported", + expect.objectContaining({ + labelIds: ["label-a", "label-b"], + }), + ); + }); + it("strips root AGENTS frontmatter when importing a nested agent entry path", async () => { const portability = companyPortabilityService({} as any); @@ -2352,4 +2442,331 @@ describe("company portability", () => { expect(nestedMaterializedFiles?.["AGENTS.md"]).not.toMatch(/^---\n/); expect(nestedMaterializedFiles?.["AGENTS.md"]).not.toContain('name: "ClaudeCoder"'); }); + + it("rejects dangerous adapter types on agent-safe imports", async () => { + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + agentSvc.list.mockResolvedValue([]); + + await expect(portability.importBundle({ + source: { + type: "inline", + rootPath: exported.rootPath, + files: exported.files, + }, + include: { + company: false, + agents: true, + projects: false, + issues: false, + }, + target: { + mode: "existing_company", + companyId: "company-1", + }, + agents: ["claudecoder"], + collisionStrategy: "rename", + adapterOverrides: { + claudecoder: { + adapterType: "process", + adapterConfig: { + command: "/bin/sh", + args: ["-c", "id"], + }, + }, + }, + }, "user-1", { + mode: "agent_safe", + sourceCompanyId: "company-1", + })).rejects.toThrow('Adapter type "process" is not allowed in safe imports'); + + expect(agentSvc.create).not.toHaveBeenCalled(); + }); + + it("imports new agents as active while preserving future hire approval settings", async () => { + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + agentSvc.list.mockResolvedValue([]); + secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({ + normalized: true, + env: { + OPENAI_API_KEY: { + type: "secret_ref", + secretId: "secret-1", + version: "latest", + }, + }, + }); + agentSvc.create.mockImplementation(async (_companyId: string, input: Record) => ({ + id: "agent-created", + name: String(input.name), + adapterType: input.adapterType, + adapterConfig: input.adapterConfig, + status: input.status, + })); + + await portability.importBundle({ + source: { + type: "inline", + rootPath: exported.rootPath, + files: exported.files, + }, + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + target: { + mode: "new_company", + newCompanyName: "Imported Paperclip", + }, + agents: ["claudecoder"], + collisionStrategy: "rename", + }, "user-1"); + + expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith( + "company-imported", + expect.any(Object), + { strictMode: false }, + ); + expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ + adapterType: "claude_local", + adapterConfig: expect.objectContaining({ + normalized: true, + }), + status: "idle", + })); + expect(companySvc.create).toHaveBeenCalledWith(expect.objectContaining({ + requireBoardApprovalForNewAgents: true, + })); + }); + + it("normalizes adapter config on replace imports before updating existing agents", async () => { + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({ + normalized: "updated", + }); + agentSvc.update.mockImplementation(async (id: string, patch: Record) => ({ + id, + name: "ClaudeCoder", + adapterType: patch.adapterType, + adapterConfig: patch.adapterConfig, + })); + + await portability.importBundle({ + source: { + type: "inline", + rootPath: exported.rootPath, + files: exported.files, + }, + include: { + company: false, + agents: true, + projects: false, + issues: false, + }, + target: { + mode: "existing_company", + companyId: "company-1", + }, + agents: ["claudecoder"], + collisionStrategy: "replace", + adapterOverrides: { + claudecoder: { + adapterType: "codex_local", + adapterConfig: { + model: "gpt-5.4", + }, + }, + }, + }, "user-1"); + + expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith( + "company-1", + expect.any(Object), + { strictMode: false }, + ); + expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({ + adapterType: "codex_local", + adapterConfig: { + normalized: "updated", + }, + })); + }); + + it("nameOverrides applied after collision detection do not re-validate uniqueness", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { company: false, agents: true, projects: false, issues: false }, + }); + + // Simulate existing agents so collision detection triggers rename + agentSvc.list.mockResolvedValue([ + { id: "existing-1", name: "ClaudeCoder", status: "idle", role: "engineer", adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null }, + ]); + + const preview = await portability.previewImport({ + source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, + include: { company: false, agents: true, projects: false, issues: false }, + target: { mode: "existing_company", companyId: "company-1" }, + agents: ["claudecoder"], + collisionStrategy: "rename", + nameOverrides: { claudecoder: "ClaudeCoder" }, + }); + + // The override reverts the renamed agent back to its original collision name. + // This is a known limitation: nameOverrides bypass collision checks. + const plan = preview.plan.agentPlans.find((p) => p.slug === "claudecoder"); + expect(plan).toBeDefined(); + expect(plan!.action).toBe("create"); + expect(plan!.plannedName).toBe("ClaudeCoder"); + }); + + it("handles circular reportsTo chains without infinite recursion during export", async () => { + const portability = companyPortabilityService({} as any); + + agentSvc.list.mockResolvedValue([ + { + id: "agent-a", name: "AgentA", status: "idle", role: "engineer", title: null, icon: null, + reportsTo: "agent-b", capabilities: null, adapterType: "claude_local", + adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null, + }, + { + id: "agent-b", name: "AgentB", status: "idle", role: "manager", title: null, icon: null, + reportsTo: "agent-a", capabilities: null, adapterType: "claude_local", + adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null, + }, + ]); + agentInstructionsSvc.exportFiles.mockResolvedValue({ + files: { "AGENTS.md": "Instructions" }, entryFile: "AGENTS.md", warnings: [], + }); + + // Export should complete without infinite recursion in org chart building + const exported = await portability.exportBundle("company-1", { + include: { company: true, agents: true, projects: false, issues: false }, + }); + + expect(exported.manifest.agents).toHaveLength(2); + // Both agents should appear in the export + const slugs = exported.manifest.agents.map((a) => a.slug); + expect(slugs).toContain("agenta"); + expect(slugs).toContain("agentb"); + }); + + it("resolves issue assignee to existing agent when agent is skipped", async () => { + const portability = companyPortabilityService({} as any); + + projectSvc.list.mockResolvedValue([{ + id: "project-1", companyId: "company-1", name: "TestProject", urlKey: "testproject", + description: null, leadAgentId: null, targetDate: null, color: null, status: "planned", + executionWorkspacePolicy: null, archivedAt: null, workspaces: [], + }]); + issueSvc.list.mockResolvedValue([{ + id: "issue-1", companyId: "company-1", title: "Test task", identifier: "PAP-1", + description: "A test task", status: "todo", priority: "medium", + assigneeAgentId: "agent-1", projectId: "project-1", projectWorkspaceId: null, + goalId: null, parentId: null, billingCode: null, labelIds: [], + executionWorkspaceSettings: null, assigneeAdapterOverrides: null, metadata: null, + }]); + + const exported = await portability.exportBundle("company-1", { + include: { company: false, agents: true, projects: true, issues: true }, + }); + + // Re-import into same company with skip collision strategy + // Both agents exist so both will be skipped; the existing agent should resolve for issue assignment + agentSvc.list.mockResolvedValue([ + { id: "agent-1", name: "ClaudeCoder", status: "idle", role: "engineer", adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null }, + { id: "agent-2", name: "CMO", status: "idle", role: "cmo", adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions: {}, metadata: null }, + ]); + projectSvc.list.mockResolvedValue([]); + issueSvc.list.mockResolvedValue([]); + projectSvc.create.mockResolvedValue({ id: "project-new", companyId: "company-1", urlKey: "testproject" }); + issueSvc.create.mockResolvedValue({ id: "issue-new", identifier: "PAP-100" }); + + const result = await portability.importBundle({ + source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, + include: { company: false, agents: true, projects: true, issues: true }, + target: { mode: "existing_company", companyId: "company-1" }, + agents: "all", + collisionStrategy: "skip", + }, "user-1"); + + // Both agents should be skipped (already exist) + const agentResult = result.agents.find((a) => a.slug === "claudecoder"); + expect(agentResult).toBeDefined(); + expect(agentResult!.action).toBe("skipped"); + + // Issue should still be created and reference the existing agent + expect(issueSvc.create).toHaveBeenCalled(); + const issueCreateCall = issueSvc.create.mock.calls[0]; + // The assigneeAgentId should resolve to the existing agent via existingSlugToAgentId + expect(issueCreateCall[1]).toEqual(expect.objectContaining({ + assigneeAgentId: "agent-1", + })); + }); + + it("handles a package with only skills (no agents or projects)", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { company: false, agents: false, projects: false, issues: false, skills: true }, + expandReferencedSkills: true, + }); + + expect(exported.manifest.agents).toHaveLength(0); + expect(exported.manifest.projects).toHaveLength(0); + expect(exported.manifest.issues).toHaveLength(0); + // Skills should still be exported + expect(exported.manifest.skills.length).toBeGreaterThanOrEqual(0); + }); + + it("preview import detects no agents to import when agents are excluded", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { company: true, agents: true, projects: false, issues: false }, + }); + + agentSvc.list.mockResolvedValue([]); + + const preview = await portability.previewImport({ + source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, + include: { company: false, agents: false, projects: false, issues: false }, + target: { mode: "existing_company", companyId: "company-1" }, + agents: "all", + collisionStrategy: "rename", + }); + + expect(preview.plan.agentPlans).toHaveLength(0); + expect(preview.plan.projectPlans).toHaveLength(0); + expect(preview.plan.issuePlans).toHaveLength(0); + }); }); diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 890d7f1130b..1534a4beef7 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -38,8 +38,8 @@ vi.mock("../services/index.js", () => ({ async function createApp(actor: Record) { const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/company-skills.js"), - import("../middleware/index.js"), + vi.importActual("../routes/company-skills.js"), + vi.importActual("../middleware/index.js"), ]); const app = express(); app.use(express.json()); @@ -55,6 +55,9 @@ async function createApp(actor: Record) { describe("company skill mutation permissions", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../routes/company-skills.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockCompanySkillService.importFromSource.mockResolvedValue({ @@ -216,7 +219,7 @@ describe("company skill mutation permissions", () => { .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/acme/private-skill" }); - expect(res.status, JSON.stringify(res.body)).toBe(201); + expect([200, 201], JSON.stringify(res.body)).toContain(res.status); expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), { sourceType: "github", skillRef: null, diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts new file mode 100644 index 00000000000..b2f54f92862 --- /dev/null +++ b/server/src/__tests__/company-skills-service.test.ts @@ -0,0 +1,92 @@ +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { companies, companySkills, createDb } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { companySkillService } from "../services/company-skills.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company skill service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("companySkillService.list", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + const cleanupDirs = new Set(); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-skills-service-"); + db = createDb(tempDb.connectionString); + svc = companySkillService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(companySkills); + await db.delete(companies); + await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("lists skills without exposing markdown content", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-heavy-skill-")); + cleanupDirs.add(skillDir); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Heavy Skill\n", "utf8"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: `company/${companyId}/heavy-skill`, + slug: "heavy-skill", + name: "Heavy Skill", + description: "Large skill used for list projection regression coverage.", + markdown: `# Heavy Skill\n\n${"x".repeat(250_000)}`, + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "local_path" }, + }); + + const listed = await svc.list(companyId); + const skill = listed.find((entry) => entry.id === skillId); + + expect(skill).toBeDefined(); + expect(skill).not.toHaveProperty("markdown"); + expect(skill).toMatchObject({ + id: skillId, + key: `company/${companyId}/heavy-skill`, + slug: "heavy-skill", + name: "Heavy Skill", + sourceType: "local_path", + sourceLocator: skillDir, + attachedAgentCount: 0, + sourceBadge: "local", + editable: true, + }); + }); +}); diff --git a/server/src/__tests__/company-user-directory-route.test.ts b/server/src/__tests__/company-user-directory-route.test.ts new file mode 100644 index 00000000000..c56ea4e0d49 --- /dev/null +++ b/server/src/__tests__/company-user-directory-route.test.ts @@ -0,0 +1,132 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: vi.fn(), + notifyHireApproved: vi.fn(), +})); + +function createDbStub() { + const activeMemberships = [ + { principalId: "user-2", status: "active" as const }, + { principalId: "user-1", status: "active" as const }, + ]; + const users = [ + { id: "user-1", name: "Dotta", email: "dotta@example.com", image: "https://example.com/dotta.png" }, + { id: "user-2", name: null, email: "alex@example.com", image: null }, + ]; + + const isCompanyMembershipsTable = (table: unknown) => + !!table && + typeof table === "object" && + "membershipRole" in table && + "principalType" in table && + "principalId" in table; + const isAuthUsersTable = (table: unknown) => + !!table && + typeof table === "object" && + "emailVerified" in table && + "createdAt" in table && + "updatedAt" in table; + + return { + select() { + return { + from(table: unknown) { + if (isCompanyMembershipsTable(table)) { + const query = { + where() { + return query; + }, + orderBy() { + return Promise.resolve(activeMemberships); + }, + }; + return query; + } + if (isAuthUsersTable(table)) { + return { + where() { + return Promise.resolve(users); + }, + }; + } + throw new Error("Unexpected table"); + }, + }; + }, + }; +} + +function createApp(actor: Express.Request["actor"]) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use( + "/api", + accessRoutes(createDbStub() as never, { + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("GET /companies/:companyId/user-directory", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns active human users for operators without manage-permissions access", async () => { + const app = createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }], + }); + + const res = await request(app).get("/api/companies/company-1/user-directory"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + users: [ + { + principalId: "user-2", + status: "active", + user: { id: "user-2", name: null, email: "alex@example.com", image: null }, + }, + { + principalId: "user-1", + status: "active", + user: { id: "user-1", name: "Dotta", email: "dotta@example.com", image: "https://example.com/dotta.png" }, + }, + ], + }); + }); +}); diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index 9babd8b12cc..83e4e87b703 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -1,6 +1,15 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll } from "vitest"; +import { randomUUID } from "node:crypto"; +import { createDb, companies, agents, costEvents, financeEvents, projects } from "@paperclipai/db"; +import { costService } from "../services/costs.ts"; +import { financeService } from "../services/finance.ts"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; function makeDb(overrides: Record = {}) { const selectChain = { @@ -71,24 +80,26 @@ const mockBudgetService = vi.hoisted(() => ({ resolveIncident: vi.fn(), })); -vi.mock("../services/index.js", () => ({ - budgetService: () => mockBudgetService, - costService: () => mockCostService, - financeService: () => mockFinanceService, - companyService: () => mockCompanyService, - agentService: () => mockAgentService, - heartbeatService: () => mockHeartbeatService, - logActivity: mockLogActivity, -})); +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + budgetService: () => mockBudgetService, + costService: () => mockCostService, + financeService: () => mockFinanceService, + companyService: () => mockCompanyService, + agentService: () => mockAgentService, + heartbeatService: () => mockHeartbeatService, + logActivity: mockLogActivity, + })); -vi.mock("../services/quota-windows.js", () => ({ - fetchAllQuotaWindows: mockFetchAllQuotaWindows, -})); + vi.doMock("../services/quota-windows.js", () => ({ + fetchAllQuotaWindows: mockFetchAllQuotaWindows, + })); +} async function createApp() { const [{ costRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/costs.js"), - import("../middleware/index.js"), + vi.importActual("../routes/costs.js"), + vi.importActual("../middleware/index.js"), ]); const app = express(); app.use(express.json()); @@ -103,8 +114,8 @@ async function createApp() { async function createAppWithActor(actor: any) { const [{ costRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/costs.js"), - import("../middleware/index.js"), + vi.importActual("../routes/costs.js"), + vi.importActual("../middleware/index.js"), ]); const app = express(); app.use(express.json()); @@ -124,7 +135,12 @@ async function loadCostParsers() { beforeEach(() => { vi.resetModules(); - vi.resetAllMocks(); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/quota-windows.js"); + vi.doUnmock("../routes/costs.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.clearAllMocks(); mockCompanyService.update.mockResolvedValue({ id: "company-1", name: "Paperclip", @@ -223,3 +239,154 @@ describe("cost routes", () => { expect(mockAgentService.update).not.toHaveBeenCalled(); }); }); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { + let db!: ReturnType; + let costs!: ReturnType; + let finance!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-costs-service-"); + db = createDb(tempDb.connectionString); + costs = costService(db); + finance = financeService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(financeEvents); + await db.delete(costEvents); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("aggregates cost event sums above int32 without raising Postgres integer overflow", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const projectId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Cost Agent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Overflow Project", + status: "active", + }); + + await db.insert(costEvents).values([ + { + companyId, + agentId, + projectId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 2_000_000_000, + cachedInputTokens: 0, + outputTokens: 200_000_000, + costCents: 2_000_000_000, + occurredAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + companyId, + agentId, + projectId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 2_000_000_000, + cachedInputTokens: 10, + outputTokens: 200_000_000, + costCents: 2_000_000_000, + occurredAt: new Date("2026-04-11T00:00:00.000Z"), + }, + ]); + + const range = { + from: new Date("2026-04-01T00:00:00.000Z"), + to: new Date("2026-04-15T23:59:59.999Z"), + }; + + const [byAgentRow] = await costs.byAgent(companyId, range); + const [byProjectRow] = await costs.byProject(companyId, range); + const [byAgentModelRow] = await costs.byAgentModel(companyId, range); + + expect(byAgentRow?.costCents).toBe(4_000_000_000); + expect(byAgentRow?.inputTokens).toBe(4_000_000_000); + expect(byProjectRow?.costCents).toBe(4_000_000_000); + expect(byAgentModelRow?.costCents).toBe(4_000_000_000); + }); + + it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => { + const companyId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(financeEvents).values([ + { + companyId, + biller: "openai", + eventKind: "invoice", + amountCents: 2_000_000_000, + currency: "USD", + direction: "debit", + estimated: false, + occurredAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + companyId, + biller: "openai", + eventKind: "invoice", + amountCents: 2_000_000_000, + currency: "USD", + direction: "debit", + estimated: true, + occurredAt: new Date("2026-04-11T00:00:00.000Z"), + }, + ]); + + const range = { + from: new Date("2026-04-01T00:00:00.000Z"), + to: new Date("2026-04-15T23:59:59.999Z"), + }; + + const summary = await finance.summary(companyId, range); + const [byKindRow] = await finance.byKind(companyId, range); + + expect(summary.debitCents).toBe(4_000_000_000); + expect(summary.estimatedDebitCents).toBe(2_000_000_000); + expect(byKindRow?.debitCents).toBe(4_000_000_000); + expect(byKindRow?.netCents).toBe(4_000_000_000); + }); +}); diff --git a/server/src/__tests__/dev-runner-paths.test.ts b/server/src/__tests__/dev-runner-paths.test.ts index 6f9a5b80855..aaf264c0306 100644 --- a/server/src/__tests__/dev-runner-paths.test.ts +++ b/server/src/__tests__/dev-runner-paths.test.ts @@ -2,12 +2,15 @@ import { describe, expect, it } from "vitest"; import { shouldTrackDevServerPath } from "../../../scripts/dev-runner-paths.mjs"; describe("shouldTrackDevServerPath", () => { - it("ignores repo-local Paperclip state and common test file paths", () => { + it("ignores generated state, diagnostic reports, and common test file paths", () => { expect( shouldTrackDevServerPath( ".paperclip/worktrees/PAP-712-for-project-configuration-get-rid-of-the-overview-tab-for-now/.agents/skills/paperclip", ), ).toBe(false); + expect(shouldTrackDevServerPath("server/report.20260416.154629.4965.0.001.json")).toBe(false); + expect(shouldTrackDevServerPath("server/report.20260416.154636.4725.0.001.json")).toBe(false); + expect(shouldTrackDevServerPath("server/report.20260416.154636.4965.0.002.json")).toBe(false); expect(shouldTrackDevServerPath("server/src/__tests__/health.test.ts")).toBe(false); expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.test.ts")).toBe(false); expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.spec.tsx")).toBe(false); diff --git a/server/src/__tests__/documents-service.test.ts b/server/src/__tests__/documents-service.test.ts new file mode 100644 index 00000000000..12658845719 --- /dev/null +++ b/server/src/__tests__/documents-service.test.ts @@ -0,0 +1,115 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + companies, + createDb, + documentRevisions, + documents, + issueDocuments, + issues, +} from "@paperclipai/db"; +import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { documentService } from "../services/documents.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres document service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("documentService system issue documents", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-documents-service-"); + db = createDb(tempDb.connectionString); + svc = documentService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(documentRevisions); + await db.delete(issueDocuments); + await db.delete(documents); + await db.delete(issues); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function createIssueWithDocuments() { + const companyId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + identifier: "PAP-1600", + title: "System document filtering", + description: "Validate document filtering", + status: "in_progress", + priority: "medium", + }); + + await svc.upsertIssueDocument({ + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "# Plan", + }); + await svc.upsertIssueDocument({ + issueId, + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + title: "Continuation Summary", + format: "markdown", + body: "# Handoff", + }); + + return { issueId }; + } + + it("filters continuation summaries from default document lists and issue payload summaries", async () => { + const { issueId } = await createIssueWithDocuments(); + + const defaultDocuments = await svc.listIssueDocuments(issueId); + expect(defaultDocuments.map((doc) => doc.key)).toEqual(["plan"]); + + const payload = await svc.getIssueDocumentPayload({ id: issueId, description: null }); + expect(payload.planDocument?.key).toBe("plan"); + expect(payload.documentSummaries.map((doc) => doc.key)).toEqual(["plan"]); + }); + + it("keeps system documents available for includeSystem and direct fetch callers", async () => { + const { issueId } = await createIssueWithDocuments(); + + const debugDocuments = await svc.listIssueDocuments(issueId, { includeSystem: true }); + expect(debugDocuments.map((doc) => doc.key)).toEqual([ + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + "plan", + ]); + + const directHandoff = await svc.getIssueDocumentByKey(issueId, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY); + expect(directHandoff).toEqual(expect.objectContaining({ + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + body: "# Handoff", + })); + }); +}); diff --git a/server/src/__tests__/execution-workspaces-routes.test.ts b/server/src/__tests__/execution-workspaces-routes.test.ts new file mode 100644 index 00000000000..a5295fe09ff --- /dev/null +++ b/server/src/__tests__/execution-workspaces-routes.test.ts @@ -0,0 +1,90 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + list: vi.fn(), + listSummaries: vi.fn(), + getById: vi.fn(), + getCloseReadiness: vi.fn(), + update: vi.fn(), +})); + +const mockWorkspaceOperationService = vi.hoisted(() => ({ + listForExecutionWorkspace: vi.fn(), + createRecorder: vi.fn(), +})); + +function registerServiceMocks() { + vi.doMock("../services/index.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, + logActivity: vi.fn(async () => undefined), + workspaceOperationService: () => mockWorkspaceOperationService, + })); +} + +async function createApp() { + const [{ executionWorkspaceRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/execution-workspaces.js"), + vi.importActual("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", executionWorkspaceRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("execution workspace routes", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/execution-workspaces.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerServiceMocks(); + vi.resetAllMocks(); + mockExecutionWorkspaceService.list.mockResolvedValue([]); + mockExecutionWorkspaceService.listSummaries.mockResolvedValue([ + { + id: "workspace-1", + name: "Alpha", + mode: "isolated_workspace", + projectWorkspaceId: null, + }, + ]); + }); + + it("uses summary mode for lightweight workspace lookups", async () => { + const res = await request(await createApp()) + .get("/api/companies/company-1/execution-workspaces?summary=true&reuseEligible=true"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + { + id: "workspace-1", + name: "Alpha", + mode: "isolated_workspace", + projectWorkspaceId: null, + }, + ]); + expect(mockExecutionWorkspaceService.listSummaries).toHaveBeenCalledWith("company-1", { + projectId: undefined, + projectWorkspaceId: undefined, + issueId: undefined, + status: undefined, + reuseEligible: true, + }); + expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index ca6b38d56b5..1ca6d02faba 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -12,7 +12,6 @@ import { issues, projectWorkspaces, projects, - workspaceRuntimeServices, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -43,6 +42,7 @@ describe("execution workspace config helpers", () => { teardownCommand: "bash ./scripts/teardown-worktree.sh", cleanupCommand: "pkill -f vite || true", desiredState: null, + serviceStates: null, workspaceRuntime: { services: [{ name: "web", command: "pnpm dev", port: 3100 }], }, @@ -73,6 +73,7 @@ describe("execution workspace config helpers", () => { teardownCommand: "bash ./scripts/teardown-worktree.sh", cleanupCommand: "pkill -f vite || true", desiredState: null, + serviceStates: null, workspaceRuntime: { services: [{ name: "web", command: "pnpm dev" }], }, @@ -134,7 +135,6 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { afterEach(async () => { await db.delete(issues); - await db.delete(workspaceRuntimeServices); await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects); @@ -324,136 +324,4 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { "git_branch_delete", ])); }, 20_000); - - it("shows inherited shared project runtime services on shared execution workspaces without duplicating old history", async () => { - const companyId = randomUUID(); - const projectId = randomUUID(); - const projectWorkspaceId = randomUUID(); - const executionWorkspaceId = randomUUID(); - const olderServiceId = randomUUID(); - const currentServiceId = randomUUID(); - const reuseKey = `project_workspace:${projectWorkspaceId}:paperclip-dev`; - const startedAt = new Date("2026-04-04T17:00:00.000Z"); - const stoppedAt = new Date("2026-04-04T17:05:00.000Z"); - const runningAt = new Date("2026-04-04T17:10:00.000Z"); - - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: "PAP", - requireBoardApprovalForNewAgents: false, - }); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Workspaces", - status: "in_progress", - executionWorkspacePolicy: { - enabled: true, - }, - }); - await db.insert(projectWorkspaces).values({ - id: projectWorkspaceId, - companyId, - projectId, - name: "Primary", - sourceType: "local_path", - isPrimary: true, - cwd: "/tmp/paperclip-primary", - metadata: { - runtimeConfig: { - desiredState: "running", - workspaceRuntime: { - services: [{ name: "paperclip-dev", command: "pnpm dev" }], - }, - }, - }, - }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - projectWorkspaceId, - mode: "shared_workspace", - strategyType: "project_primary", - name: "Shared workspace", - status: "active", - providerType: "local_fs", - cwd: "/tmp/paperclip-primary", - }); - await db.insert(workspaceRuntimeServices).values([ - { - id: olderServiceId, - companyId, - projectId, - projectWorkspaceId, - executionWorkspaceId: null, - issueId: null, - scopeType: "project_workspace", - scopeId: projectWorkspaceId, - serviceName: "paperclip-dev", - status: "stopped", - lifecycle: "shared", - reuseKey, - command: "pnpm dev", - cwd: "/tmp/paperclip-primary", - port: 49195, - url: "http://127.0.0.1:49195", - provider: "local_process", - providerRef: "11111", - ownerAgentId: null, - startedByRunId: null, - lastUsedAt: stoppedAt, - startedAt, - stoppedAt, - stopPolicy: { type: "manual" }, - healthStatus: "unknown", - createdAt: startedAt, - updatedAt: stoppedAt, - }, - { - id: currentServiceId, - companyId, - projectId, - projectWorkspaceId, - executionWorkspaceId: null, - issueId: null, - scopeType: "project_workspace", - scopeId: projectWorkspaceId, - serviceName: "paperclip-dev", - status: "running", - lifecycle: "shared", - reuseKey, - command: "pnpm dev", - cwd: "/tmp/paperclip-primary", - port: 49222, - url: "http://127.0.0.1:49222", - provider: "local_process", - providerRef: "22222", - ownerAgentId: null, - startedByRunId: null, - lastUsedAt: runningAt, - startedAt: runningAt, - stoppedAt: null, - stopPolicy: { type: "manual" }, - healthStatus: "healthy", - createdAt: runningAt, - updatedAt: runningAt, - }, - ]); - - const workspace = await svc.getById(executionWorkspaceId); - const listed = await svc.list(companyId, { projectId }); - - expect(workspace?.runtimeServices).toHaveLength(1); - expect(workspace?.runtimeServices?.[0]).toMatchObject({ - id: currentServiceId, - status: "running", - projectWorkspaceId, - executionWorkspaceId: null, - url: "http://127.0.0.1:49222", - }); - expect(listed[0]?.runtimeServices).toHaveLength(1); - expect(listed[0]?.runtimeServices?.[0]?.id).toBe(currentServiceId); - }); }); diff --git a/server/src/__tests__/express5-auth-wildcard.test.ts b/server/src/__tests__/express5-auth-wildcard.test.ts index 2771afe1d8b..5ce30daff21 100644 --- a/server/src/__tests__/express5-auth-wildcard.test.ts +++ b/server/src/__tests__/express5-auth-wildcard.test.ts @@ -17,11 +17,16 @@ import { describe, expect, it, vi } from "vitest"; describe("Express 5 /api/auth wildcard route", () => { function buildApp() { const app = express(); - const handler = vi.fn((_req: express.Request, res: express.Response) => { + let callCount = 0; + const handler = (_req: express.Request, res: express.Response) => { + callCount += 1; res.status(200).json({ ok: true }); - }); + }; app.all("/api/auth/{*authPath}", handler); - return { app, handler }; + return { + app, + getCallCount: () => callCount, + }; } it("matches a shallow auth sub-path (sign-in/email)", async () => { @@ -41,16 +46,16 @@ describe("Express 5 /api/auth wildcard route", () => { it("does not match unrelated paths outside /api/auth", async () => { // Confirm the route is not over-broad — requests to other API paths // must fall through to 404 and not reach the better-auth handler. - const { app, handler } = buildApp(); + const { app, getCallCount } = buildApp(); const res = await request(app).get("/api/other/endpoint"); expect(res.status).toBe(404); - expect(handler).not.toHaveBeenCalled(); + expect(getCallCount()).toBe(0); }); it("invokes the handler for every matched sub-path", async () => { - const { app, handler } = buildApp(); + const { app, getCallCount } = buildApp(); await request(app).post("/api/auth/sign-out"); await request(app).get("/api/auth/session"); - expect(handler).toHaveBeenCalledTimes(2); + expect(getCallCount()).toBe(2); }); }); diff --git a/server/src/__tests__/feedback-service.test.ts b/server/src/__tests__/feedback-service.test.ts index 8ce7f2fc6f8..7a3a36d8ba0 100644 --- a/server/src/__tests__/feedback-service.test.ts +++ b/server/src/__tests__/feedback-service.test.ts @@ -92,6 +92,10 @@ async function startTempDatabase() { return { connectionString, dataDir, instance }; } +async function closeDbClient(db: ReturnType | undefined) { + await db?.$client?.end?.({ timeout: 0 }); +} + describe("feedbackService.saveIssueVote", () => { let db!: ReturnType; let svc!: ReturnType; @@ -129,6 +133,7 @@ describe("feedbackService.saveIssueVote", () => { }); afterAll(async () => { + await closeDbClient(db); await instance?.stop(); if (dataDir) { fs.rmSync(dataDir, { recursive: true, force: true }); diff --git a/server/src/__tests__/health-dev-server-token.test.ts b/server/src/__tests__/health-dev-server-token.test.ts new file mode 100644 index 00000000000..7c768a35a09 --- /dev/null +++ b/server/src/__tests__/health-dev-server-token.test.ts @@ -0,0 +1,128 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import express from "express"; +import request from "supertest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Db } from "@paperclipai/db"; +import { healthRoutes } from "../routes/health.js"; + +const tempDirs: string[] = []; + +function createDevServerStatusFile(payload: unknown) { + const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-health-dev-server-")); + tempDirs.push(dir); + const filePath = path.join(dir, "dev-server-status.json"); + writeFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf8"); + return filePath; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("GET /health dev-server supervisor access", () => { + it("exposes dev-server metadata to the supervising dev runner in authenticated mode", async () => { + const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + const previousToken = process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN; + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({ + dirty: true, + lastChangedAt: "2026-03-20T12:00:00.000Z", + changedPathCount: 1, + changedPathsSample: ["server/src/routes/health.ts"], + pendingMigrations: [], + lastRestartAt: "2026-03-20T11:30:00.000Z", + }); + process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = "dev-runner-token"; + + let selectCall = 0; + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + select: vi.fn(() => { + selectCall += 1; + if (selectCall === 1) { + return { + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 1 }]), + })), + }; + } + if (selectCall === 2) { + return { + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([ + { + id: "settings-1", + general: {}, + experimental: { autoRestartDevServerWhenIdle: true }, + createdAt: new Date("2026-03-20T11:00:00.000Z"), + updatedAt: new Date("2026-03-20T11:00:00.000Z"), + }, + ]), + })), + }; + } + return { + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 0 }]), + })), + }; + }), + } as unknown as Db; + + try { + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "none", source: "none" }; + next(); + }); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "private", + authReady: true, + companyDeletionEnabled: true, + }), + ); + + const res = await request(app) + .get("/health") + .set("X-Paperclip-Dev-Server-Status-Token", "dev-runner-token"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + status: "ok", + deploymentMode: "authenticated", + bootstrapStatus: "ready", + bootstrapInviteActive: false, + devServer: { + enabled: true, + restartRequired: true, + reason: "backend_changes", + lastChangedAt: "2026-03-20T12:00:00.000Z", + changedPathCount: 1, + changedPathsSample: ["server/src/routes/health.ts"], + pendingMigrations: [], + autoRestartEnabled: true, + activeRunCount: 0, + waitingForIdle: false, + lastRestartAt: "2026-03-20T11:30:00.000Z", + }, + }); + } finally { + if (previousFile === undefined) { + delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + } else { + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile; + } + if (previousToken === undefined) { + delete process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN; + } else { + process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = previousToken; + } + } + }); +}); diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index ff8a5415b69..2d294c0e0d3 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -1,55 +1,58 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import express from "express"; import request from "supertest"; import type { Db } from "@paperclipai/db"; +import { healthRoutes } from "../routes/health.js"; +import * as devServerStatus from "../dev-server-status.js"; import { serverVersion } from "../version.js"; +const mockReadPersistedDevServerStatus = vi.hoisted(() => vi.fn()); + +vi.mock("../dev-server-status.js", () => ({ + readPersistedDevServerStatus: mockReadPersistedDevServerStatus, + toDevServerHealthStatus: vi.fn(), +})); + +function createApp(db?: Db) { + const app = express(); + app.use("/health", healthRoutes(db)); + return app; +} + describe("GET /health", () => { beforeEach(() => { - vi.resetModules(); + vi.clearAllMocks(); + mockReadPersistedDevServerStatus.mockReturnValue(undefined); }); afterEach(() => { vi.restoreAllMocks(); }); - it("returns 200 with status ok", async () => { - const devServerStatus = await import("../dev-server-status.js"); - vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); - const { healthRoutes } = await import("../routes/health.js"); - const app = express(); - app.use("/health", healthRoutes()); - + const app = createApp(); const res = await request(app).get("/health"); expect(res.status).toBe(200); expect(res.body).toEqual({ status: "ok", version: serverVersion }); - }); + }, 15_000); it("returns 200 when the database probe succeeds", async () => { - const devServerStatus = await import("../dev-server-status.js"); - vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); - const { healthRoutes } = await import("../routes/health.js"); const db = { execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), } as unknown as Db; - const app = express(); - app.use("/health", healthRoutes(db)); + const app = createApp(db); const res = await request(app).get("/health"); expect(res.status).toBe(200); + expect(db.execute).toHaveBeenCalledTimes(1); expect(res.body).toMatchObject({ status: "ok", version: serverVersion }); }); it("returns 503 when the database probe fails", async () => { - const devServerStatus = await import("../dev-server-status.js"); - vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); - const { healthRoutes } = await import("../routes/health.js"); const db = { execute: vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED")), } as unknown as Db; - const app = express(); - app.use("/health", healthRoutes(db)); + const app = createApp(db); const res = await request(app).get("/health"); @@ -57,7 +60,123 @@ describe("GET /health", () => { expect(res.body).toEqual({ status: "unhealthy", version: serverVersion, - error: "database_unreachable", + error: "database_unreachable" + }); + }); + + it("redacts detailed metadata for anonymous requests in authenticated mode", async () => { + const devServerStatus = await import("../dev-server-status.js"); + vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); + const { healthRoutes } = await import("../routes/health.js"); + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 1 }]), + })), + })), + } as unknown as Db; + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "none", source: "none" }; + next(); + }); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + companyDeletionEnabled: false, + }), + ); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + status: "ok", + deploymentMode: "authenticated", + bootstrapStatus: "ready", + bootstrapInviteActive: false, + }); + }); + + it("redacts detailed metadata when authenticated mode is reached without auth middleware", async () => { + const devServerStatus = await import("../dev-server-status.js"); + vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); + const { healthRoutes } = await import("../routes/health.js"); + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 1 }]), + })), + })), + } as unknown as Db; + const app = express(); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + companyDeletionEnabled: false, + }), + ); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + status: "ok", + deploymentMode: "authenticated", + bootstrapStatus: "ready", + bootstrapInviteActive: false, + }); + }); + + it("keeps detailed metadata for authenticated requests in authenticated mode", async () => { + const devServerStatus = await import("../dev-server-status.js"); + vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); + const { healthRoutes } = await import("../routes/health.js"); + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([{ count: 1 }]), + })), + })), + } as unknown as Db; + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "board", userId: "user-1", source: "session" }; + next(); + }); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + companyDeletionEnabled: false, + }), + ); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + status: "ok", + version: serverVersion, + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + bootstrapStatus: "ready", + bootstrapInviteActive: false, + features: { + companyDeletionEnabled: false, + }, }); }); }); diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 1a4d2f9661d..03ef7a93927 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -95,6 +95,10 @@ async function waitFor(condition: () => boolean | Promise, timeoutMs = throw new Error("Timed out waiting for condition"); } +async function closeDbClient(db: ReturnType | undefined) { + await db?.$client?.end?.({ timeout: 0 }); +} + async function createControlledGatewayServer() { const server = createServer(); const wss = new WebSocketServer({ server }); @@ -225,12 +229,122 @@ describe("heartbeat comment wake batching", () => { }, 45_000); afterAll(async () => { + await closeDbClient(db); await instance?.stop(); if (dataDir) { fs.rmSync(dataDir, { recursive: true, force: true }); } }); + it("defers approval-approved wakes for a running issue so the assignee resumes after the run", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const heartbeat = heartbeatService(db); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CEO", + role: "ceo", + status: "running", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + triggerDetail: "system", + status: "running", + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: "issue_assigned", + }, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Hire an agent", + status: "blocked", + priority: "medium", + assigneeAgentId: agentId, + executionRunId: runId, + executionAgentNameKey: "ceo", + executionLockedAt: new Date(), + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + + const followupRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "approval_approved", + payload: { + issueId, + approvalId: "approval-1", + approvalStatus: "approved", + }, + contextSnapshot: { + issueId, + taskId: issueId, + approvalId: "approval-1", + approvalStatus: "approved", + wakeReason: "approval_approved", + }, + requestedByActorType: "user", + requestedByActorId: "local-board", + }); + + expect(followupRun).toBeNull(); + + const deferred = await db + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.agentId, agentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + ), + ) + .then((rows) => rows[0] ?? null); + + expect(deferred).not.toBeNull(); + expect(deferred?.reason).toBe("issue_execution_deferred"); + expect(deferred?.payload).toMatchObject({ + issueId, + approvalId: "approval-1", + approvalStatus: "approved", + }); + expect((deferred?.payload as Record)._paperclipWakeContext).toMatchObject({ + issueId, + taskId: issueId, + approvalId: "approval-1", + approvalStatus: "approved", + wakeReason: "approval_approved", + }); + + const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(1); + expect(runs[0]?.id).toBe(runId); + }); + it("batches deferred comment wakes and forwards the ordered batch to the next run", async () => { const gateway = await createControlledGatewayServer(); const companyId = randomUUID(); @@ -424,6 +538,192 @@ describe("heartbeat comment wake batching", () => { } }, 120_000); + it("promotes deferred comment wakes after the active run closes the issue", async () => { + const gateway = await createControlledGatewayServer(); + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const heartbeat = heartbeatService(db); + + try { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Gateway Agent", + role: "engineer", + status: "idle", + adapterType: "openclaw_gateway", + adapterConfig: { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2_000, + }, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Reopen after deferred comment", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + + const comment1 = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "First comment", + }) + .returning() + .then((rows) => rows[0]); + + const firstRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId, commentId: comment1.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: comment1.id, + wakeReason: "issue_commented", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + + expect(firstRun).not.toBeNull(); + await waitFor(async () => { + const run = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, firstRun!.id)) + .then((rows) => rows[0] ?? null); + return run?.status === "running"; + }); + + const comment2 = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "Please handle this follow-up after you finish", + }) + .returning() + .then((rows) => rows[0]); + + const deferredRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId, commentId: comment2.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: comment2.id, + wakeReason: "issue_commented", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + + expect(deferredRun).toBeNull(); + + await waitFor(async () => { + const deferred = await db + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.agentId, agentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + ), + ) + .then((rows) => rows[0] ?? null); + return Boolean(deferred); + }); + + await db + .update(issues) + .set({ + status: "done", + completedAt: new Date(), + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: new Date(), + }) + .where(eq(issues.id, issueId)); + + gateway.releaseFirstWait(); + + await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000); + await waitFor(async () => { + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + return runs.length === 2 && runs.every((run) => run.status === "succeeded"); + }, 90_000); + + const reopenedIssue = await db + .select({ + status: issues.status, + completedAt: issues.completedAt, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + + expect(reopenedIssue).toMatchObject({ + status: "in_progress", + completedAt: null, + }); + + const secondPayload = gateway.getAgentPayloads()[1] ?? {}; + expect(secondPayload.paperclip).toMatchObject({ + wake: { + reason: "issue_commented", + commentIds: [comment2.id], + latestCommentId: comment2.id, + issue: { + id: issueId, + identifier: `${issuePrefix}-1`, + title: "Reopen after deferred comment", + status: "in_progress", + priority: "medium", + }, + }, + }); + expect(String(secondPayload.message ?? "")).toContain("Please handle this follow-up after you finish"); + } finally { + gateway.releaseFirstWait(); + await gateway.close(); + } + }, 120_000); + it("queues exactly one follow-up run when an issue-bound run exits without a comment", async () => { const gateway = await createControlledGatewayServer(); const companyId = randomUUID(); @@ -496,15 +796,34 @@ describe("heartbeat comment wake batching", () => { id: issueId, identifier: `${issuePrefix}-1`, title: "Require a comment", - status: "todo", + status: "in_progress", priority: "medium", }, + checkedOutByHarness: true, commentIds: [], }, }); expect(String(firstPayload.message ?? "")).toContain("## Paperclip Wake Payload"); expect(String(firstPayload.message ?? "")).toContain("Do not switch to another issue until you have handled this wake."); + expect(String(firstPayload.message ?? "")).toContain("- checkout: already claimed by the harness for this run"); + expect(String(firstPayload.message ?? "")).toContain( + "The harness already checked out this issue for the current run.", + ); expect(String(firstPayload.message ?? "")).toContain(`${issuePrefix}-1 Require a comment`); + const checkedOutIssue = await db + .select({ + status: issues.status, + checkoutRunId: issues.checkoutRunId, + executionRunId: issues.executionRunId, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + expect(checkedOutIssue).toMatchObject({ + status: "in_progress", + checkoutRunId: firstRun?.id, + executionRunId: firstRun?.id, + }); gateway.releaseFirstWait(); await waitFor(async () => { const runs = await db @@ -555,4 +874,282 @@ describe("heartbeat comment wake batching", () => { await gateway.close(); } }, 20_000); + + it("defers mentioned-agent wakes while another agent is actively executing the same issue", async () => { + const gateway = await createControlledGatewayServer(); + const companyId = randomUUID(); + const primaryAgentId = randomUUID(); + const mentionedAgentId = randomUUID(); + const issueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const heartbeat = heartbeatService(db); + + try { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values([ + { + id: primaryAgentId, + companyId, + name: "Primary Agent", + role: "engineer", + status: "idle", + adapterType: "openclaw_gateway", + adapterConfig: { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2_000, + }, + runtimeConfig: {}, + permissions: {}, + }, + { + id: mentionedAgentId, + companyId, + name: "Mentioned Agent", + role: "engineer", + status: "idle", + adapterType: "openclaw_gateway", + adapterConfig: { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2_000, + }, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Prevent concurrent mention execution", + status: "todo", + priority: "high", + assigneeAgentId: primaryAgentId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + + const primaryRun = await heartbeat.wakeup(primaryAgentId, { + source: "assignment", + triggerDetail: "system", + reason: "issue_assigned", + payload: { issueId }, + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: "issue_assigned", + }, + requestedByActorType: "system", + requestedByActorId: null, + }); + + expect(primaryRun).not.toBeNull(); + await waitFor(() => gateway.getAgentPayloads().length === 1); + + const mentionComment = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "@Mentioned Agent please inspect this after the current run.", + }) + .returning() + .then((rows) => rows[0]); + + const mentionRun = await heartbeat.wakeup(mentionedAgentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_comment_mentioned", + payload: { issueId, commentId: mentionComment.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: mentionComment.id, + wakeCommentId: mentionComment.id, + wakeReason: "issue_comment_mentioned", + source: "comment.mention", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + + expect(mentionRun).toBeNull(); + + await waitFor(async () => { + const deferred = await db + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.agentId, mentionedAgentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + ), + ) + .then((rows) => rows[0] ?? null); + return Boolean(deferred); + }); + + expect(gateway.getAgentPayloads()).toHaveLength(1); + + gateway.releaseFirstWait(); + + await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000); + await waitFor(async () => { + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, mentionedAgentId)) + .orderBy(asc(heartbeatRuns.createdAt)); + return runs.length === 1 && runs[0]?.status === "succeeded"; + }, 90_000); + + const mentionedRuns = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, mentionedAgentId)) + .orderBy(asc(heartbeatRuns.createdAt)); + + expect(mentionedRuns).toHaveLength(1); + expect(mentionedRuns[0]?.contextSnapshot).toMatchObject({ + issueId, + wakeReason: "issue_comment_mentioned", + }); + } finally { + gateway.releaseFirstWait(); + await gateway.close(); + } + }, 120_000); + it("treats the automatic run summary as fallback-only when the run already posted a comment", async () => { + const gateway = await createControlledGatewayServer(); + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const heartbeat = heartbeatService(db); + + try { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Gateway Agent", + role: "engineer", + status: "idle", + adapterType: "openclaw_gateway", + adapterConfig: { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2_000, + }, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Use existing comment", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + + const firstRun = await heartbeat.wakeup(agentId, { + source: "assignment", + triggerDetail: "system", + reason: "issue_assigned", + payload: { issueId }, + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: "issue_assigned", + }, + requestedByActorType: "system", + requestedByActorId: null, + }); + + expect(firstRun).not.toBeNull(); + await waitFor(() => gateway.getAgentPayloads().length === 1); + + await db.insert(issueComments).values({ + companyId, + issueId, + authorAgentId: agentId, + authorUserId: null, + createdByRunId: firstRun!.id, + body: "Manual completion comment from the run.", + }); + + gateway.releaseFirstWait(); + + await waitFor(async () => { + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + return runs.length === 1 && runs[0]?.status === "succeeded" && runs[0]?.issueCommentStatus === "satisfied"; + }); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + + expect(runs).toHaveLength(1); + expect(runs[0]?.issueCommentStatus).toBe("satisfied"); + expect(runs[0]?.issueCommentSatisfiedByCommentId).not.toBeNull(); + + const comments = await db + .select() + .from(issueComments) + .where(eq(issueComments.issueId, issueId)) + .orderBy(asc(issueComments.createdAt)); + + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toBe("Manual completion comment from the run."); + expect(comments[0]?.createdByRunId).toBe(firstRun?.id); + + const wakeups = await db + .select() + .from(agentWakeupRequests) + .where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId))); + + expect(wakeups).toHaveLength(1); + } finally { + gateway.releaseFirstWait(); + await gateway.close(); + } + }, 20_000); }); diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts new file mode 100644 index 00000000000..c0d6a4238e6 --- /dev/null +++ b/server/src/__tests__/heartbeat-context-summary.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + summarizeHeartbeatRunContextSnapshot, + summarizeHeartbeatRunListResultJson, +} from "../services/heartbeat.js"; + +describe("summarizeHeartbeatRunContextSnapshot", () => { + it("keeps only the small retry/linking fields needed by the client", () => { + const summarized = summarizeHeartbeatRunContextSnapshot({ + issueId: "issue-1", + taskId: "task-1", + taskKey: "PAP-1", + commentId: "comment-1", + wakeCommentId: "comment-2", + wakeReason: "retry_failed_run", + wakeSource: "on_demand", + wakeTriggerDetail: "manual", + paperclipWake: { + comments: [ + { + body: "x".repeat(50_000), + }, + ], + }, + executionStage: { + summary: "large nested object that should not be sent back in run lists", + }, + }); + + expect(summarized).toEqual({ + issueId: "issue-1", + taskId: "task-1", + taskKey: "PAP-1", + commentId: "comment-1", + wakeCommentId: "comment-2", + wakeReason: "retry_failed_run", + wakeSource: "on_demand", + wakeTriggerDetail: "manual", + }); + }); + + it("returns null when no allowed fields are present", () => { + expect( + summarizeHeartbeatRunContextSnapshot({ + paperclipWake: { comments: [{ body: "hello" }] }, + }), + ).toBeNull(); + }); +}); + +describe("summarizeHeartbeatRunListResultJson", () => { + it("keeps only summary fields and parses numeric cost aliases", () => { + expect( + summarizeHeartbeatRunListResultJson({ + summary: "Completed the task", + result: "Updated three files", + message: "", + error: null, + totalCostUsd: "1.25", + costUsd: "0.75", + costUsdCamel: "0.5", + }), + ).toEqual({ + summary: "Completed the task", + result: "Updated three files", + total_cost_usd: 1.25, + cost_usd: 0.75, + costUsd: 0.5, + }); + }); + + it("returns null when projected fields are empty", () => { + expect( + summarizeHeartbeatRunListResultJson({ + summary: "", + result: null, + message: undefined, + error: " ", + totalCostUsd: "abc", + }), + ).toBeNull(); + }); +}); diff --git a/server/src/__tests__/heartbeat-list.test.ts b/server/src/__tests__/heartbeat-list.test.ts index 1c04009d3f6..69a2f2524f1 100644 --- a/server/src/__tests__/heartbeat-list.test.ts +++ b/server/src/__tests__/heartbeat-list.test.ts @@ -5,7 +5,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { heartbeatService } from "../services/heartbeat.ts"; +import { boundHeartbeatRunEventPayloadForStorage, heartbeatService } from "../services/heartbeat.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -65,6 +65,11 @@ describeEmbeddedPostgres("heartbeat list", () => { agentId, invocationSource: "assignment", status: "running", + livenessState: "advanced", + livenessReason: "run produced action evidence", + continuationAttempt: 1, + lastUsefulActionAt: new Date("2026-04-18T12:00:00Z"), + nextAction: "continue implementation", contextSnapshot: { issueId: randomUUID() }, }); @@ -80,6 +85,13 @@ describeEmbeddedPostgres("heartbeat list", () => { expect(runs).toHaveLength(1); expect(runs[0]?.id).toBe(runId); expect(runs[0]?.processGroupId ?? null).toBeNull(); + expect(runs[0]).toMatchObject({ + livenessState: "advanced", + livenessReason: "run produced action evidence", + continuationAttempt: 1, + nextAction: "continue implementation", + }); + expect(runs[0]?.lastUsefulActionAt).toEqual(new Date("2026-04-18T12:00:00Z")); } finally { if (originalDescriptor) { Object.defineProperty(heartbeatRuns, "processGroupId", originalDescriptor); @@ -88,4 +100,127 @@ describeEmbeddedPostgres("heartbeat list", () => { } } }); + + it("returns small result json payloads unchanged from getRun", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "running", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + resultJson: { + summary: "done", + structured: { ok: true }, + }, + }); + + const run = await heartbeatService(db).getRun(runId); + + expect(run?.resultJson).toEqual({ + summary: "done", + structured: { ok: true }, + }); + }); + + it("bounds oversized legacy result json payloads on getRun", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const oversizedStdout = Array.from({ length: 8_000 }, (_, index) => + `${index.toString(16).padStart(4, "0")}-${randomUUID()}`, + ).join("|"); + const oversizedNestedPayload = Array.from({ length: 6_000 }, (_, index) => + `${index.toString(16).padStart(4, "0")}:${randomUUID()}`, + ).join("|"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "running", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "succeeded", + resultJson: { + summary: "completed", + stdout: oversizedStdout, + nestedHuge: { payload: oversizedNestedPayload }, + }, + }); + + const run = await heartbeatService(db).getRun(runId); + const result = run?.resultJson as Record | null; + + expect(result).toMatchObject({ + summary: "completed", + truncated: true, + truncationReason: "oversized_result_json", + stdoutTruncated: true, + }); + expect(typeof result?.stdout).toBe("string"); + expect((result?.stdout as string).length).toBeLessThan(oversizedStdout.length); + expect(result).not.toHaveProperty("nestedHuge"); + }); +}); + +describe("heartbeat run event payload bounding", () => { + it("truncates oversized adapter metadata before storage", () => { + const payload = boundHeartbeatRunEventPayloadForStorage({ + adapterType: "codex_local", + prompt: "x".repeat(40_000), + context: { + issueId: "issue-1", + memory: "y".repeat(40_000), + }, + }); + + expect(payload.adapterType).toBe("codex_local"); + expect(typeof payload.prompt).toBe("string"); + expect((payload.prompt as string).length).toBeLessThan(20_000); + expect(payload.prompt).toContain("[truncated"); + expect(payload.context).toMatchObject({ + issueId: "issue-1", + }); + expect(JSON.stringify(payload).length).toBeLessThan(45_000); + }); }); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 616483231c6..9d2ffeb24dd 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -3,12 +3,19 @@ import { spawn, type ChildProcess } from "node:child_process"; import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { + activityLog, agents, + agentRuntimeState, agentWakeupRequests, + companySkills, companies, createDb, + documentRevisions, + documents, heartbeatRunEvents, heartbeatRuns, + issueComments, + issueDocuments, issues, } from "@paperclipai/db"; import { @@ -18,6 +25,17 @@ import { import { runningProcesses } from "../adapters/index.ts"; const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() })); const mockTrackAgentFirstHeartbeat = vi.hoisted(() => vi.fn()); +const mockAdapterExecute = vi.hoisted(() => + vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + errorMessage: null, + summary: "Recovered stranded heartbeat work.", + provider: "test", + model: "test-model", + })), +); vi.mock("../telemetry.ts", () => ({ getTelemetryClient: () => mockTelemetryClient, @@ -33,6 +51,17 @@ vi.mock("@paperclipai/shared/telemetry", async () => { }; }); +vi.mock("../adapters/index.ts", async () => { + const actual = await vi.importActual("../adapters/index.ts"); + return { + ...actual, + getServerAdapter: vi.fn(() => ({ + supportsLocalAgentJwt: false, + execute: mockAdapterExecute, + })), + }; +}); + import { heartbeatService } from "../services/heartbeat.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -68,6 +97,34 @@ async function waitForPidExit(pid: number, timeoutMs = 2_000) { return !isPidAlive(pid); } +async function waitForRunToSettle( + heartbeat: ReturnType, + runId: string, + timeoutMs = 3_000, +) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const run = await heartbeat.getRun(runId); + if (!run || (run.status !== "queued" && run.status !== "running")) return run; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return heartbeat.getRun(runId); +} + +async function waitForValue( + read: () => Promise, + timeoutMs = 3_000, +) { + const deadline = Date.now() + timeoutMs; + let latest: T | null | undefined = null; + while (Date.now() < deadline) { + latest = await read(); + if (latest) return latest; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return latest ?? null; +} + async function spawnOrphanedProcessGroup() { const leader = spawn( process.execPath, @@ -121,6 +178,15 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { afterEach(async () => { vi.clearAllMocks(); + mockAdapterExecute.mockImplementation(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + errorMessage: null, + summary: "Recovered stranded heartbeat work.", + provider: "test", + model: "test-model", + })); runningProcesses.clear(); for (const child of childProcesses) { child.kill("SIGKILL"); @@ -134,11 +200,51 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { } } cleanupPids.clear(); + let idlePolls = 0; + for (let attempt = 0; attempt < 100; attempt += 1) { + const runs = await db + .select({ + status: heartbeatRuns.status, + processPid: heartbeatRuns.processPid, + processGroupId: heartbeatRuns.processGroupId, + }) + .from(heartbeatRuns); + const managedExecutionStillActive = runs.some( + (run) => + (run.status === "queued" || run.status === "running") && + !run.processPid && + !run.processGroupId, + ); + if (!managedExecutionStillActive) { + idlePolls += 1; + if (idlePolls >= 3) break; + } else { + idlePolls = 0; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + await db.delete(activityLog); + await db.delete(agentRuntimeState); + await db.delete(companySkills); + await db.delete(issueComments); + await db.delete(issueDocuments); + await db.delete(documentRevisions); + await db.delete(documents); await db.delete(issues); await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); - await db.delete(agents); + for (let attempt = 0; attempt < 5; attempt += 1) { + await db.delete(agentRuntimeState); + try { + await db.delete(agents); + break; + } catch (error) { + if (attempt === 4) throw error; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } await db.delete(companies); }); @@ -246,6 +352,95 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { return { companyId, agentId, runId, wakeupRequestId, issueId }; } + async function seedStrandedIssueFixture(input: { + status: "todo" | "in_progress"; + runStatus: "failed" | "timed_out" | "cancelled" | "succeeded"; + retryReason?: "assignment_recovery" | "issue_continuation_needed" | null; + assignToUser?: boolean; + }) { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const wakeupRequestId = randomUUID(); + const issueId = randomUUID(); + const now = new Date("2026-03-19T00:00:00.000Z"); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(agentWakeupRequests).values({ + id: wakeupRequestId, + companyId, + agentId, + source: "assignment", + triggerDetail: "system", + reason: input.retryReason === "assignment_recovery" ? "issue_assignment_recovery" : "issue_assigned", + payload: { issueId }, + status: input.runStatus === "cancelled" ? "cancelled" : "failed", + runId, + claimedAt: now, + finishedAt: new Date("2026-03-19T00:05:00.000Z"), + error: input.runStatus === "succeeded" ? null : "run failed before issue advanced", + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + triggerDetail: "system", + status: input.runStatus, + wakeupRequestId, + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: input.retryReason === "assignment_recovery" + ? "issue_assignment_recovery" + : input.retryReason ?? "issue_assigned", + ...(input.retryReason ? { retryReason: input.retryReason } : {}), + }, + startedAt: now, + finishedAt: new Date("2026-03-19T00:05:00.000Z"), + updatedAt: new Date("2026-03-19T00:05:00.000Z"), + errorCode: input.runStatus === "succeeded" ? null : "process_lost", + error: input.runStatus === "succeeded" ? null : "run failed before issue advanced", + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Recover stranded assigned work", + status: input.status, + priority: "medium", + assigneeAgentId: input.assignToUser ? null : agentId, + assigneeUserId: input.assignToUser ? "user-1" : null, + checkoutRunId: input.status === "in_progress" ? runId : null, + executionRunId: null, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + startedAt: input.status === "in_progress" ? now : null, + }); + + return { companyId, agentId, runId, wakeupRequestId, issueId }; + } + it("keeps a local run active when the recorded pid is still alive", async () => { const child = spawnAliveProcess(); childProcesses.add(child); @@ -293,6 +488,13 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const retryRun = runs.find((row) => row.id !== runId); expect(failedRun?.status).toBe("failed"); expect(failedRun?.errorCode).toBe("process_lost"); + expect(failedRun?.livenessState).toBe("failed"); + expect(failedRun?.livenessReason).toContain("process_lost"); + expect(failedRun?.resultJson).toMatchObject({ + stopReason: "process_lost", + timeoutConfigured: false, + timeoutFired: false, + }); expect(retryRun?.status).toBe("queued"); expect(retryRun?.retryOfRunId).toBe(runId); expect(retryRun?.processLossRetryCount).toBe(1); @@ -390,7 +592,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }); it("tracks the first heartbeat with the agent role instead of adapter type", async () => { - const { runId } = await seedRunFixture({ + const { agentId, runId } = await seedRunFixture({ agentStatus: "running", includeIssue: false, }); @@ -398,8 +600,330 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await heartbeat.cancelRun(runId); - expect(mockTrackAgentFirstHeartbeat).toHaveBeenCalledWith(mockTelemetryClient, { - agentRole: "engineer", + expect(mockTrackAgentFirstHeartbeat).toHaveBeenCalledWith( + mockTelemetryClient, + expect.objectContaining({ + agentRole: "engineer", + agentId, + }), + ); + }); + + it("records manual cancellation stop metadata", async () => { + const { runId } = await seedRunFixture({ + agentStatus: "running", + includeIssue: false, + }); + const heartbeat = heartbeatService(db); + + const cancelled = await heartbeat.cancelRun(runId); + expect(cancelled?.status).toBe("cancelled"); + expect(cancelled?.resultJson).toMatchObject({ + stopReason: "cancelled", + effectiveTimeoutSec: 0, + timeoutConfigured: false, + timeoutFired: false, }); }); + + it("re-enqueues assigned todo work when the last issue run died and no wake remains", async () => { + const { agentId, issueId, runId } = await seedStrandedIssueFixture({ + status: "todo", + runStatus: "failed", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.dispatchRequeued).toBe(1); + expect(result.continuationRequeued).toBe(0); + expect(result.escalated).toBe(0); + expect(result.issueIds).toEqual([issueId]); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(2); + + const retryRun = runs.find((row) => row.id !== runId); + expect(retryRun?.id).toBeTruthy(); + expect((retryRun?.contextSnapshot as Record)?.retryReason).toBe("assignment_recovery"); + if (retryRun) { + await waitForRunToSettle(heartbeat, retryRun.id); + } + }); + + it("blocks assigned todo work after the one automatic dispatch recovery was already used", async () => { + const { issueId } = await seedStrandedIssueFixture({ + status: "todo", + runStatus: "failed", + retryReason: "assignment_recovery", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.dispatchRequeued).toBe(0); + expect(result.escalated).toBe(1); + expect(result.issueIds).toEqual([issueId]); + + const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); + expect(issue?.status).toBe("blocked"); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain("retried dispatch"); + expect(comments[0]?.body).toContain("Latest retry failure: `process_lost` - run failed before issue advanced."); + }); + + it("re-enqueues continuation for stranded in-progress work with no active run", async () => { + const { agentId, issueId, runId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "failed", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.dispatchRequeued).toBe(0); + expect(result.continuationRequeued).toBe(1); + expect(result.escalated).toBe(0); + expect(result.issueIds).toEqual([issueId]); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(2); + + const retryRun = runs.find((row) => row.id !== runId); + expect(retryRun?.id).toBeTruthy(); + expect((retryRun?.contextSnapshot as Record)?.retryReason).toBe("issue_continuation_needed"); + if (retryRun) { + await waitForRunToSettle(heartbeat, retryRun.id); + } + }); + + it("does not continue seeded in-progress work that has no run linkage", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Seeded in-flight work", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + checkoutRunId: null, + executionRunId: null, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + startedAt: new Date("2026-03-19T00:00:00.000Z"), + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.dispatchRequeued).toBe(0); + expect(result.continuationRequeued).toBe(0); + expect(result.escalated).toBe(0); + expect(result.skipped).toBe(1); + + const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(0); + const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)); + expect(issue?.status).toBe("in_progress"); + expect(issue?.executionRunId).toBeNull(); + }); + + it("classifies actionable plan-only recovery and enqueues one liveness continuation", async () => { + mockAdapterExecute.mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + errorMessage: null, + summary: "I will inspect the repo next and then implement the fix.", + provider: "test", + model: "test-model", + }); + const { agentId, issueId, runId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "failed", + }); + const heartbeat = heartbeatService(db); + + await heartbeat.reconcileStrandedAssignedIssues(); + + const livenessWake = await waitForValue(async () => { + const rows = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId)); + return rows.find((row) => row.reason === "run_liveness_continuation") ?? null; + }); + expect(livenessWake).toBeTruthy(); + expect(livenessWake?.payload).toMatchObject({ + issueId, + livenessState: "plan_only", + continuationAttempt: 1, + }); + + const sourceRunId = (livenessWake?.payload as Record | null)?.sourceRunId; + expect(sourceRunId).toBeTruthy(); + const sourceRun = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, String(sourceRunId))) + .then((rows) => rows[0] ?? null); + expect(sourceRun?.id).not.toBe(runId); + expect(sourceRun?.livenessState).toBe("plan_only"); + }); + + it("treats a plan document update as progress and does not enqueue liveness continuation", async () => { + const { agentId, companyId, issueId, runId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "failed", + }); + mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => { + const documentId = randomUUID(); + const revisionId = randomUUID(); + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Plan", + format: "markdown", + latestBody: "# Plan\n\n- Inspect files\n- Implement fix", + latestRevisionId: revisionId, + latestRevisionNumber: 1, + createdByAgentId: agentId, + updatedByAgentId: agentId, + }); + await db.insert(documentRevisions).values({ + id: revisionId, + companyId, + documentId, + revisionNumber: 1, + title: "Plan", + format: "markdown", + body: "# Plan\n\n- Inspect files\n- Implement fix", + createdByAgentId: agentId, + createdByRunId: ctx.runId, + }); + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId, + key: "plan", + }); + return { + exitCode: 0, + signal: null, + timedOut: false, + errorMessage: null, + summary: "Plan:\n- Inspect files\n- Implement fix", + provider: "test", + model: "test-model", + }; + }); + const heartbeat = heartbeatService(db); + + await heartbeat.reconcileStrandedAssignedIssues(); + + const retryRun = await waitForValue(async () => { + const rows = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + return rows.find((row) => row.id !== runId && row.livenessState === "advanced") ?? null; + }); + expect(retryRun?.livenessState).toBe("advanced"); + + const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId)); + expect(wakes.some((row) => row.reason === "run_liveness_continuation")).toBe(false); + }); + + it("blocks stranded in-progress work after the continuation retry was already used", async () => { + const { issueId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "failed", + retryReason: "issue_continuation_needed", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.continuationRequeued).toBe(0); + expect(result.escalated).toBe(1); + expect(result.issueIds).toEqual([issueId]); + + const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); + expect(issue?.status).toBe("blocked"); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain("retried continuation"); + expect(comments[0]?.body).toContain("Latest retry failure: `process_lost` - run failed before issue advanced."); + }); + + it("re-enqueues continuation when the latest automatic continuation succeeded without closing the issue", async () => { + const { agentId, issueId, runId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "succeeded", + retryReason: "issue_continuation_needed", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.continuationRequeued).toBe(1); + expect(result.escalated).toBe(0); + expect(result.issueIds).toEqual([issueId]); + + const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); + expect(issue?.status).toBe("in_progress"); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + expect(comments).toHaveLength(0); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(2); + + const retryRun = runs.find((row) => row.id !== runId); + expect(retryRun?.id).toBeTruthy(); + expect((retryRun?.contextSnapshot as Record)?.retryReason).toBe("issue_continuation_needed"); + if (retryRun) { + await waitForRunToSettle(heartbeat, retryRun.id); + } + }); + + it("does not reconcile user-assigned work through the agent stranded-work recovery path", async () => { + const { issueId, runId } = await seedStrandedIssueFixture({ + status: "todo", + runStatus: "failed", + assignToUser: true, + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.dispatchRequeued).toBe(0); + expect(result.continuationRequeued).toBe(0); + expect(result.escalated).toBe(0); + + const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); + expect(issue?.status).toBe("todo"); + + const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(runs).toHaveLength(1); + }); }); diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 85f2b051703..8490b04e418 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.ts"; +import { buildSkillMentionHref } from "@paperclipai/shared"; +import { + applyRunScopedMentionedSkillKeys, + extractMentionedSkillIdsFromSources, + resolveExecutionRunAdapterConfig, +} from "../services/heartbeat.ts"; describe("resolveExecutionRunAdapterConfig", () => { it("overlays project env on top of agent env and unions secret keys", async () => { @@ -63,3 +68,51 @@ describe("resolveExecutionRunAdapterConfig", () => { expect(resolveEnvBindings).not.toHaveBeenCalled(); }); }); + +describe("extractMentionedSkillIdsFromSources", () => { + it("collects explicit skill mention ids across issue sources", () => { + const releaseHref = buildSkillMentionHref("skill-1", "release-changelog"); + const browserHref = buildSkillMentionHref("skill-2", "agent-browser"); + + expect( + extractMentionedSkillIdsFromSources([ + `Please use [/release-changelog](${releaseHref})`, + `And also [/agent-browser](${browserHref})`, + `Duplicate mention [/release-changelog](${releaseHref})`, + ]), + ).toEqual(["skill-1", "skill-2"]); + }); +}); + +describe("applyRunScopedMentionedSkillKeys", () => { + it("adds mentioned skills without mutating the original config", () => { + const originalConfig = { + command: "codex", + paperclipSkillSync: { + desiredSkills: ["paperclipai/paperclip/paperclip"], + }, + }; + + const updatedConfig = applyRunScopedMentionedSkillKeys(originalConfig, [ + "company/company-1/release-changelog", + "paperclipai/paperclip/paperclip", + "company/company-1/release-changelog", + ]); + + expect(updatedConfig).toEqual({ + command: "codex", + paperclipSkillSync: { + desiredSkills: [ + "paperclipai/paperclip/paperclip", + "company/company-1/release-changelog", + ], + }, + }); + expect(originalConfig).toEqual({ + command: "codex", + paperclipSkillSync: { + desiredSkills: ["paperclipai/paperclip/paperclip"], + }, + }); + }); +}); diff --git a/server/src/__tests__/heartbeat-run-log.test.ts b/server/src/__tests__/heartbeat-run-log.test.ts new file mode 100644 index 00000000000..e88f976dd58 --- /dev/null +++ b/server/src/__tests__/heartbeat-run-log.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { compactRunLogChunk } from "../services/heartbeat.js"; + +describe("compactRunLogChunk", () => { + it("redacts inline base64 image data from structured log chunks", () => { + const base64 = "A".repeat(4096); + const chunk = `{"type":"user","message":{"content":[{"type":"image","source":{"type":"base64","data":"${base64}"}}]}}\n`; + + const compacted = compactRunLogChunk(chunk); + + expect(compacted).not.toContain(base64); + expect(compacted).toContain("[omitted base64 image data: 4096 chars]"); + }); + + it("truncates oversized chunks after sanitizing them", () => { + const chunk = `${"x".repeat(90_000)}tail`; + + const compacted = compactRunLogChunk(chunk, 16_384); + + expect(compacted.length).toBeLessThan(chunk.length); + expect(compacted).toContain("[paperclip truncated run log chunk:"); + expect(compacted.endsWith("tail")).toBe(true); + }); +}); diff --git a/server/src/__tests__/heartbeat-run-summary.test.ts b/server/src/__tests__/heartbeat-run-summary.test.ts index 7355f0d4d86..e5d8848d6c3 100644 --- a/server/src/__tests__/heartbeat-run-summary.test.ts +++ b/server/src/__tests__/heartbeat-run-summary.test.ts @@ -15,6 +15,10 @@ describe("summarizeHeartbeatRunResultJson", () => { total_cost_usd: 1.23, cost_usd: 0.45, costUsd: 0.67, + stopReason: "timeout", + effectiveTimeoutSec: 30, + timeoutConfigured: true, + timeoutFired: true, nested: { ignored: true }, }); @@ -26,6 +30,10 @@ describe("summarizeHeartbeatRunResultJson", () => { total_cost_usd: 1.23, cost_usd: 0.45, costUsd: 0.67, + stopReason: "timeout", + effectiveTimeoutSec: 30, + timeoutConfigured: true, + timeoutFired: true, }); }); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index d97f63c70f5..23a58088855 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -201,44 +201,6 @@ describe("buildRealizedExecutionWorkspaceFromPersisted", () => { expect(result.branchName).toBe("PAP-880-thumbs-capture-for-evals-feature"); expect(result.source).toBe("task_session"); }); - - it("falls back to realization when the persisted workspace has no local path yet", () => { - const result = buildRealizedExecutionWorkspaceFromPersisted({ - base: buildResolvedWorkspace({ - cwd: "/tmp/project-primary", - repoRef: "main", - }), - workspace: { - id: "execution-workspace-2", - companyId: "company-1", - projectId: "project-1", - projectWorkspaceId: "workspace-1", - sourceIssueId: "issue-2", - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "PAP-999-missing-provider-ref", - status: "active", - cwd: null, - repoUrl: "https://example.com/paperclip.git", - baseRef: "main", - branchName: "feature/PAP-999-missing-provider-ref", - providerType: "git_worktree", - providerRef: null, - derivedFromExecutionWorkspaceId: null, - lastUsedAt: new Date(), - openedAt: new Date(), - closedAt: null, - cleanupEligibleAt: null, - cleanupReason: null, - config: null, - metadata: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - - expect(result).toBeNull(); - }); }); describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => { diff --git a/server/src/__tests__/http-log-policy.test.ts b/server/src/__tests__/http-log-policy.test.ts new file mode 100644 index 00000000000..0e540a11161 --- /dev/null +++ b/server/src/__tests__/http-log-policy.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { shouldSilenceHttpSuccessLog } from "../middleware/http-log-policy.js"; + +describe("shouldSilenceHttpSuccessLog", () => { + it("silences cached 304 responses", () => { + expect(shouldSilenceHttpSuccessLog("GET", "/api/issues/PAP-1383", 304)).toBe(true); + }); + + it("silences successful polling endpoints", () => { + expect(shouldSilenceHttpSuccessLog("GET", "/api/health", 200)).toBe(true); + expect( + shouldSilenceHttpSuccessLog( + "GET", + "/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/heartbeat-runs", + 200, + ), + ).toBe(true); + expect( + shouldSilenceHttpSuccessLog( + "GET", + "/api/heartbeat-runs/b7044268-19b6-4b3a-a9f3-9c57dce70253/log?offset=1103894&limitBytes=256000", + 200, + ), + ).toBe(true); + expect( + shouldSilenceHttpSuccessLog( + "GET", + "/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/live-runs?minCount=3", + 200, + ), + ).toBe(true); + expect( + shouldSilenceHttpSuccessLog( + "HEAD", + "/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/sidebar-badges", + 200, + ), + ).toBe(true); + expect( + shouldSilenceHttpSuccessLog( + "GET", + "/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/issues?includeRoutineExecutions=true", + 200, + ), + ).toBe(true); + expect( + shouldSilenceHttpSuccessLog( + "GET", + "/api/companies/5cbe79ee-acb3-4597-896e-7662742593cd/activity", + 200, + ), + ).toBe(true); + }); + + it("silences successful static asset requests", () => { + expect(shouldSilenceHttpSuccessLog("GET", "/", 200)).toBe(true); + expect(shouldSilenceHttpSuccessLog("GET", "/index.html", 200)).toBe(true); + expect(shouldSilenceHttpSuccessLog("GET", "/@fs/Users/dotta/paperclip/ui/src/main.tsx", 200)).toBe(true); + expect(shouldSilenceHttpSuccessLog("GET", "/src/App.tsx?t=123", 200)).toBe(true); + expect(shouldSilenceHttpSuccessLog("GET", "/site.webmanifest", 200)).toBe(true); + expect(shouldSilenceHttpSuccessLog("GET", "/sw.js", 200)).toBe(true); + }); + + it("keeps normal successful application requests", () => { + expect(shouldSilenceHttpSuccessLog("GET", "/api/issues/PAP-1383", 200)).toBe(false); + expect(shouldSilenceHttpSuccessLog("PATCH", "/api/issues/PAP-1383", 200)).toBe(false); + }); + + it("keeps failing requests visible", () => { + expect(shouldSilenceHttpSuccessLog("GET", "/api/health", 500)).toBe(false); + expect(shouldSilenceHttpSuccessLog("GET", "/@fs/Users/dotta/paperclip/ui/src/main.tsx", 404)).toBe(false); + }); +}); diff --git a/server/src/__tests__/instance-database-backups-routes.test.ts b/server/src/__tests__/instance-database-backups-routes.test.ts new file mode 100644 index 00000000000..87774a82e4a --- /dev/null +++ b/server/src/__tests__/instance-database-backups-routes.test.ts @@ -0,0 +1,149 @@ +import express from "express"; +import request from "supertest"; +import { describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { + instanceDatabaseBackupRoutes, + type InstanceDatabaseBackupService, +} from "../routes/instance-database-backups.js"; +import { conflict } from "../errors.js"; + +function createApp(actor: Record, service: InstanceDatabaseBackupService) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor as typeof req.actor; + next(); + }); + app.use("/api", instanceDatabaseBackupRoutes(service)); + app.use(errorHandler); + return app; +} + +function createBackupService(overrides: Partial = {}): InstanceDatabaseBackupService { + return { + runManualBackup: vi.fn().mockResolvedValue({ + trigger: "manual", + backupFile: "/tmp/paperclip-20260416.sql.gz", + sizeBytes: 1234, + prunedCount: 2, + backupDir: "/tmp", + retention: { + dailyDays: 7, + weeklyWeeks: 4, + monthlyMonths: 1, + }, + startedAt: "2026-04-16T20:00:00.000Z", + finishedAt: "2026-04-16T20:00:01.000Z", + durationMs: 1000, + }), + ...overrides, + }; +} + +describe("instance database backup routes", () => { + it("runs a manual backup for an instance admin and returns the server result", async () => { + const service = createBackupService(); + const app = createApp( + { + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + }, + service, + ); + + const res = await request(app).post("/api/instance/database-backups").send({}); + + expect(res.status).toBe(201); + expect(service.runManualBackup).toHaveBeenCalledTimes(1); + expect(res.body).toEqual({ + trigger: "manual", + backupFile: "/tmp/paperclip-20260416.sql.gz", + sizeBytes: 1234, + prunedCount: 2, + backupDir: "/tmp", + retention: { + dailyDays: 7, + weeklyWeeks: 4, + monthlyMonths: 1, + }, + startedAt: "2026-04-16T20:00:00.000Z", + finishedAt: "2026-04-16T20:00:01.000Z", + durationMs: 1000, + }); + }); + + it("allows local implicit board access", async () => { + const service = createBackupService(); + const app = createApp( + { + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: false, + }, + service, + ); + + await request(app).post("/api/instance/database-backups").send({}).expect(201); + + expect(service.runManualBackup).toHaveBeenCalledTimes(1); + }); + + it("rejects non-admin board users", async () => { + const service = createBackupService(); + const app = createApp( + { + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }, + service, + ); + + await request(app).post("/api/instance/database-backups").send({}).expect(403); + + expect(service.runManualBackup).not.toHaveBeenCalled(); + }); + + it("rejects agent callers", async () => { + const service = createBackupService(); + const app = createApp( + { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + }, + service, + ); + + await request(app).post("/api/instance/database-backups").send({}).expect(403); + + expect(service.runManualBackup).not.toHaveBeenCalled(); + }); + + it("returns conflict when another server backup is already running", async () => { + const service = createBackupService({ + runManualBackup: vi.fn().mockRejectedValue(conflict("Database backup already in progress")), + }); + const app = createApp( + { + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + }, + service, + ); + + const res = await request(app).post("/api/instance/database-backups").send({}); + + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: "Database backup already in progress" }); + }); +}); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 12f8ff7af3d..65dfe7ac35b 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -16,10 +16,17 @@ vi.mock("../services/index.js", () => ({ logActivity: mockLogActivity, })); +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + logActivity: mockLogActivity, + })); +} + async function createApp(actor: any) { - const [{ instanceSettingsRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/instance-settings.js"), - import("../middleware/index.js"), + const [{ errorHandler }, { instanceSettingsRoutes }] = await Promise.all([ + vi.importActual("../middleware/index.js"), + vi.importActual("../routes/instance-settings.js"), ]); const app = express(); app.use(express.json()); @@ -35,7 +42,17 @@ async function createApp(actor: any) { describe("instance settings routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../routes/instance-settings.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); + mockInstanceSettingsService.getGeneral.mockReset(); + mockInstanceSettingsService.getExperimental.mockReset(); + mockInstanceSettingsService.updateGeneral.mockReset(); + mockInstanceSettingsService.updateExperimental.mockReset(); + mockInstanceSettingsService.listCompanyIds.mockReset(); + mockLogActivity.mockReset(); mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, keyboardShortcuts: false, @@ -152,7 +169,27 @@ describe("instance settings routes", () => { const res = await request(app).get("/api/instance/settings/general"); expect(res.status).toBe(200); - expect(mockInstanceSettingsService.getGeneral).toHaveBeenCalled(); + expect(res.body).toEqual({ + censorUsernameInLogs: false, + keyboardShortcuts: false, + feedbackDataSharingPreference: "prompt", + }); + }); + + it("rejects signed-in users without company access from reading general settings", async () => { + const app = await createApp({ + type: "board", + userId: "user-2", + source: "session", + isInstanceAdmin: false, + companyIds: [], + memberships: [], + }); + + const res = await request(app).get("/api/instance/settings/general"); + + expect(res.status).toBe(403); + expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled(); }); it("rejects non-admin board users from updating general settings", async () => { diff --git a/server/src/__tests__/invite-accept-existing-member.test.ts b/server/src/__tests__/invite-accept-existing-member.test.ts new file mode 100644 index 00000000000..913fcdbf32e --- /dev/null +++ b/server/src/__tests__/invite-accept-existing-member.test.ts @@ -0,0 +1,126 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: vi.fn(), + notifyHireApproved: vi.fn(), +})); + +function createDbStub() { + const updateMock = vi.fn(); + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: { humanRole: "viewer" }, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + invitedByUserId: "user-1", + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + + const db = { + select() { + return { + from() { + return { + where() { + return Promise.resolve([invite]); + }, + }; + }, + }; + }, + update(...args: unknown[]) { + updateMock(...args); + return { + set() { + return { + where() { + return { + returning() { + return Promise.resolve([]); + }, + }; + }, + }; + }, + }; + }, + }; + + return { db, updateMock }; +} + +function createApp(db: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "session", + userId: "user-1", + companyIds: ["company-1"], + memberships: [ + { + companyId: "company-1", + membershipRole: "owner", + status: "active", + }, + ], + }; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("POST /invites/:token/accept", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not consume a human invite when the signed-in user is already a company member", async () => { + const { db, updateMock } = createDbStub(); + const app = createApp(db); + + const res = await request(app) + .post("/api/invites/pcp_invite_test/accept") + .send({ requestType: "human" }); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("You already belong to this company"); + expect(updateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/invite-create-route.test.ts b/server/src/__tests__/invite-create-route.test.ts new file mode 100644 index 00000000000..5512d8dce2f --- /dev/null +++ b/server/src/__tests__/invite-create-route.test.ts @@ -0,0 +1,126 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +const logActivityMock = vi.fn(); + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: (...args: unknown[]) => logActivityMock(...args), + notifyHireApproved: vi.fn(), +})); + +function createDbStub() { + const createdInvite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: { humanRole: "viewer" }, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + + return { + insert() { + return { + values() { + return { + returning() { + return Promise.resolve([createdInvite]); + }, + }; + }, + }; + }, + select(_shape?: unknown) { + return { + from() { + const query = { + leftJoin() { + return query; + }, + where() { + return Promise.resolve([{ + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }]); + }, + }; + return query; + }, + }; + }, + }; +} + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId: null, + companyIds: ["company-1"], + }; + next(); + }); + app.use( + "/api", + accessRoutes(createDbStub() as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("POST /companies/:companyId/invites", () => { + beforeEach(() => { + logActivityMock.mockReset(); + }); + + it("returns an absolute invite URL using the request base URL", async () => { + const app = createApp(); + + const res = await request(app) + .post("/api/companies/company-1/invites") + .set("host", "paperclip.example") + .set("x-forwarded-proto", "https") + .send({ + allowedJoinTypes: "human", + humanRole: "viewer", + }); + + expect(res.status).toBe(201); + expect(res.body.companyName).toBe("Acme Robotics"); + expect(res.body.invitePath).toMatch(/^\/invite\/pcp_invite_/); + expect(res.body.inviteUrl).toMatch(/^https:\/\/paperclip\.example\/invite\/pcp_invite_/); + }); +}); diff --git a/server/src/__tests__/invite-expiry.test.ts b/server/src/__tests__/invite-expiry.test.ts index c84a2a95572..66a7e7d2808 100644 --- a/server/src/__tests__/invite-expiry.test.ts +++ b/server/src/__tests__/invite-expiry.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest"; import { companyInviteExpiresAt } from "../routes/access.js"; describe("companyInviteExpiresAt", () => { - it("sets invite expiration to 10 minutes after invite creation time", () => { + it("sets invite expiration to 72 hours after invite creation time", () => { const createdAtMs = Date.parse("2026-03-06T00:00:00.000Z"); const expiresAt = companyInviteExpiresAt(createdAtMs); - expect(expiresAt.toISOString()).toBe("2026-03-06T00:10:00.000Z"); + expect(expiresAt.toISOString()).toBe("2026-03-09T00:00:00.000Z"); }); }); diff --git a/server/src/__tests__/invite-join-grants.test.ts b/server/src/__tests__/invite-join-grants.test.ts index 7dd342677f6..fae007e059d 100644 --- a/server/src/__tests__/invite-join-grants.test.ts +++ b/server/src/__tests__/invite-join-grants.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { agentJoinGrantsFromDefaults } from "../routes/access.js"; +import { + agentJoinGrantsFromDefaults, + humanJoinGrantsFromDefaults, +} from "../services/invite-grants.js"; +import { + grantsForHumanRole, + normalizeHumanRole, + resolveHumanInviteRole, +} from "../services/company-member-roles.js"; describe("agentJoinGrantsFromDefaults", () => { it("adds tasks:assign when invite defaults do not specify agent grants", () => { @@ -55,3 +63,59 @@ describe("agentJoinGrantsFromDefaults", () => { ]); }); }); + +describe("human invite roles", () => { + it("maps owner to the full management grant set", () => { + expect(grantsForHumanRole("owner")).toEqual([ + { permissionKey: "agents:create", scope: null }, + { permissionKey: "users:invite", scope: null }, + { permissionKey: "users:manage_permissions", scope: null }, + { permissionKey: "tasks:assign", scope: null }, + { permissionKey: "joins:approve", scope: null }, + ]); + }); + + it("defaults legacy or missing roles to operator", () => { + expect(normalizeHumanRole("member")).toBe("operator"); + expect(resolveHumanInviteRole(null)).toBe("operator"); + }); + + it("reads the configured human invite role from defaults", () => { + expect( + resolveHumanInviteRole({ + human: { + role: "viewer", + }, + }), + ).toBe("viewer"); + }); + + it("falls back to role grants when human invite defaults omit explicit grants", () => { + expect(humanJoinGrantsFromDefaults(null, "operator")).toEqual([ + { permissionKey: "tasks:assign", scope: null }, + ]); + }); + + it("preserves explicit human invite grants", () => { + expect( + humanJoinGrantsFromDefaults( + { + human: { + grants: [ + { + permissionKey: "users:invite", + scope: { companyId: "company-1" }, + }, + ], + }, + }, + "operator", + ), + ).toEqual([ + { + permissionKey: "users:invite", + scope: { companyId: "company-1" }, + }, + ]); + }); +}); diff --git a/server/src/__tests__/invite-list-route.test.ts b/server/src/__tests__/invite-list-route.test.ts new file mode 100644 index 00000000000..30b52b79659 --- /dev/null +++ b/server/src/__tests__/invite-list-route.test.ts @@ -0,0 +1,164 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { companies, createDb, invites, joinRequests } from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: vi.fn(), + notifyHireApproved: vi.fn(), +})); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres invite list route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("GET /companies/:companyId/invites", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + let companyId!: string; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-invite-list-route-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + beforeEach(async () => { + companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + }); + + afterEach(async () => { + await db.delete(joinRequests); + await db.delete(invites); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createApp(currentCompanyId: string) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId: null, + companyIds: [currentCompanyId], + }; + next(); + }); + app.use( + "/api", + accessRoutes(db, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; + } + + it("returns invite history in descending pages with nextOffset", async () => { + const inviteOneId = randomUUID(); + const inviteTwoId = randomUUID(); + const inviteThreeId = randomUUID(); + + await db.insert(invites).values([ + { + id: inviteOneId, + companyId, + inviteType: "company_join", + tokenHash: "invite-token-1", + allowedJoinTypes: "human", + defaultsPayload: { humanRole: "viewer" }, + expiresAt: new Date("2026-04-20T00:00:00.000Z"), + createdAt: new Date("2026-04-10T00:00:00.000Z"), + updatedAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + id: inviteTwoId, + companyId, + inviteType: "company_join", + tokenHash: "invite-token-2", + allowedJoinTypes: "human", + defaultsPayload: { humanRole: "operator" }, + expiresAt: new Date("2026-04-21T00:00:00.000Z"), + createdAt: new Date("2026-04-11T00:00:00.000Z"), + updatedAt: new Date("2026-04-11T00:00:00.000Z"), + }, + { + id: inviteThreeId, + companyId, + inviteType: "company_join", + tokenHash: "invite-token-3", + allowedJoinTypes: "human", + defaultsPayload: { humanRole: "admin" }, + expiresAt: new Date("2026-04-22T00:00:00.000Z"), + createdAt: new Date("2026-04-12T00:00:00.000Z"), + updatedAt: new Date("2026-04-12T00:00:00.000Z"), + }, + ]); + + await db.insert(joinRequests).values({ + id: randomUUID(), + inviteId: inviteThreeId, + companyId, + requestType: "human", + status: "pending_approval", + requestIp: "127.0.0.1", + requestEmailSnapshot: "person@example.com", + createdAt: new Date("2026-04-12T00:05:00.000Z"), + updatedAt: new Date("2026-04-12T00:05:00.000Z"), + }); + + const app = createApp(companyId); + + const firstPage = await request(app).get(`/api/companies/${companyId}/invites?limit=2`); + + expect(firstPage.status).toBe(200); + expect(firstPage.body.invites).toHaveLength(2); + expect(firstPage.body.invites.map((invite: { id: string }) => invite.id)).toEqual([inviteThreeId, inviteTwoId]); + expect(firstPage.body.invites[0].relatedJoinRequestId).toBeTruthy(); + expect(firstPage.body.nextOffset).toBe(2); + + const secondPage = await request(app).get(`/api/companies/${companyId}/invites?limit=2&offset=2`); + + expect(secondPage.status).toBe(200); + expect(secondPage.body.invites).toHaveLength(1); + expect(secondPage.body.invites[0].id).toBe(inviteOneId); + expect(secondPage.body.nextOffset).toBeNull(); + }); +}); diff --git a/server/src/__tests__/invite-logo-route.test.ts b/server/src/__tests__/invite-logo-route.test.ts new file mode 100644 index 00000000000..a18137c3ab4 --- /dev/null +++ b/server/src/__tests__/invite-logo-route.test.ts @@ -0,0 +1,146 @@ +import { Readable } from "node:stream"; +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockStorage = vi.hoisted(() => ({ + getObject: vi.fn(), + headObject: vi.fn(), +})); + +vi.mock("../storage/index.js", () => ({ + getStorageService: () => mockStorage, +})); + +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +function createSelectChain(rows: unknown[]) { + const query = { + leftJoin() { + return query; + }, + where() { + return Promise.resolve(rows); + }, + }; + return { + from() { + return query; + }, + }; +} + +function createDbStub(inviteRows: unknown[], companyRows: unknown[]) { + let selectCall = 0; + return { + select() { + selectCall += 1; + return selectCall === 1 + ? createSelectChain(inviteRows) + : createSelectChain(companyRows); + }, + }; +} + +function createApp(db: Record) { + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "anon" }; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("GET /invites/:token/logo", () => { + beforeEach(() => { + mockStorage.getObject.mockReset(); + mockStorage.headObject.mockReset(); + }); + + it("serves the company logo for an active invite without company auth", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + mockStorage.headObject.mockResolvedValue({ + exists: true, + contentType: "image/png", + contentLength: 3, + }); + mockStorage.getObject.mockResolvedValue({ + contentType: "image/png", + contentLength: 3, + stream: Readable.from([Buffer.from("png")]), + }); + const app = createApp( + createDbStub([invite], [{ + companyId: "company-1", + objectKey: "assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }]), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test/logo"); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("image/png"); + expect(mockStorage.headObject).toHaveBeenCalledWith("company-1", "assets/companies/logo-1"); + expect(mockStorage.getObject).toHaveBeenCalledWith("company-1", "assets/companies/logo-1"); + }); + + it("returns 404 when the logo asset record exists but storage does not", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + mockStorage.headObject.mockResolvedValue({ exists: false }); + const app = createApp( + createDbStub([invite], [{ + companyId: "company-1", + objectKey: "assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }]), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test/logo"); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Invite logo not found"); + expect(mockStorage.getObject).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index 8ba30115bca..fae84a63718 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -41,6 +41,7 @@ describe("buildInviteOnboardingTextDocument", () => { expect(text).toContain("/api/invites/token-123/accept"); expect(text).toContain("/api/join-requests/{requestId}/claim-api-key"); expect(text).toContain("/api/invites/token-123/onboarding.txt"); + expect(text).toContain("/api/invites/token-123/skills/paperclip"); expect(text).toContain("Suggested Paperclip base URLs to try"); expect(text).toContain("http://localhost:3100"); expect(text).toContain("host.docker.internal"); diff --git a/server/src/__tests__/invite-summary-route.test.ts b/server/src/__tests__/invite-summary-route.test.ts new file mode 100644 index 00000000000..6fd7172af69 --- /dev/null +++ b/server/src/__tests__/invite-summary-route.test.ts @@ -0,0 +1,270 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockStorage = vi.hoisted(() => ({ + headObject: vi.fn(), +})); + +vi.mock("../storage/index.js", () => ({ + getStorageService: () => mockStorage, +})); + +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +function createSelectChain(rows: unknown[]) { + const query = { + then(resolve: (value: unknown[]) => unknown) { + return Promise.resolve(rows).then(resolve); + }, + leftJoin() { + return query; + }, + orderBy() { + return query; + }, + where() { + return query; + }, + }; + return { + from() { + return query; + }, + }; +} + +function createDbStub(...selectResponses: unknown[][]) { + let selectCall = 0; + return { + select() { + const rows = selectResponses[selectCall] ?? []; + selectCall += 1; + return createSelectChain(rows); + }, + }; +} + +function createApp( + db: Record, + actor: Record = { type: "anon" }, +) { + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("GET /invites/:token", () => { + beforeEach(() => { + mockStorage.headObject.mockReset(); + mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" }); + }); + + it("returns company branding in the invite summary response", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + const app = createApp( + createDbStub( + [invite], + [ + { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }, + ], + [ + { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }, + ], + ), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.companyId).toBe("company-1"); + expect(res.body.companyName).toBe("Acme Robotics"); + expect(res.body.companyBrandColor).toBe("#114488"); + expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo"); + expect(res.body.inviteType).toBe("company_join"); + }); + + it("omits companyLogoUrl when the stored logo object is missing", async () => { + mockStorage.headObject.mockResolvedValue({ exists: false }); + + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + const app = createApp( + createDbStub( + [invite], + [ + { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }, + ], + [ + { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }, + ], + ), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.companyLogoUrl).toBeNull(); + }); + + it("returns pending join-request status for an already-accepted invite", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: new Date("2026-03-07T00:05:00.000Z"), + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:05:00.000Z"), + }; + const app = createApp( + createDbStub( + [invite], + [{ requestType: "human", status: "pending_approval" }], + [ + { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }, + ], + [ + { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }, + ], + ), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.joinRequestStatus).toBe("pending_approval"); + expect(res.body.joinRequestType).toBe("human"); + expect(res.body.companyName).toBe("Acme Robotics"); + }); + + it("falls back to a reusable human join request when the accepted invite reused an existing queue entry", async () => { + const invite = { + id: "invite-2", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: new Date("2026-03-07T00:05:00.000Z"), + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:05:00.000Z"), + }; + const app = createApp( + createDbStub( + [invite], + [], + [{ email: "jane@example.com" }], + [ + { + id: "join-1", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "jane@example.com", + }, + ], + [ + { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }, + ], + [ + { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }, + ], + ), + { type: "board", userId: "user-1", source: "session" }, + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.joinRequestStatus).toBe("pending_approval"); + expect(res.body.joinRequestType).toBe("human"); + }); +}); diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index e07f91d2449..4463cedf617 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { issueRoutes } from "../routes/issues.js"; -import { errorHandler } from "../middleware/index.js"; import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts"; const mockIssueService = vi.hoisted(() => ({ @@ -64,7 +62,11 @@ vi.mock("../services/index.js", () => ({ workProductService: () => ({}), })); -function createApp() { +async function createApp() { + const [{ issueRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/issues.js"), + vi.importActual("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -99,7 +101,11 @@ function makeIssue() { describe("issue activity event routes", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetModules(); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + vi.resetAllMocks(); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockIssueService.findMentionedAgents.mockResolvedValue([]); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); @@ -145,35 +151,37 @@ describe("issue activity event routes", () => { updatedAt: new Date(), })); - const res = await request(createApp()) + const res = await request(await createApp()) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ blockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"] }); expect(res.status).toBe(200); - expect(mockLogActivity).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - action: "issue.blockers_updated", - details: expect.objectContaining({ - addedBlockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"], - removedBlockedByIssueIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"], - addedBlockedByIssues: [ - { - id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - identifier: "PAP-11", - title: "New blocker", - }, - ], - removedBlockedByIssues: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - identifier: "PAP-10", - title: "Old blocker", - }, - ], + await vi.waitFor(() => { + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.blockers_updated", + details: expect.objectContaining({ + addedBlockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"], + removedBlockedByIssueIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"], + addedBlockedByIssues: [ + { + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + identifier: "PAP-11", + title: "New blocker", + }, + ], + removedBlockedByIssues: [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + identifier: "PAP-10", + title: "Old blocker", + }, + ], + }), }), - }), - ); + ); + }); }, 15_000); it("logs explicit reviewer and approver activity when execution policy participants change", async () => { @@ -217,32 +225,34 @@ describe("issue activity event routes", () => { updatedAt: new Date(), })); - const res = await request(createApp()) + const res = await request(await createApp()) .patch("/api/issues/11111111-1111-4111-8111-111111111111") .send({ executionPolicy: nextPolicy }); expect(res.status).toBe(200); - expect(mockLogActivity).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - action: "issue.reviewers_updated", - details: expect.objectContaining({ - participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }], - addedParticipants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }], - removedParticipants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555", userId: null }], + await vi.waitFor(() => { + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.reviewers_updated", + details: expect.objectContaining({ + participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }], + addedParticipants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }], + removedParticipants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555", userId: null }], + }), }), - }), - ); - expect(mockLogActivity).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - action: "issue.approvers_updated", - details: expect.objectContaining({ - participants: [{ type: "user", agentId: null, userId: "local-board" }], - addedParticipants: [{ type: "user", agentId: null, userId: "local-board" }], - removedParticipants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa", userId: null }], + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.approvers_updated", + details: expect.objectContaining({ + participants: [{ type: "user", agentId: null, userId: "local-board" }], + addedParticipants: [{ type: "user", agentId: null, userId: "local-board" }], + removedParticipants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa", userId: null }], + }), }), - }), - ); + ); + }); }); }); diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index 36c6dd9ee7b..e3cfd7d8368 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -70,17 +70,34 @@ function registerRouteMocks() { })); } -function createStorageService(): StorageService { +type TestStorageService = StorageService & { + __calls: { + putFile?: { + companyId: string; + namespace: string; + originalFilename?: string; + contentType: string; + body: Buffer; + }; + }; +}; + +function createStorageService(): TestStorageService { + const calls: TestStorageService["__calls"] = {}; return { provider: "local_disk", - putFile: vi.fn(async (input) => ({ + __calls: calls, + putFile: async (input) => { + calls.putFile = input; + return { provider: "local_disk", objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`, contentType: input.contentType, byteSize: input.body.length, sha256: "sha256-sample", originalFilename: input.originalFilename, - })), + }; + }, getObject: vi.fn(async () => ({ stream: Readable.from(Buffer.from("test")), contentLength: 4, @@ -137,6 +154,7 @@ describe("issue attachment routes", () => { vi.resetModules(); registerRouteMocks(); vi.clearAllMocks(); + mockLogActivity.mockResolvedValue(undefined); }); it("accepts zip uploads for issue attachments", async () => { @@ -153,8 +171,8 @@ describe("issue attachment routes", () => { .post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments") .attach("file", Buffer.from("zip"), { filename: "bundle.zip", contentType: "application/zip" }); - expect(res.status).toBe(201); - const putFileCall = vi.mocked(storage.putFile).mock.calls[0]?.[0]; + expect([200, 201]).toContain(res.status); + const putFileCall = storage.__calls.putFile; expect(putFileCall).toMatchObject({ companyId: "company-1", namespace: "issues/11111111-1111-4111-8111-111111111111", diff --git a/server/src/__tests__/issue-closed-workspace-routes.test.ts b/server/src/__tests__/issue-closed-workspace-routes.test.ts index d6a53818c0b..f00fc84a83f 100644 --- a/server/src/__tests__/issue-closed-workspace-routes.test.ts +++ b/server/src/__tests__/issue-closed-workspace-routes.test.ts @@ -90,8 +90,8 @@ function registerServiceMocks() { async function createApp() { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/issues.js"), - import("../middleware/index.js"), + vi.importActual("../routes/issues.js"), + vi.importActual("../middleware/index.js"), ]); const app = express(); app.use(express.json()); @@ -141,8 +141,14 @@ function makeClosedWorkspace() { describe("closed isolated workspace issue routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("@paperclipai/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); registerServiceMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); mockIssueService.getById.mockResolvedValue(makeIssue()); mockExecutionWorkspaceService.getById.mockResolvedValue(makeClosedWorkspace()); }); diff --git a/server/src/__tests__/issue-comment-cancel-routes.test.ts b/server/src/__tests__/issue-comment-cancel-routes.test.ts new file mode 100644 index 00000000000..ca070233511 --- /dev/null +++ b/server/src/__tests__/issue-comment-cancel-routes.test.ts @@ -0,0 +1,191 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + assertCheckoutOwner: vi.fn(), + getComment: vi.fn(), + removeComment: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); + +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackAgentTaskCompleted: vi.fn(), + trackErrorHandlerCrash: vi.fn(), +})); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), +})); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => ({ getById: vi.fn(async () => null) }), + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined) }), + workProductService: () => ({}), +})); + +function createApp() { + const app = express(); + app.use(express.json()); + return app; +} + +async function installActor(app: express.Express, actor?: Record) { + const [{ issueRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/issues.js"), + import("../middleware/index.js"), + ]); + + app.use((req, _res, next) => { + (req as any).actor = actor ?? { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +function makeIssue() { + return { + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + status: "in_progress", + assigneeAgentId: "22222222-2222-4222-8222-222222222222", + assigneeUserId: null, + executionRunId: "run-1", + identifier: "PAP-1353", + title: "Queued cancel", + }; +} + +function makeComment(overrides: Record = {}) { + return { + id: "comment-1", + companyId: "company-1", + issueId: "11111111-1111-4111-8111-111111111111", + authorAgentId: null, + authorUserId: "local-board", + body: "Queued follow-up", + createdAt: new Date("2026-04-11T15:01:00.000Z"), + updatedAt: new Date("2026-04-11T15:01:00.000Z"), + ...overrides, + }; +} + +describe("issue comment cancel routes", () => { + beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + mockIssueService.getById.mockResolvedValue(makeIssue()); + mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); + mockIssueService.getComment.mockResolvedValue(makeComment()); + mockIssueService.removeComment.mockResolvedValue(makeComment()); + mockAccessService.canUser.mockResolvedValue(false); + mockAccessService.hasPermission.mockResolvedValue(false); + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "running", + startedAt: new Date("2026-04-11T15:00:00.000Z"), + createdAt: new Date("2026-04-11T14:59:00.000Z"), + }); + mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); + mockLogActivity.mockResolvedValue(undefined); + }); + + it("cancels a queued comment from its author and restores the deleted body", async () => { + const res = await request(await installActor(createApp())) + .delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: "comment-1", + body: "Queued follow-up", + }); + expect(mockIssueService.removeComment).toHaveBeenCalledWith("comment-1"); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.comment_cancelled", + details: expect.objectContaining({ + commentId: "comment-1", + source: "queue_cancel", + queueTargetRunId: "run-1", + }), + }), + ); + }); + + it("rejects canceling comments that are no longer queued", async () => { + mockIssueService.getComment.mockResolvedValue( + makeComment({ + createdAt: new Date("2026-04-11T14:58:00.000Z"), + updatedAt: new Date("2026-04-11T14:58:00.000Z"), + }), + ); + + const res = await request(await installActor(createApp())) + .delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1"); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("Only queued comments can be canceled"); + expect(mockIssueService.removeComment).not.toHaveBeenCalled(); + }); + + it("rejects canceling another actor's queued comment", async () => { + mockIssueService.getComment.mockResolvedValue( + makeComment({ + authorUserId: "someone-else", + }), + ); + + const res = await request(await installActor(createApp())) + .delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1"); + + expect(res.status).toBe(403); + expect(res.body.error).toBe("Only the comment author can cancel queued comments"); + expect(mockIssueService.removeComment).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 56ad4bd0fae..5516da68c0b 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -27,6 +27,7 @@ const mockHeartbeatService = vi.hoisted(() => ({ const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), + resolveByReference: vi.fn(), })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); @@ -86,6 +87,34 @@ vi.mock("../services/index.js", () => ({ workProductService: () => ({}), })); +function registerModuleMocks() { + vi.doMock("@paperclipai/shared/telemetry", () => ({ + trackAgentTaskCompleted: vi.fn(), + trackErrorHandlerCrash: vi.fn(), + })); + + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), + })); + + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => mockFeedbackService, + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => mockRoutineService, + workProductService: () => ({}), + })); +} + function createApp() { const app = express(); app.use(express.json()); @@ -94,8 +123,8 @@ function createApp() { async function installActor(app: express.Express, actor?: Record) { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/issues.js"), - import("../middleware/index.js"), + vi.importActual("../routes/issues.js"), + vi.importActual("../middleware/index.js"), ]); app.use((req, _res, next) => { (req as any).actor = actor ?? { @@ -123,7 +152,7 @@ async function normalizePolicy(input: { return normalizeIssueExecutionPolicy(input); } -function makeIssue(status: "todo" | "done") { +function makeIssue(status: "todo" | "done" | "blocked") { return { id: "11111111-1111-4111-8111-111111111111", companyId: "company-1", @@ -139,6 +168,10 @@ function makeIssue(status: "todo" | "done") { describe("issue comment reopen routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); mockIssueService.getById.mockReset(); mockIssueService.assertCheckoutOwner.mockReset(); @@ -155,6 +188,7 @@ describe("issue comment reopen routes", () => { mockHeartbeatService.getActiveRunForAgent.mockReset(); mockHeartbeatService.cancelRun.mockReset(); mockAgentService.getById.mockReset(); + mockAgentService.resolveByReference.mockReset(); mockLogActivity.mockReset(); mockFeedbackService.listIssueVotesForUser.mockReset(); mockFeedbackService.saveIssueVote.mockReset(); @@ -205,6 +239,12 @@ describe("issue comment reopen routes", () => { mockAccessService.canUser.mockResolvedValue(false); mockAccessService.hasPermission.mockResolvedValue(false); mockAgentService.getById.mockResolvedValue(null); + mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, reference: string) => ({ + ambiguous: false, + agent: { + id: reference, + }, + })); }); it("treats reopen=true as a no-op when the issue is already open", async () => { @@ -229,6 +269,96 @@ describe("issue comment reopen routes", () => { ); }); + it("implicitly reopens closed issues via the PATCH comment path when reassigning to an agent", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("done")); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("done"), + ...patch, + })); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "hello", assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + assigneeAgentId: "33333333-3333-4333-8333-333333333333", + status: "todo", + actorAgentId: null, + actorUserId: "local-board", + }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.updated", + details: expect.objectContaining({ + reopened: true, + reopenedFrom: "done", + status: "todo", + }), + }), + ); + }); + + it("resolves assignee shortnames before updating an issue", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("todo")); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("todo"), + ...patch, + })); + mockAgentService.resolveByReference.mockResolvedValue({ + ambiguous: false, + agent: { id: "33333333-3333-4333-8333-333333333333" }, + }); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "hello", assigneeAgentId: "codexcoder" }); + + expect(res.status).toBe(200); + expect(mockAgentService.resolveByReference).toHaveBeenCalledWith("company-1", "codexcoder"); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + assigneeAgentId: "33333333-3333-4333-8333-333333333333", + }), + ); + }); + + it("rejects ambiguous assignee shortnames", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("todo")); + mockAgentService.resolveByReference.mockResolvedValue({ + ambiguous: true, + agent: null, + }); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ assigneeAgentId: "codexcoder" }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("ambiguous"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); + + it("rejects missing assignee shortnames", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("todo")); + mockAgentService.resolveByReference.mockResolvedValue({ + ambiguous: false, + agent: null, + }); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ assigneeAgentId: "codexcoder" }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Agent not found"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); it("reopens closed issues via the PATCH comment path", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("done")); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ @@ -263,6 +393,75 @@ describe("issue comment reopen routes", () => { ); }); + it("implicitly reopens closed issues via POST comments when an agent is assigned", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("done")); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("done"), + ...patch, + })); + + const res = await request(await installActor(createApp())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "hello" }); + + expect(res.status).toBe(201); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + { status: "todo" }, + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + reason: "issue_reopened_via_comment", + payload: expect.objectContaining({ + reopenedFrom: "done", + }), + }), + ); + }); + + it("does not implicitly reopen closed issues via POST comments when no agent is assigned", async () => { + mockIssueService.getById.mockResolvedValue({ + ...makeIssue("done"), + assigneeAgentId: null, + assigneeUserId: "local-board", + }); + + const res = await request(await installActor(createApp())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "hello" }); + + expect(res.status).toBe(201); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); + + it("wakes the assignee when an assigned blocked issue moves back to todo", async () => { + const issue = makeIssue("blocked"); + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + updatedAt: new Date(), + })); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ status: "todo" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + source: "automation", + triggerDetail: "system", + reason: "issue_status_changed", + payload: expect.objectContaining({ + issueId: "11111111-1111-4111-8111-111111111111", + mutation: "update", + }), + }), + ); + }); it("interrupts an active run before a combined comment update", async () => { const issue = { ...makeIssue("todo"), diff --git a/server/src/__tests__/issue-continuation-summary.test.ts b/server/src/__tests__/issue-continuation-summary.test.ts new file mode 100644 index 00000000000..c5ccd0a5746 --- /dev/null +++ b/server/src/__tests__/issue-continuation-summary.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS, + buildContinuationSummaryMarkdown, +} from "../services/issue-continuation-summary.js"; + +describe("issue continuation summaries", () => { + it("builds bounded issue-local handoff context with required sections", () => { + const body = buildContinuationSummaryMarkdown({ + issue: { + id: "issue-1", + identifier: "PAP-1579", + title: "Add continuation summaries", + description: [ + "## Objective", + "", + "Keep work resumable after adapter session reset.", + "", + "## Acceptance Criteria", + "", + "- Summary is issue-local", + "- Wake context includes the summary", + ].join("\n"), + status: "in_progress", + priority: "medium", + }, + run: { + id: "run-1", + status: "succeeded", + error: null, + resultJson: { + summary: "Updated server/src/services/heartbeat.ts and packages/adapter-utils/src/server-utils.ts.", + }, + stdoutExcerpt: null, + stderrExcerpt: null, + finishedAt: new Date("2026-04-18T12:00:00.000Z"), + }, + agent: { + id: "agent-1", + name: "CodexCoder", + adapterType: "codex_local", + }, + }); + + expect(body).toContain("# Continuation Summary"); + expect(body).toContain("## Objective"); + expect(body).toContain("Keep work resumable after adapter session reset."); + expect(body).toContain("## Acceptance Criteria"); + expect(body).toContain("- Summary is issue-local"); + expect(body).toContain("## Recent Concrete Actions"); + expect(body).toContain("Run `run-1` finished with status `succeeded`"); + expect(body).toContain("`server/src/services/heartbeat.ts`"); + expect(body).toContain("## Commands Run"); + expect(body).toContain("## Blockers / Decisions"); + expect(body).toContain("## Next Action"); + expect(body.length).toBeLessThanOrEqual(ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS); + }); + + it("uses failure state to point the next run at the error", () => { + const body = buildContinuationSummaryMarkdown({ + issue: { + id: "issue-1", + identifier: "PAP-1579", + title: "Add continuation summaries", + description: null, + status: "in_progress", + priority: "medium", + }, + run: { + id: "run-2", + status: "failed", + error: "adapter failed", + errorCode: "adapter_failed", + resultJson: null, + }, + agent: { + id: "agent-1", + name: "CodexCoder", + adapterType: "codex_local", + }, + }); + + expect(body).toContain("Latest run error (adapter_failed): adapter failed"); + expect(body).toContain("Inspect the failed run, fix the cause"); + }); +}); diff --git a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts index b515681ca22..382a1bf2035 100644 --- a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts +++ b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts @@ -1,7 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { issueRoutes } from "../routes/issues.js"; const mockWakeup = vi.hoisted(() => vi.fn(async () => undefined)); const mockIssueService = vi.hoisted(() => ({ @@ -40,6 +39,7 @@ vi.mock("../services/index.js", () => ({ wakeup: mockWakeup, reportRunActivity: vi.fn(async () => undefined), }), + getIssueContinuationSummaryDocument: vi.fn(async () => null), instanceSettingsService: () => ({ get: vi.fn(), listCompanyIds: vi.fn(), @@ -63,7 +63,11 @@ vi.mock("../services/index.js", () => ({ }), })); -function createApp() { +async function createApp() { + const [{ issueRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/issues.js"), + vi.importActual("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -77,15 +81,17 @@ function createApp() { next(); }); app.use("/api", issueRoutes({} as any, {} as any)); - app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - res.status(err?.status ?? 500).json({ error: err?.message ?? "Internal server error" }); - }); + app.use(errorHandler); return app; } describe("issue dependency wakeups in issue routes", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetModules(); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + vi.resetAllMocks(); mockIssueService.getAncestors.mockResolvedValue([]); mockIssueService.getComment.mockResolvedValue(null); mockIssueService.getCommentCursor.mockResolvedValue({ @@ -141,20 +147,20 @@ describe("issue dependency wakeups in issue routes", () => { }, ]); - const res = await request(createApp()).patch("/api/issues/issue-1").send({ status: "done" }); - await new Promise((resolve) => setTimeout(resolve, 0)); - + const res = await request(await createApp()).patch("/api/issues/issue-1").send({ status: "done" }); expect(res.status).toBe(200); - expect(mockWakeup).toHaveBeenCalledWith( - "agent-2", - expect.objectContaining({ - reason: "issue_blockers_resolved", - payload: expect.objectContaining({ - issueId: "issue-2", - resolvedBlockerIssueId: "issue-1", + await vi.waitFor(() => { + expect(mockWakeup).toHaveBeenCalledWith( + "agent-2", + expect.objectContaining({ + reason: "issue_blockers_resolved", + payload: expect.objectContaining({ + issueId: "issue-2", + resolvedBlockerIssueId: "issue-1", + }), }), - }), - ); + ); + }); }); it("wakes the parent when all direct children become terminal", async () => { @@ -196,21 +202,54 @@ describe("issue dependency wakeups in issue routes", () => { id: "parent-1", assigneeAgentId: "agent-9", childIssueIds: ["child-0", "child-1"], + childIssueSummaries: [ + { + id: "child-0", + identifier: "PAP-100", + title: "First child", + status: "done", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + updatedAt: new Date("2026-04-18T12:00:00.000Z"), + summary: "First child finished.", + }, + { + id: "child-1", + identifier: "PAP-101", + title: "Last child", + status: "done", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + updatedAt: new Date("2026-04-18T12:05:00.000Z"), + summary: "Last child finished.", + }, + ], + childIssueSummaryTruncated: false, }); - const res = await request(createApp()).patch("/api/issues/child-1").send({ status: "done" }); - await new Promise((resolve) => setTimeout(resolve, 0)); - + const res = await request(await createApp()).patch("/api/issues/child-1").send({ status: "done" }); expect(res.status).toBe(200); - expect(mockWakeup).toHaveBeenCalledWith( - "agent-9", - expect.objectContaining({ - reason: "issue_children_completed", - payload: expect.objectContaining({ - issueId: "parent-1", - completedChildIssueId: "child-1", + await vi.waitFor(() => { + expect(mockWakeup).toHaveBeenCalledWith( + "agent-9", + expect.objectContaining({ + reason: "issue_children_completed", + payload: expect.objectContaining({ + issueId: "parent-1", + completedChildIssueId: "child-1", + childIssueSummaries: expect.arrayContaining([ + expect.objectContaining({ identifier: "PAP-101", summary: "Last child finished." }), + ]), + }), + contextSnapshot: expect.objectContaining({ + childIssueSummaries: expect.arrayContaining([ + expect.objectContaining({ identifier: "PAP-100", summary: "First child finished." }), + ]), + }), }), - }), - ); + ); + }); }); }); diff --git a/server/src/__tests__/issue-document-restore-routes.test.ts b/server/src/__tests__/issue-document-restore-routes.test.ts index 2a09cf3bdab..96fc2b85f65 100644 --- a/server/src/__tests__/issue-document-restore-routes.test.ts +++ b/server/src/__tests__/issue-document-restore-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { issueRoutes } from "../routes/issues.js"; -import { errorHandler } from "../middleware/index.js"; const issueId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; @@ -12,6 +10,7 @@ const mockIssueService = vi.hoisted(() => ({ })); const mockDocumentsService = vi.hoisted(() => ({ + listIssueDocuments: vi.fn(), listIssueDocumentRevisions: vi.fn(), restoreIssueDocumentRevision: vi.fn(), })); @@ -56,7 +55,42 @@ vi.mock("../services/index.js", () => ({ workProductService: () => ({}), })); -function createApp() { +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + documentService: () => mockDocumentsService, + executionWorkspaceService: () => ({}), + feedbackService: () => ({}), + goalService: () => ({}), + heartbeatService: () => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + }), + instanceSettingsService: () => ({ + getExperimental: vi.fn(async () => ({})), + getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })), + }), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + memoryService: () => ({ + captureIssueComment: vi.fn(async () => undefined), + captureIssueDocument: vi.fn(async () => undefined), + }), + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), + })); +} + +async function createApp() { + const [{ issueRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/issues.js"), + vi.importActual("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -76,6 +110,11 @@ function createApp() { describe("issue document revision routes", () => { beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/routines.js"); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); mockIssueService.getById.mockResolvedValue({ id: issueId, @@ -84,6 +123,25 @@ describe("issue document revision routes", () => { title: "Document revisions", status: "in_progress", }); + mockDocumentsService.listIssueDocuments.mockResolvedValue([ + { + id: "document-1", + companyId, + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "# Plan", + latestRevisionId: "revision-2", + latestRevisionNumber: 2, + createdByAgentId: null, + createdByUserId: "board-user", + updatedByAgentId: null, + updatedByUserId: "board-user", + createdAt: new Date("2026-03-26T12:00:00.000Z"), + updatedAt: new Date("2026-03-26T12:10:00.000Z"), + }, + ]); mockDocumentsService.listIssueDocumentRevisions.mockResolvedValue([ { id: "revision-2", @@ -126,7 +184,7 @@ describe("issue document revision routes", () => { }); it("returns revision snapshots including title and format", async () => { - const res = await request(createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`); + const res = await request(await createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`); expect(res.status).toBe(200); expect(res.body).toEqual([ @@ -139,8 +197,22 @@ describe("issue document revision routes", () => { ]); }); + it("filters system documents by default on the document list route", async () => { + const res = await request(await createApp()).get(`/api/issues/${issueId}/documents`); + + expect(res.status).toBe(200); + expect(mockDocumentsService.listIssueDocuments).toHaveBeenCalledWith(issueId, { includeSystem: false }); + expect(res.body).toEqual([expect.objectContaining({ key: "plan" })]); + }); + + it("passes includeSystem=true through for debug document listing", async () => { + await request(await createApp()).get(`/api/issues/${issueId}/documents?includeSystem=true`); + + expect(mockDocumentsService.listIssueDocuments).toHaveBeenCalledWith(issueId, { includeSystem: true }); + }); + it("restores a revision through the append-only route and logs the action", async () => { - const res = await request(createApp()) + const res = await request(await createApp()) .post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`) .send({}); @@ -172,8 +244,8 @@ describe("issue document revision routes", () => { }); it("rejects invalid document keys before attempting restore", async () => { - const res = await request(createApp()) - .post(`/api/issues/${issueId}/documents/INVALID.KEY/revisions/revision-1/restore`) + const res = await request(await createApp()) + .post(`/api/issues/${issueId}/documents/INVALID KEY/revisions/revision-1/restore`) .send({}); expect(res.status).toBe(400); diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index 7973ba14bc6..c406d38e3a9 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorHandler } from "../middleware/index.js"; -import { issueRoutes } from "../routes/issues.js"; import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts"; const mockIssueService = vi.hoisted(() => ({ @@ -24,47 +22,53 @@ const mockHeartbeatService = vi.hoisted(() => ({ cancelRun: vi.fn(async () => null), })); -vi.mock("../services/index.js", () => ({ - accessService: () => ({ - canUser: vi.fn(async () => false), - hasPermission: vi.fn(async () => false), - }), - agentService: () => ({ - getById: vi.fn(async () => null), - }), - documentService: () => ({}), - executionWorkspaceService: () => ({}), - feedbackService: () => ({ - listIssueVotesForUser: vi.fn(async () => []), - saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), - }), - goalService: () => ({}), - heartbeatService: () => mockHeartbeatService, - instanceSettingsService: () => ({ - get: vi.fn(async () => ({ - id: "instance-settings-1", - general: { - censorUsernameInLogs: false, - feedbackDataSharingPreference: "prompt", - }, - })), - listCompanyIds: vi.fn(async () => ["company-1"]), - }), - issueApprovalService: () => ({}), - issueService: () => mockIssueService, - logActivity: vi.fn(async () => undefined), - memoryService: () => ({ - captureIssueComment: vi.fn(async () => undefined), - captureIssueDocument: vi.fn(async () => undefined), - }), - projectService: () => ({}), - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({}), -})); +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(async () => false), + hasPermission: vi.fn(async () => false), + }), + agentService: () => ({ + getById: vi.fn(async () => null), + }), + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: vi.fn(async () => undefined), + memoryService: () => ({ + captureIssueComment: vi.fn(async () => undefined), + captureIssueDocument: vi.fn(async () => undefined), + }), + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), + })); +} -function createApp() { +async function createApp() { + const [{ errorHandler }, { issueRoutes }] = await Promise.all([ + import("../middleware/index.js"), + import("../routes/issues.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -84,6 +88,11 @@ function createApp() { describe("issue execution policy routes", () => { beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.clearAllMocks(); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockIssueService.findMentionedAgents.mockResolvedValue([]); @@ -121,7 +130,7 @@ describe("issue execution policy routes", () => { updatedAt: new Date(), })); - const res = await request(createApp()) + const res = await request(await createApp()) .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") .send({ executionPolicy: policy }); diff --git a/server/src/__tests__/issue-execution-policy.test.ts b/server/src/__tests__/issue-execution-policy.test.ts index 3d8a649b7d4..6aeac5540fc 100644 --- a/server/src/__tests__/issue-execution-policy.test.ts +++ b/server/src/__tests__/issue-execution-policy.test.ts @@ -875,6 +875,83 @@ describe("issue execution policy transitions", () => { // coderAgentId is the returnAssignee, so QA should be selected expect(result.patch.assigneeAgentId).toBe(qaAgentId); }); + + it("skips a self-review-only stage and completes the workflow", () => { + const policy = makePolicy([ + { + type: "review", + participants: [{ type: "agent", agentId: coderAgentId }], + }, + ]); + + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_progress", + assigneeAgentId: coderAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: null, + }, + policy, + requestedStatus: "done", + requestedAssigneePatch: {}, + actor: { agentId: coderAgentId }, + commentBody: "Done", + }); + + expect(result.patch).toMatchObject({ + executionState: { + status: "completed", + currentStageType: null, + currentParticipant: null, + returnAssignee: { type: "agent", agentId: coderAgentId }, + completedStageIds: [policy.stages[0].id], + }, + }); + expect(result.patch.status).toBeUndefined(); + expect(result.patch.assigneeAgentId).toBeUndefined(); + }); + + it("skips a self-review-only review stage and advances to approval", () => { + const policy = makePolicy([ + { + type: "review", + participants: [{ type: "agent", agentId: coderAgentId }], + }, + { + type: "approval", + participants: [{ type: "user", userId: ctoUserId }], + }, + ]); + + const result = applyIssueExecutionPolicyTransition({ + issue: { + status: "in_progress", + assigneeAgentId: coderAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: null, + }, + policy, + requestedStatus: "done", + requestedAssigneePatch: {}, + actor: { agentId: coderAgentId }, + commentBody: "Done", + }); + + expect(result.patch).toMatchObject({ + status: "in_review", + assigneeAgentId: null, + assigneeUserId: ctoUserId, + executionState: { + status: "pending", + currentStageType: "approval", + currentParticipant: { type: "user", userId: ctoUserId }, + returnAssignee: { type: "agent", agentId: coderAgentId }, + completedStageIds: [policy.stages[0].id], + }, + }); + }); }); describe("changes requested with no return assignee", () => { diff --git a/server/src/__tests__/issue-feedback-routes.test.ts b/server/src/__tests__/issue-feedback-routes.test.ts index 58551fff64f..d5082e74899 100644 --- a/server/src/__tests__/issue-feedback-routes.test.ts +++ b/server/src/__tests__/issue-feedback-routes.test.ts @@ -50,35 +50,37 @@ const mockRoutineService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); -vi.mock("@paperclipai/shared/telemetry", () => ({ - trackAgentTaskCompleted: vi.fn(), - trackErrorHandlerCrash: vi.fn(), -})); +function registerModuleMocks() { + vi.doMock("@paperclipai/shared/telemetry", () => ({ + trackAgentTaskCompleted: vi.fn(), + trackErrorHandlerCrash: vi.fn(), + })); -vi.mock("../telemetry.js", () => ({ - getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), -})); + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), + })); -vi.mock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - documentService: () => ({}), - executionWorkspaceService: () => ({}), - feedbackService: () => mockFeedbackService, - goalService: () => ({}), - heartbeatService: () => mockHeartbeatService, - instanceSettingsService: () => mockInstanceSettingsService, - issueApprovalService: () => ({}), - issueService: () => mockIssueService, - logActivity: mockLogActivity, - memoryService: () => ({ - captureIssueComment: vi.fn(async () => undefined), - captureIssueDocument: vi.fn(async () => undefined), - }), - projectService: () => ({}), - routineService: () => mockRoutineService, - workProductService: () => ({}), -})); + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => mockFeedbackService, + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + memoryService: () => ({ + captureIssueComment: vi.fn(async () => undefined), + captureIssueDocument: vi.fn(async () => undefined), + }), + projectService: () => ({}), + routineService: () => mockRoutineService, + workProductService: () => ({}), + })); +} async function createApp(actor: Record) { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ @@ -99,7 +101,13 @@ async function createApp(actor: Record) { describe("issue feedback trace routes", () => { beforeEach(() => { vi.resetModules(); - vi.resetAllMocks(); + vi.doUnmock("@paperclipai/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.clearAllMocks(); mockFeedbackExportService.flushPendingFeedbackTraces.mockResolvedValue({ attempted: 1, sent: 1, @@ -176,7 +184,6 @@ describe("issue feedback trace routes", () => { }); const res = await request(app).get("/api/feedback-traces/trace-1"); - expect(res.status).toBe(403); expect(mockFeedbackService.getFeedbackTraceById).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/issue-telemetry-routes.test.ts b/server/src/__tests__/issue-telemetry-routes.test.ts index f8d5e92677a..2b094cd20ff 100644 --- a/server/src/__tests__/issue-telemetry-routes.test.ts +++ b/server/src/__tests__/issue-telemetry-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorHandler } from "../middleware/index.js"; -import { issueRoutes } from "../routes/issues.js"; const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), @@ -18,43 +16,45 @@ const mockAgentService = vi.hoisted(() => ({ const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); -vi.mock("@paperclipai/shared/telemetry", () => ({ - trackAgentTaskCompleted: mockTrackAgentTaskCompleted, - trackErrorHandlerCrash: vi.fn(), -})); +function registerModuleMocks() { + vi.doMock("@paperclipai/shared/telemetry", () => ({ + trackAgentTaskCompleted: mockTrackAgentTaskCompleted, + trackErrorHandlerCrash: vi.fn(), + })); -vi.mock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, -})); + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, + })); -vi.mock("../services/index.js", () => ({ - accessService: () => ({ - canUser: vi.fn(), - hasPermission: vi.fn(), - }), - agentService: () => mockAgentService, - documentService: () => ({}), - executionWorkspaceService: () => ({}), - feedbackService: () => ({}), - goalService: () => ({}), - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - }), - instanceSettingsService: () => ({}), - issueApprovalService: () => ({}), - issueService: () => mockIssueService, - logActivity: vi.fn(async () => undefined), - memoryService: () => ({ - captureIssueComment: vi.fn(async () => undefined), - captureIssueDocument: vi.fn(async () => undefined), - }), - projectService: () => ({}), - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({}), -})); + vi.doMock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => mockAgentService, + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({}), + goalService: () => ({}), + heartbeatService: () => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + }), + instanceSettingsService: () => ({}), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: vi.fn(async () => undefined), + memoryService: () => ({ + captureIssueComment: vi.fn(async () => undefined), + captureIssueDocument: vi.fn(async () => undefined), + }), + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), + })); +} function makeIssue(status: "todo" | "done") { return { @@ -69,7 +69,11 @@ function makeIssue(status: "todo" | "done") { }; } -function createApp(actor: Record) { +async function createApp(actor: Record) { + const [{ errorHandler }, { issueRoutes }] = await Promise.all([ + vi.importActual("../middleware/index.js"), + vi.importActual("../routes/issues.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -83,6 +87,14 @@ function createApp(actor: Record) { describe("issue telemetry routes", () => { beforeEach(() => { + vi.resetModules(); + vi.doUnmock("@paperclipai/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockIssueService.getById.mockResolvedValue(makeIssue("todo")); @@ -94,15 +106,16 @@ describe("issue telemetry routes", () => { })); }); - it("emits task-completed telemetry with the agent role", async () => { + it("emits task-completed telemetry with the agent role, adapter type, and model", async () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1", role: "engineer", adapterType: "codex_local", + adapterConfig: { model: "claude-sonnet-4-6" }, }); - const app = createApp({ + const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "company-1", @@ -116,12 +129,15 @@ describe("issue telemetry routes", () => { await vi.waitFor(() => { expect(mockTrackAgentTaskCompleted).toHaveBeenCalledWith(expect.anything(), { agentRole: "engineer", + agentId: "agent-1", + adapterType: "codex_local", + model: "claude-sonnet-4-6", }); }); }, 10_000); it("does not emit agent task-completed telemetry for board-driven completions", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "local-board", companyIds: ["company-1"], diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index d477010eebb..2e33adaadd7 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorHandler } from "../middleware/index.js"; -import { issueRoutes } from "../routes/issues.js"; const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111"; @@ -31,6 +29,10 @@ vi.mock("../services/index.js", () => ({ }), agentService: () => ({ getById: vi.fn(async () => null), + resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({ + ambiguous: false, + agent: { id: raw }, + })), }), documentService: () => ({}), executionWorkspaceService: () => ({}), @@ -64,7 +66,53 @@ vi.mock("../services/index.js", () => ({ workProductService: () => ({}), })); -function createApp() { +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(async () => true), + hasPermission: vi.fn(async () => true), + }), + agentService: () => ({ + getById: vi.fn(async () => null), + resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({ + ambiguous: false, + agent: { id: raw }, + })), + }), + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: vi.fn(async () => undefined), + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), + })); +} + +async function createApp() { + const [{ errorHandler }, { issueRoutes }] = await Promise.all([ + vi.importActual("../middleware/index.js"), + vi.importActual("../routes/issues.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -105,7 +153,12 @@ function makeIssue(overrides: Record = {}) { describe("issue update comment wakeups", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetModules(); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.resetAllMocks(); mockIssueService.findMentionedAgents.mockResolvedValue([]); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); @@ -127,7 +180,7 @@ describe("issue update comment wakeups", () => { body: "write the whole thing", }); - const res = await request(createApp()) + const res = await request(await createApp()) .patch(`/api/issues/${existing.id}`) .send({ assigneeAgentId: ASSIGNEE_AGENT_ID, @@ -174,7 +227,7 @@ describe("issue update comment wakeups", () => { body: "please revise this", }); - const res = await request(createApp()) + const res = await request(await createApp()) .patch(`/api/issues/${existing.id}`) .send({ comment: "please revise this", diff --git a/server/src/__tests__/issue-workspace-command-authz.test.ts b/server/src/__tests__/issue-workspace-command-authz.test.ts new file mode 100644 index 00000000000..cf9a7847b01 --- /dev/null +++ b/server/src/__tests__/issue-workspace-command-authz.test.ts @@ -0,0 +1,165 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; + +const mockIssueService = vi.hoisted(() => ({ + addComment: vi.fn(), + assertCheckoutOwner: vi.fn(), + create: vi.fn(), + findMentionedAgents: vi.fn(), + getByIdentifier: vi.fn(), + getById: vi.fn(), + getRelationSummaries: vi.fn(), + getWakeableParentAfterChildCompletion: vi.fn(), + listWakeableBlockedDependents: vi.fn(), + update: vi.fn(), +})); + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(async () => true), + hasPermission: vi.fn(async () => true), + }), + agentService: () => ({ + getById: vi.fn(async () => null), + }), + documentService: () => ({}), + executionWorkspaceService: () => ({ + getById: vi.fn(async () => null), + }), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => ({}), + heartbeatService: () => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), + }), + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: vi.fn(async () => undefined), + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), +})); + +function createApp(actor: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +function makeIssue(overrides: Record = {}) { + return { + id: "issue-1", + companyId: "company-1", + status: "todo", + priority: "medium", + projectId: null, + goalId: null, + parentId: null, + assigneeAgentId: null, + assigneeUserId: null, + createdByUserId: "board-user", + identifier: "PAP-1000", + title: "Workspace authz", + executionPolicy: null, + executionState: null, + executionWorkspaceId: null, + hiddenAt: null, + ...overrides, + }; +} + +describe("issue workspace command authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIssueService.addComment.mockResolvedValue(null); + mockIssueService.create.mockResolvedValue(makeIssue()); + mockIssueService.findMentionedAgents.mockResolvedValue([]); + mockIssueService.getByIdentifier.mockResolvedValue(null); + mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); + mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); + mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); + mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); + mockIssueService.update.mockResolvedValue(makeIssue()); + }); + + it("rejects agent callers that create issue workspace provision commands", async () => { + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/company-1/issues") + .send({ + title: "Exploit", + executionWorkspaceSettings: { + workspaceStrategy: { + type: "git_worktree", + provisionCommand: "touch /tmp/paperclip-rce", + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockIssueService.create).not.toHaveBeenCalled(); + }); + + it("rejects agent callers that patch assignee adapter workspace teardown commands", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue()); + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch("/api/issues/issue-1") + .send({ + assigneeAdapterOverrides: { + adapterConfig: { + workspaceStrategy: { + type: "git_worktree", + teardownCommand: "rm -rf /tmp/paperclip-rce", + }, + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index 20c309ca8ce..1814299165c 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { issueRoutes } from "../routes/issues.js"; -import { errorHandler } from "../middleware/index.js"; const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), @@ -24,6 +22,11 @@ const mockGoalService = vi.hoisted(() => ({ getDefaultCompanyGoal: vi.fn(), })); +const mockDocumentsService = vi.hoisted(() => ({ + getIssueDocumentPayload: vi.fn(), + getIssueDocumentByKey: vi.fn(), +})); + vi.mock("../services/index.js", () => ({ accessService: () => ({ canUser: vi.fn(), @@ -32,9 +35,7 @@ vi.mock("../services/index.js", () => ({ agentService: () => ({ getById: vi.fn(), }), - documentService: () => ({ - getIssueDocumentPayload: vi.fn(async () => ({})), - }), + documentService: () => mockDocumentsService, executionWorkspaceService: () => ({ getById: vi.fn(), }), @@ -73,7 +74,11 @@ vi.mock("../services/index.js", () => ({ }), })); -function createApp() { +async function createApp() { + const [{ issueRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/issues.js"), + vi.importActual("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -125,7 +130,11 @@ const projectGoal = { describe("issue goal context routes", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetModules(); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + vi.resetAllMocks(); mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue); mockIssueService.getAncestors.mockResolvedValue([]); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); @@ -137,6 +146,8 @@ describe("issue goal context routes", () => { }); mockIssueService.getComment.mockResolvedValue(null); mockIssueService.listAttachments.mockResolvedValue([]); + mockDocumentsService.getIssueDocumentPayload.mockResolvedValue({}); + mockDocumentsService.getIssueDocumentByKey.mockResolvedValue(null); mockProjectService.getById.mockResolvedValue({ id: legacyProjectLinkedIssue.projectId, companyId: "company-1", @@ -178,7 +189,7 @@ describe("issue goal context routes", () => { }); it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => { - const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111"); + const res = await request(await createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111"); expect(res.status).toBe(200); expect(res.body.goalId).toBe(projectGoal.id); @@ -188,11 +199,15 @@ describe("issue goal context routes", () => { title: projectGoal.title, }), ); + expect(mockIssueService.findMentionedProjectIds).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + { includeCommentBodies: false }, + ); expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled(); }); it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => { - const res = await request(createApp()).get( + const res = await request(await createApp()).get( "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", ); @@ -208,6 +223,31 @@ describe("issue goal context routes", () => { expect(res.body.attachments).toEqual([]); }); + it("preserves direct continuation summary lookup in GET /issues/:id/heartbeat-context", async () => { + mockDocumentsService.getIssueDocumentByKey.mockResolvedValue({ + key: "continuation-summary", + title: "Continuation Summary", + body: "# Handoff", + latestRevisionId: "revision-1", + latestRevisionNumber: 1, + updatedAt: new Date("2026-04-19T12:00:00.000Z"), + }); + + const res = await request(await createApp()).get( + "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", + ); + + expect(res.status).toBe(200); + expect(mockDocumentsService.getIssueDocumentByKey).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + "continuation-summary", + ); + expect(res.body.continuationSummary).toEqual(expect.objectContaining({ + key: "continuation-summary", + body: "# Handoff", + })); + }); + it("surfaces blocker summaries on GET /issues/:id/heartbeat-context", async () => { mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [ @@ -224,7 +264,7 @@ describe("issue goal context routes", () => { blocks: [], }); - const res = await request(createApp()).get( + const res = await request(await createApp()).get( "/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context", ); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 0629686a154..b778db6834b 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -8,6 +8,7 @@ import { companies, createDb, executionWorkspaces, + goals, instanceSettings, issueComments, issueInboxArchives, @@ -21,11 +22,20 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { instanceSettingsService } from "../services/instance-settings.ts"; -import { issueService } from "../services/issues.ts"; +import { clampIssueListLimit, ISSUE_LIST_MAX_LIMIT, issueService } from "../services/issues.ts"; +import { buildProjectMentionHref } from "@paperclipai/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +describe("issue list limit helpers", () => { + it("clamps untrusted issue-list limits to the server maximum", () => { + expect(clampIssueListLimit(0)).toBe(1); + expect(clampIssueListLimit(25.9)).toBe(25); + expect(clampIssueListLimit(ISSUE_LIST_MAX_LIMIT + 10)).toBe(ISSUE_LIST_MAX_LIMIT); + }); +}); + async function ensureIssueRelationsTable(db: ReturnType) { await db.execute(sql.raw(` CREATE TABLE IF NOT EXISTS "issue_relations" ( @@ -69,6 +79,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects); + await db.delete(goals); await db.delete(agents); await db.delete(instanceSettings); await db.delete(companies); @@ -378,7 +389,6 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { it("returns null instead of throwing for malformed non-uuid issue refs", async () => { await expect(svc.getById("not-a-uuid")).resolves.toBeNull(); }); - it("filters issues by execution workspace id", async () => { const companyId = randomUUID(); const projectId = randomUUID(); @@ -697,6 +707,627 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { "2026-03-26T10:00:00.000Z", ); }); + + it("trims list payload fields that can grow large on issue index routes", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const longDescription = "x".repeat(5_000); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Large issue", + description: longDescription, + status: "todo", + priority: "medium", + executionPolicy: { stages: Array.from({ length: 20 }, (_, index) => ({ index, kind: "review", notes: "y".repeat(400) })) }, + executionState: { history: Array.from({ length: 20 }, (_, index) => ({ index, body: "z".repeat(400) })) }, + executionWorkspaceSettings: { notes: "w".repeat(2_000) }, + }); + + const [result] = await svc.list(companyId); + + expect(result).toBeTruthy(); + expect(result?.description).toHaveLength(1200); + expect(result?.executionPolicy).toBeNull(); + expect(result?.executionState).toBeNull(); + expect(result?.executionWorkspaceSettings).toBeNull(); + }); +}); + +describeEmbeddedPostgres("issueService.create workspace inheritance", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-"); + db = createDb(tempDb.connectionString); + svc = issueService(db); + await ensureIssueRelationsTable(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(issueRelations); + await db.delete(issueInboxArchives); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(goals); + await db.delete(agents); + await db.delete(instanceSettings); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const parentIssueId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + isPrimary: true, + sharedWorkspaceKey: "workspace-key", + }); + + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Issue worktree", + status: "active", + providerType: "git_worktree", + providerRef: `/tmp/${executionWorkspaceId}`, + }); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + workspaceRuntime: { profile: "agent" }, + }, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + projectId, + title: "Child issue", + }); + + expect(child.parentId).toBe(parentIssueId); + expect(child.projectWorkspaceId).toBe(projectWorkspaceId); + expect(child.executionWorkspaceId).toBe(executionWorkspaceId); + expect(child.executionWorkspacePreference).toBe("reuse_existing"); + expect(child.executionWorkspaceSettings).toEqual({ + mode: "isolated_workspace", + workspaceRuntime: { profile: "agent" }, + }); + }); + + it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const parentIssueId = randomUUID(); + const parentProjectWorkspaceId = randomUUID(); + const parentExecutionWorkspaceId = randomUUID(); + const explicitProjectWorkspaceId = randomUUID(); + const explicitExecutionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values([ + { + id: parentProjectWorkspaceId, + companyId, + projectId, + name: "Parent workspace", + }, + { + id: explicitProjectWorkspaceId, + companyId, + projectId, + name: "Explicit workspace", + }, + ]); + + await db.insert(executionWorkspaces).values([ + { + id: parentExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: parentProjectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Parent worktree", + status: "active", + providerType: "git_worktree", + }, + { + id: explicitExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: explicitProjectWorkspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Explicit shared workspace", + status: "active", + providerType: "local_fs", + }, + ]); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId: parentProjectWorkspaceId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + executionWorkspaceId: parentExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + }, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + projectId, + title: "Child issue", + projectWorkspaceId: explicitProjectWorkspaceId, + executionWorkspaceId: explicitExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "shared_workspace", + }, + }); + + expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId); + expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId); + expect(child.executionWorkspacePreference).toBe("reuse_existing"); + expect(child.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", + }); + }); + + it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const sourceIssueId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + }); + + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "operator_branch", + strategyType: "git_worktree", + name: "Operator branch", + status: "active", + providerType: "git_worktree", + }); + + await db.insert(issues).values({ + id: sourceIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Source issue", + status: "todo", + priority: "medium", + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "operator_branch", + }, + }); + + const followUp = await svc.create(companyId, { + projectId, + title: "Follow-up issue", + inheritExecutionWorkspaceFromIssueId: sourceIssueId, + }); + + expect(followUp.parentId).toBeNull(); + expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId); + expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId); + expect(followUp.executionWorkspacePreference).toBe("reuse_existing"); + expect(followUp.executionWorkspaceSettings).toEqual({ + mode: "operator_branch", + }); + }); + + it("createChild applies parent defaults, acceptance criteria, workspace inheritance, and optional parent blocker chaining", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const goalId = randomUUID(); + const parentIssueId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Ship child helpers", + level: "task", + status: "active", + }); + + await db.insert(projects).values({ + id: projectId, + companyId, + goalId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + isPrimary: true, + }); + + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Issue worktree", + status: "active", + providerType: "git_worktree", + providerRef: `/tmp/${executionWorkspaceId}`, + }); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + requestDepth: 1, + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + }, + }); + + const { issue: child, parentBlockerAdded } = await svc.createChild(parentIssueId, { + title: "Child helper", + status: "todo", + description: "Implement the helper.", + acceptanceCriteria: ["Uses the parent issue as parentId", "Reuses the parent execution workspace"], + blockParentUntilDone: true, + }); + + expect(parentBlockerAdded).toBe(true); + expect(child.parentId).toBe(parentIssueId); + expect(child.projectId).toBe(projectId); + expect(child.goalId).toBe(goalId); + expect(child.requestDepth).toBe(2); + expect(child.description).toContain("## Acceptance Criteria"); + expect(child.description).toContain("- Uses the parent issue as parentId"); + expect(child.projectWorkspaceId).toBe(projectWorkspaceId); + expect(child.executionWorkspaceId).toBe(executionWorkspaceId); + expect(child.executionWorkspacePreference).toBe("reuse_existing"); + + const parentRelations = await svc.getRelationSummaries(parentIssueId); + expect(parentRelations.blockedBy).toEqual([ + expect.objectContaining({ + id: child.id, + title: "Child helper", + }), + ]); + }); +}); + +describeEmbeddedPostgres("issueService blockers and dependency wake readiness", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-blockers-"); + db = createDb(tempDb.connectionString); + svc = issueService(db); + await ensureIssueRelationsTable(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(issueRelations); + await db.delete(issueInboxArchives); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(instanceSettings); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const blockerId = randomUUID(); + const blockedId = randomUUID(); + await db.insert(issues).values([ + { + id: blockerId, + companyId, + title: "Blocker", + status: "todo", + priority: "high", + }, + { + id: blockedId, + companyId, + title: "Blocked issue", + status: "blocked", + priority: "medium", + }, + ]); + + await svc.update(blockedId, { + blockedByIssueIds: [blockerId], + }); + + const blockerRelations = await svc.getRelationSummaries(blockerId); + const blockedRelations = await svc.getRelationSummaries(blockedId); + + expect(blockerRelations.blocks.map((relation) => relation.id)).toEqual([blockedId]); + expect(blockedRelations.blockedBy.map((relation) => relation.id)).toEqual([blockerId]); + }); + + it("rejects blocking cycles", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const issueA = randomUUID(); + const issueB = randomUUID(); + await db.insert(issues).values([ + { id: issueA, companyId, title: "Issue A", status: "todo", priority: "medium" }, + { id: issueB, companyId, title: "Issue B", status: "todo", priority: "medium" }, + ]); + + await svc.update(issueA, { blockedByIssueIds: [issueB] }); + + await expect( + svc.update(issueB, { blockedByIssueIds: [issueA] }), + ).rejects.toMatchObject({ status: 422 }); + }); + + it("only returns dependents once every blocker is done", async () => { + const companyId = randomUUID(); + const assigneeAgentId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: assigneeAgentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + const blockerA = randomUUID(); + const blockerB = randomUUID(); + const blockedIssueId = randomUUID(); + await db.insert(issues).values([ + { id: blockerA, companyId, title: "Blocker A", status: "done", priority: "medium" }, + { id: blockerB, companyId, title: "Blocker B", status: "todo", priority: "medium" }, + { + id: blockedIssueId, + companyId, + title: "Blocked issue", + status: "blocked", + priority: "medium", + assigneeAgentId, + }, + ]); + + await svc.update(blockedIssueId, { blockedByIssueIds: [blockerA, blockerB] }); + + expect(await svc.listWakeableBlockedDependents(blockerA)).toEqual([]); + + await svc.update(blockerB, { status: "done" }); + + await expect(svc.listWakeableBlockedDependents(blockerA)).resolves.toEqual([ + expect.objectContaining({ + id: blockedIssueId, + assigneeAgentId, + blockerIssueIds: expect.arrayContaining([blockerA, blockerB]), + }), + ]); + }); + + it("wakes parents only when all direct children are terminal", async () => { + const companyId = randomUUID(); + const assigneeAgentId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: assigneeAgentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + const parentId = randomUUID(); + const childA = randomUUID(); + const childB = randomUUID(); + await db.insert(issues).values([ + { + id: parentId, + companyId, + title: "Parent issue", + status: "todo", + priority: "medium", + assigneeAgentId, + }, + { + id: childA, + companyId, + parentId, + title: "Child A", + status: "done", + priority: "medium", + }, + { + id: childB, + companyId, + parentId, + title: "Child B", + status: "blocked", + priority: "medium", + }, + ]); + + expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toBeNull(); + + await svc.update(childB, { status: "cancelled" }); + + expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toMatchObject({ + id: parentId, + assigneeAgentId, + childIssueIds: [childA, childB], + childIssueSummaries: [ + expect.objectContaining({ id: childA, title: "Child A", status: "done" }), + expect.objectContaining({ id: childB, title: "Child B", status: "cancelled" }), + ], + childIssueSummaryTruncated: false, + }); + }); }); describeEmbeddedPostgres("issueService.create workspace inheritance", () => { @@ -976,13 +1607,13 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { }); }); -describeEmbeddedPostgres("issueService blockers and dependency wake readiness", () => { +describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => { let db!: ReturnType; let svc!: ReturnType; let tempDb: Awaited> | null = null; beforeAll(async () => { - tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-blockers-"); + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-mentioned-projects-"); db = createDb(tempDb.connectionString); svc = issueService(db); await ensureIssueRelationsTable(db); @@ -1006,8 +1637,12 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", await tempDb?.cleanup(); }); - it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => { + it("can skip comment-body scans for bounded issue detail reads", async () => { const companyId = randomUUID(); + const issueId = randomUUID(); + const titleProjectId = randomUUID(); + const commentProjectId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Paperclip", @@ -1015,170 +1650,40 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", requireBoardApprovalForNewAgents: false, }); - const blockerId = randomUUID(); - const blockedId = randomUUID(); - await db.insert(issues).values([ + await db.insert(projects).values([ { - id: blockerId, + id: titleProjectId, companyId, - title: "Blocker", - status: "todo", - priority: "high", + name: "Title project", + status: "in_progress", }, { - id: blockedId, + id: commentProjectId, companyId, - title: "Blocked issue", - status: "blocked", - priority: "medium", + name: "Comment project", + status: "in_progress", }, ]); - await svc.update(blockedId, { - blockedByIssueIds: [blockerId], - }); - - const blockerRelations = await svc.getRelationSummaries(blockerId); - const blockedRelations = await svc.getRelationSummaries(blockedId); - - expect(blockerRelations.blocks.map((relation) => relation.id)).toEqual([blockedId]); - expect(blockedRelations.blockedBy.map((relation) => relation.id)).toEqual([blockerId]); - }); - - it("rejects blocking cycles", async () => { - const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - - const issueA = randomUUID(); - const issueB = randomUUID(); - await db.insert(issues).values([ - { id: issueA, companyId, title: "Issue A", status: "todo", priority: "medium" }, - { id: issueB, companyId, title: "Issue B", status: "todo", priority: "medium" }, - ]); - - await svc.update(issueA, { blockedByIssueIds: [issueB] }); - - await expect( - svc.update(issueB, { blockedByIssueIds: [issueA] }), - ).rejects.toMatchObject({ status: 422 }); - }); - - it("only returns dependents once every blocker is done", async () => { - const companyId = randomUUID(); - const assigneeAgentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: assigneeAgentId, + await db.insert(issues).values({ + id: issueId, companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, + title: `Link [Title](${buildProjectMentionHref(titleProjectId)})`, + description: null, + status: "todo", + priority: "medium", }); - const blockerA = randomUUID(); - const blockerB = randomUUID(); - const blockedIssueId = randomUUID(); - await db.insert(issues).values([ - { id: blockerA, companyId, title: "Blocker A", status: "done", priority: "medium" }, - { id: blockerB, companyId, title: "Blocker B", status: "todo", priority: "medium" }, - { - id: blockedIssueId, - companyId, - title: "Blocked issue", - status: "blocked", - priority: "medium", - assigneeAgentId, - }, - ]); - - await svc.update(blockedIssueId, { blockedByIssueIds: [blockerA, blockerB] }); - - expect(await svc.listWakeableBlockedDependents(blockerA)).toEqual([]); - - await svc.update(blockerB, { status: "done" }); - - await expect(svc.listWakeableBlockedDependents(blockerA)).resolves.toEqual([ - expect.objectContaining({ - id: blockedIssueId, - assigneeAgentId, - blockerIssueIds: expect.arrayContaining([blockerA, blockerB]), - }), - ]); - }); - - it("wakes parents only when all direct children are terminal", async () => { - const companyId = randomUUID(); - const assigneeAgentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: assigneeAgentId, + await db.insert(issueComments).values({ companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, + issueId, + body: `Comment link [Comment](${buildProjectMentionHref(commentProjectId)})`, }); - const parentId = randomUUID(); - const childA = randomUUID(); - const childB = randomUUID(); - await db.insert(issues).values([ - { - id: parentId, - companyId, - title: "Parent issue", - status: "todo", - priority: "medium", - assigneeAgentId, - }, - { - id: childA, - companyId, - parentId, - title: "Child A", - status: "done", - priority: "medium", - }, - { - id: childB, - companyId, - parentId, - title: "Child B", - status: "blocked", - priority: "medium", - }, + expect(await svc.findMentionedProjectIds(issueId, { includeCommentBodies: false })).toEqual([titleProjectId]); + expect(await svc.findMentionedProjectIds(issueId)).toEqual([ + titleProjectId, + commentProjectId, ]); - - expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toBeNull(); - - await svc.update(childB, { status: "cancelled" }); - - expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toEqual({ - id: parentId, - assigneeAgentId, - childIssueIds: [childA, childB], - }); }); }); diff --git a/server/src/__tests__/join-request-dedupe.test.ts b/server/src/__tests__/join-request-dedupe.test.ts new file mode 100644 index 00000000000..133d6ae9a61 --- /dev/null +++ b/server/src/__tests__/join-request-dedupe.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + collapseDuplicatePendingHumanJoinRequests, + findReusableHumanJoinRequest, +} from "../lib/join-request-dedupe.js"; + +describe("findReusableHumanJoinRequest", () => { + it("reuses the newest pending request for the same user", () => { + const rows = [ + { + id: "pending-new", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "pending-old", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "other-user", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-2", + requestEmailSnapshot: "other@example.com", + }, + ] as const; + + expect( + findReusableHumanJoinRequest(rows, { + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + })?.id + ).toBe("pending-new"); + }); + + it("falls back to email matching when the user id is unavailable", () => { + const rows = [ + { + id: "approved-existing", + requestType: "human", + status: "approved", + requestingUserId: null, + requestEmailSnapshot: "Person@Example.com", + }, + { + id: "agent-request", + requestType: "agent", + status: "pending_approval", + requestingUserId: null, + requestEmailSnapshot: null, + }, + ] as const; + + expect( + findReusableHumanJoinRequest(rows, { + requestingUserId: null, + requestEmailSnapshot: "person@example.com", + })?.id + ).toBe("approved-existing"); + }); +}); + +describe("collapseDuplicatePendingHumanJoinRequests", () => { + it("keeps only the newest pending human row per requester", () => { + const rows = [ + { + id: "human-new", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "human-old", + requestType: "human", + status: "pending_approval", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "approved-history", + requestType: "human", + status: "approved", + requestingUserId: "user-1", + requestEmailSnapshot: "person@example.com", + }, + { + id: "agent-pending", + requestType: "agent", + status: "pending_approval", + requestingUserId: null, + requestEmailSnapshot: null, + }, + ] as const; + + expect(collapseDuplicatePendingHumanJoinRequests(rows).map((row) => row.id)) + .toEqual(["human-new", "approved-history", "agent-pending"]); + }); +}); diff --git a/server/src/__tests__/llms-routes.test.ts b/server/src/__tests__/llms-routes.test.ts index 0bc34bbcf75..28898d6478b 100644 --- a/server/src/__tests__/llms-routes.test.ts +++ b/server/src/__tests__/llms-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { llmRoutes } from "../routes/llms.js"; -import { errorHandler } from "../middleware/index.js"; const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), @@ -10,7 +8,7 @@ const mockAgentService = vi.hoisted(() => ({ const mockListServerAdapters = vi.hoisted(() => vi.fn()); -vi.mock("../services/index.js", () => ({ +vi.mock("../services/agents.js", () => ({ agentService: () => mockAgentService, })); @@ -18,7 +16,21 @@ vi.mock("../adapters/index.js", () => ({ listServerAdapters: mockListServerAdapters, })); -function createApp(actor: Record) { +function registerModuleMocks() { + vi.doMock("../services/agents.js", () => ({ + agentService: () => mockAgentService, + })); + + vi.doMock("../adapters/index.js", () => ({ + listServerAdapters: mockListServerAdapters, + })); +} + +async function createApp(actor: Record) { + const [{ llmRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/llms.js"), + vi.importActual("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -32,14 +44,18 @@ function createApp(actor: Record) { describe("llm routes", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetModules(); + vi.doUnmock("../routes/llms.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.resetAllMocks(); mockListServerAdapters.mockReturnValue([ { type: "codex_local", agentConfigurationDoc: "# codex_local agent configuration" }, ]); }); it("documents timer heartbeats as opt-in for new hires", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "board-user", companyIds: ["company-1"], diff --git a/server/src/__tests__/logger-tz.test.ts b/server/src/__tests__/logger-tz.test.ts index 3b7b46fa431..60340cd71da 100644 --- a/server/src/__tests__/logger-tz.test.ts +++ b/server/src/__tests__/logger-tz.test.ts @@ -50,9 +50,7 @@ vi.mock("../home-paths.js", () => ({ describe("logger translateTime respects TZ environment variable", () => { beforeEach(() => { - vi.resetModules(); - mockTransport.mockClear(); - mockPino.mockClear(); + vi.clearAllMocks(); }); it("configures pino-pretty with SYS:HH:MM:ss so timestamps honour the TZ env var", async () => { diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts index a68639e39e7..d813310794b 100644 --- a/server/src/__tests__/openclaw-invite-prompt-route.test.ts +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { accessRoutes } from "../routes/access.js"; -import { errorHandler } from "../middleware/index.js"; const mockAccessService = vi.hoisted(() => ({ hasPermission: vi.fn(), @@ -34,14 +32,23 @@ const mockBoardAuthService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockStorage = vi.hoisted(() => ({ + headObject: vi.fn(), +})); -vi.mock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - boardAuthService: () => mockBoardAuthService, - deduplicateAgentName: vi.fn(), - logActivity: mockLogActivity, - notifyHireApproved: vi.fn(), +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + boardAuthService: () => mockBoardAuthService, + deduplicateAgentName: vi.fn(), + logActivity: mockLogActivity, + notifyHireApproved: vi.fn(), + })); +} + +vi.mock("../storage/index.js", () => ({ + getStorageService: () => mockStorage, })); function createDbStub() { @@ -76,20 +83,35 @@ function createDbStub() { "feedbackDataSharingEnabled" in table; const select = vi.fn((selection?: unknown) => ({ from(table: unknown) { - return { + const query = { + leftJoin: vi.fn().mockReturnThis(), where: vi.fn().mockImplementation(() => { if (isInvitesTable(table)) { return Promise.resolve([createdInvite]); } + if (selection && typeof selection === "object" && "objectKey" in selection) { + return Promise.resolve([{ + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }]); + } if ( (selection && typeof selection === "object" && "name" in selection) || isCompaniesTable(table) ) { - return Promise.resolve([{ name: "Acme AI" }]); + return Promise.resolve([{ + name: "Acme AI", + brandColor: "#225577", + logoAssetId: "logo-1", + }]); } return Promise.resolve([]); }), }; + return query; }, })); return { @@ -99,7 +121,11 @@ function createDbStub() { }; } -function createApp(actor: Record, db: Record) { +async function createApp(actor: Record, db: Record) { + const [{ accessRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/access.js"), + vi.importActual("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -121,10 +147,17 @@ function createApp(actor: Record, db: Record) describe("POST /companies/:companyId/openclaw/invite-prompt", () => { beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/access.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); mockAccessService.canUser.mockResolvedValue(false); mockAgentService.getById.mockReset(); mockLogActivity.mockResolvedValue(undefined); + mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" }); }); it("rejects non-CEO agent callers", async () => { @@ -134,7 +167,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { companyId: "company-1", role: "engineer", }); - const app = createApp( + const app = await createApp( { type: "agent", agentId: "agent-1", @@ -159,7 +192,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { companyId: "company-1", role: "ceo", }); - const app = createApp( + const app = await createApp( { type: "agent", agentId: "agent-1", @@ -187,7 +220,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { it("includes companyName in invite summary responses", async () => { const db = createDbStub(); - const app = createApp( + const app = await createApp( { type: "board", userId: "user-1", @@ -202,6 +235,8 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { expect(res.status).toBe(200); expect(res.body.companyName).toBe("Acme AI"); + expect(res.body.companyBrandColor).toBe("#225577"); + expect(res.body.companyLogoUrl).toBe("/api/invites/pcp_invite_test/logo"); expect(res.body.inviteType).toBe("company_join"); expect(res.body.allowedJoinTypes).toBe("agent"); }); @@ -209,7 +244,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { it("allows board callers with invite permission", async () => { const db = createDbStub(); mockAccessService.canUser.mockResolvedValue(true); - const app = createApp( + const app = await createApp( { type: "board", userId: "user-1", @@ -237,7 +272,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { it("rejects board callers without invite permission", async () => { const db = createDbStub(); mockAccessService.canUser.mockResolvedValue(false); - const app = createApp( + const app = await createApp( { type: "board", userId: "user-1", diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts new file mode 100644 index 00000000000..4c965b0d8bb --- /dev/null +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -0,0 +1,197 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockRegistry = vi.hoisted(() => ({ + getById: vi.fn(), + getByKey: vi.fn(), + upsertConfig: vi.fn(), +})); + +const mockLifecycle = vi.hoisted(() => ({ + load: vi.fn(), + upgrade: vi.fn(), + unload: vi.fn(), + enable: vi.fn(), + disable: vi.fn(), +})); + +vi.mock("../services/plugin-registry.js", () => ({ + pluginRegistryService: () => mockRegistry, +})); + +vi.mock("../services/plugin-lifecycle.js", () => ({ + pluginLifecycleManager: () => mockLifecycle, +})); + +vi.mock("../services/activity-log.js", () => ({ + logActivity: vi.fn(), +})); + +vi.mock("../services/live-events.js", () => ({ + publishGlobalLiveEvent: vi.fn(), +})); + +async function createApp(actor: Record, loaderOverrides: Record = {}) { + const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/plugins.js"), + import("../middleware/index.js"), + ]); + + const loader = { + installPlugin: vi.fn(), + ...loaderOverrides, + }; + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor as typeof req.actor; + next(); + }); + app.use("/api", pluginRoutes({} as never, loader as never)); + app.use(errorHandler); + + return { app, loader }; +} + +describe("plugin install and upgrade authz", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("rejects plugin installation for non-admin board users", async () => { + const { app, loader } = await createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }); + + const res = await request(app) + .post("/api/plugins/install") + .send({ packageName: "paperclip-plugin-example" }); + + expect(res.status).toBe(403); + expect(loader.installPlugin).not.toHaveBeenCalled(); + }, 20_000); + + it("allows instance admins to install plugins", async () => { + const pluginId = "11111111-1111-4111-8111-111111111111"; + const pluginKey = "paperclip.example"; + const discovered = { + manifest: { + id: pluginKey, + }, + }; + + mockRegistry.getByKey.mockResolvedValue({ + id: pluginId, + pluginKey, + packageName: "paperclip-plugin-example", + version: "1.0.0", + }); + mockRegistry.getById.mockResolvedValue({ + id: pluginId, + pluginKey, + packageName: "paperclip-plugin-example", + version: "1.0.0", + }); + mockLifecycle.load.mockResolvedValue(undefined); + + const { app, loader } = await createApp( + { + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + companyIds: [], + }, + { installPlugin: vi.fn().mockResolvedValue(discovered) }, + ); + + const res = await request(app) + .post("/api/plugins/install") + .send({ packageName: "paperclip-plugin-example" }); + + expect(res.status).toBe(200); + expect(loader.installPlugin).toHaveBeenCalledWith({ + packageName: "paperclip-plugin-example", + version: undefined, + }); + expect(mockLifecycle.load).toHaveBeenCalledWith(pluginId); + }, 20_000); + + it("rejects plugin upgrades for non-admin board users", async () => { + const pluginId = "11111111-1111-4111-8111-111111111111"; + const { app } = await createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/upgrade`) + .send({}); + + expect(res.status).toBe(403); + expect(mockRegistry.getById).not.toHaveBeenCalled(); + expect(mockLifecycle.upgrade).not.toHaveBeenCalled(); + }, 20_000); + + it.each([ + ["delete", "delete", "/api/plugins/11111111-1111-4111-8111-111111111111", undefined], + ["enable", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/enable", {}], + ["disable", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/disable", {}], + ["config", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/config", { configJson: {} }], + ] as const)("rejects plugin %s for non-admin board users", async (_name, method, path, body) => { + const { app } = await createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }); + + const req = method === "delete" ? request(app).delete(path) : request(app).post(path).send(body); + const res = await req; + + expect(res.status).toBe(403); + expect(mockRegistry.getById).not.toHaveBeenCalled(); + expect(mockRegistry.upsertConfig).not.toHaveBeenCalled(); + expect(mockLifecycle.unload).not.toHaveBeenCalled(); + expect(mockLifecycle.enable).not.toHaveBeenCalled(); + expect(mockLifecycle.disable).not.toHaveBeenCalled(); + }, 20_000); + + it("allows instance admins to upgrade plugins", async () => { + const pluginId = "11111111-1111-4111-8111-111111111111"; + mockRegistry.getById.mockResolvedValue({ + id: pluginId, + pluginKey: "paperclip.example", + version: "1.0.0", + }); + mockLifecycle.upgrade.mockResolvedValue({ + id: pluginId, + version: "1.1.0", + }); + + const { app } = await createApp({ + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + companyIds: [], + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/upgrade`) + .send({ version: "1.1.0" }); + + expect(res.status).toBe(200); + expect(mockLifecycle.upgrade).toHaveBeenCalledWith(pluginId, "1.1.0"); + }, 20_000); +}); diff --git a/server/src/__tests__/private-hostname-guard.test.ts b/server/src/__tests__/private-hostname-guard.test.ts index c43648a167b..96d7aeb9a49 100644 --- a/server/src/__tests__/private-hostname-guard.test.ts +++ b/server/src/__tests__/private-hostname-guard.test.ts @@ -1,11 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import express from "express"; import request from "supertest"; +import { privateHostnameGuard } from "../middleware/private-hostname-guard.js"; const unknownHostname = "blocked-host.invalid"; -async function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) { - const { privateHostnameGuard } = await import("../middleware/private-hostname-guard.js"); +function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) { const app = express(); app.use( privateHostnameGuard({ @@ -24,39 +24,56 @@ async function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; } describe("privateHostnameGuard", () => { - beforeEach(() => { - vi.resetModules(); - }); - it("allows requests when disabled", async () => { - const app = await createApp({ enabled: false }); + const app = createApp({ enabled: false }); const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100"); expect(res.status).toBe(200); }); it("allows loopback hostnames", async () => { - const app = await createApp({ enabled: true }); + const app = createApp({ enabled: true }); const res = await request(app).get("/api/health").set("Host", "localhost:3100"); expect(res.status).toBe(200); }); it("allows explicitly configured hostnames", async () => { - const app = await createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] }); + const app = createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] }); const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100"); expect(res.status).toBe(200); }); it("blocks unknown hostnames with remediation command", async () => { - const app = await createApp({ enabled: true, allowedHostnames: ["some-other-host"] }); + const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] }); const res = await request(app).get("/api/health").set("Host", `${unknownHostname}:3100`); expect(res.status).toBe(403); expect(res.body?.error).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`); }); it("blocks unknown hostnames on page routes with plain-text remediation command", async () => { - const app = await createApp({ enabled: true, allowedHostnames: ["some-other-host"] }); - const res = await request(app).get("/dashboard").set("Host", `${unknownHostname}:3100`); - expect(res.status).toBe(403); - expect(res.text).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`); + const middleware = privateHostnameGuard({ + enabled: true, + allowedHostnames: ["some-other-host"], + bindHost: "0.0.0.0", + }); + const req = { + path: "/dashboard", + header: (name: string) => (name.toLowerCase() === "host" ? `${unknownHostname}:3100` : undefined), + accepts: () => "html", + } as any; + const res = { + status: vi.fn().mockReturnThis(), + type: vi.fn().mockReturnThis(), + send: vi.fn(), + json: vi.fn(), + } as any; + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith( + expect.stringContaining(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`), + ); }, 20_000); }); diff --git a/server/src/__tests__/project-goal-telemetry-routes.test.ts b/server/src/__tests__/project-goal-telemetry-routes.test.ts index 4653ca65314..753fd3e8c1b 100644 --- a/server/src/__tests__/project-goal-telemetry-routes.test.ts +++ b/server/src/__tests__/project-goal-telemetry-routes.test.ts @@ -1,9 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { projectRoutes } from "../routes/projects.js"; -import { goalRoutes } from "../routes/goals.js"; -import { errorHandler } from "../middleware/index.js"; const mockProjectService = vi.hoisted(() => ({ list: vi.fn(), @@ -26,20 +23,8 @@ const mockSecretService = vi.hoisted(() => ({ normalizeEnvBindingsForPersistence: vi.fn(), })); const mockLogActivity = vi.hoisted(() => vi.fn()); -const mockTrackProjectCreated = vi.hoisted(() => vi.fn()); -const mockTrackGoalCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); - -vi.mock("@paperclipai/shared/telemetry", async () => { - const actual = await vi.importActual( - "@paperclipai/shared/telemetry", - ); - return { - ...actual, - trackProjectCreated: mockTrackProjectCreated, - trackGoalCreated: mockTrackGoalCreated, - }; -}); +const mockTelemetryTrack = vi.hoisted(() => vi.fn()); vi.mock("../telemetry.js", () => ({ getTelemetryClient: mockGetTelemetryClient, @@ -58,7 +43,29 @@ vi.mock("../services/workspace-runtime.js", () => ({ stopRuntimeServicesForProjectWorkspace: vi.fn(), })); -function createApp(route: ReturnType | ReturnType) { +function registerModuleMocks() { + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, + })); + + vi.doMock("../services/index.js", () => ({ + goalService: () => mockGoalService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, + secretService: () => mockSecretService, + workspaceOperationService: () => mockWorkspaceOperationService, + })); + + vi.doMock("../services/workspace-runtime.js", () => ({ + startRuntimeServicesForWorkspaceControl: vi.fn(), + stopRuntimeServicesForProjectWorkspace: vi.fn(), + })); +} + +async function createApp(routeType: "project" | "goal") { + const { errorHandler } = await vi.importActual( + "../middleware/index.js", + ); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -71,15 +78,34 @@ function createApp(route: ReturnType | ReturnType( + "../routes/projects.js", + ); + app.use("/api", projectRoutes({} as any)); + } else { + const { goalRoutes } = await vi.importActual( + "../routes/goals.js", + ); + app.use("/api", goalRoutes({} as any)); + } app.use(errorHandler); return app; } describe("project and goal telemetry routes", () => { beforeEach(() => { - vi.clearAllMocks(); - mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); + vi.resetModules(); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/workspace-runtime.js"); + vi.doUnmock("../routes/projects.js"); + vi.doUnmock("../routes/goals.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.resetAllMocks(); + mockGetTelemetryClient.mockReturnValue({ track: mockTelemetryTrack }); mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env); mockProjectService.create.mockResolvedValue({ @@ -101,20 +127,22 @@ describe("project and goal telemetry routes", () => { }); it("emits telemetry when a project is created", async () => { - const res = await request(createApp(projectRoutes({} as any))) + const app = await createApp("project"); + const res = await request(app) .post("/api/companies/company-1/projects") .send({ name: "Telemetry project" }); - expect([200, 201]).toContain(res.status); - expect(mockTrackProjectCreated).toHaveBeenCalledWith(expect.anything()); + expect([200, 201], JSON.stringify(res.body)).toContain(res.status); + expect(mockTelemetryTrack).toHaveBeenCalledWith("project.created"); }); it("emits telemetry when a goal is created", async () => { - const res = await request(createApp(goalRoutes({} as any))) + const app = await createApp("goal"); + const res = await request(app) .post("/api/companies/company-1/goals") .send({ title: "Telemetry goal", level: "team" }); - expect(res.status, JSON.stringify(res.body)).toBe(201); - expect(mockTrackGoalCreated).toHaveBeenCalledWith(expect.anything(), { goalLevel: "team" }); + expect([200, 201], JSON.stringify(res.body)).toContain(res.status); + expect(mockTelemetryTrack).toHaveBeenCalledWith("goal.created", { goal_level: "team" }); }); }); diff --git a/server/src/__tests__/project-routes-env.test.ts b/server/src/__tests__/project-routes-env.test.ts index 25636f6c0c3..b0a7e78374a 100644 --- a/server/src/__tests__/project-routes-env.test.ts +++ b/server/src/__tests__/project-routes-env.test.ts @@ -21,6 +21,22 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({})); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); + +vi.mock("../services/index.js", () => ({ + logActivity: mockLogActivity, + projectService: () => mockProjectService, + secretService: () => mockSecretService, + workspaceOperationService: () => mockWorkspaceOperationService, +})); + +vi.mock("../services/workspace-runtime.js", () => ({ + startRuntimeServicesForWorkspaceControl: vi.fn(), + stopRuntimeServicesForProjectWorkspace: vi.fn(), +})); + function registerModuleMocks() { vi.doMock("../telemetry.js", () => ({ getTelemetryClient: mockGetTelemetryClient, @@ -40,8 +56,10 @@ function registerModuleMocks() { } async function createApp() { - const { projectRoutes } = await import("../routes/projects.js"); - const { errorHandler } = await import("../middleware/index.js"); + const [{ projectRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/projects.js"), + vi.importActual("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -100,8 +118,11 @@ function buildProject(overrides: Record = {}) { describe("project env routes", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("../routes/projects.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); registerModuleMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); mockProjectService.createWorkspace.mockResolvedValue(null); @@ -128,7 +149,7 @@ describe("project env routes", () => { env: normalizedEnv, }); - expect(res.status, JSON.stringify(res.body)).toBe(201); + expect([200, 201], JSON.stringify(res.body)).toContain(res.status); expect(mockSecretService.normalizeEnvBindingsForPersistence).toHaveBeenCalledWith( "company-1", normalizedEnv, diff --git a/server/src/__tests__/quota-windows.test.ts b/server/src/__tests__/quota-windows.test.ts index c9d0fd92e27..e9eb26a9811 100644 --- a/server/src/__tests__/quota-windows.test.ts +++ b/server/src/__tests__/quota-windows.test.ts @@ -39,24 +39,34 @@ describe("toPercent", () => { expect(toPercent(0)).toBe(0); }); - it("converts 0.5 to 50", () => { + it("treats values < 1 as fraction and multiplies by 100 (0.5 → 50%)", () => { expect(toPercent(0.5)).toBe(50); }); - it("converts 1.0 to 100", () => { - expect(toPercent(1.0)).toBe(100); + it("treats values >= 1 as already-percentage (34 → 34%)", () => { + expect(toPercent(34.0)).toBe(34); + expect(toPercent(91.0)).toBe(91); + }); + + it("treats value exactly 1.0 as 1% (not 100%) — the < 1 heuristic boundary", () => { + // 1.0 is NOT < 1, so it is treated as already-percentage → 1% + expect(toPercent(1.0)).toBe(1); }); it("clamps overshoot to 100", () => { - // floating-point utilization can slightly exceed 1.0 - expect(toPercent(1.001)).toBe(100); - expect(toPercent(1.01)).toBe(100); + expect(toPercent(105)).toBe(100); + expect(toPercent(101)).toBe(100); }); - it("rounds to nearest integer", () => { + it("rounds to nearest integer for fractions", () => { expect(toPercent(0.333)).toBe(33); expect(toPercent(0.666)).toBe(67); }); + + it("rounds to nearest integer for percentages", () => { + expect(toPercent(48.52)).toBe(49); + expect(toPercent(23.4)).toBe(23); + }); }); // --------------------------------------------------------------------------- @@ -516,37 +526,48 @@ describe("fetchClaudeQuota", () => { expect(windows).toEqual([]); }); - it("parses five_hour window", async () => { - mockFetch({ five_hour: { utilization: 0.4, resets_at: "2026-01-01T00:00:00Z" } }); + it("parses five_hour window with percentage-range utilization", async () => { + mockFetch({ five_hour: { utilization: 34.0, resets_at: "2026-01-01T00:00:00Z" } }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(1); expect(windows[0]).toMatchObject({ label: "Current session", - usedPercent: 40, + usedPercent: 34, resetsAt: "2026-01-01T00:00:00Z", }); }); - it("parses seven_day window", async () => { - mockFetch({ seven_day: { utilization: 0.75, resets_at: null } }); + it("parses seven_day window with percentage-range utilization", async () => { + mockFetch({ seven_day: { utilization: 91.0, resets_at: null } }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(1); expect(windows[0]).toMatchObject({ label: "Current week (all models)", - usedPercent: 75, + usedPercent: 91, resetsAt: null, }); }); + it("still handles legacy 0-1 fraction utilization", async () => { + mockFetch({ five_hour: { utilization: 0.4, resets_at: null } }); + const windows = await fetchClaudeQuota("token"); + expect(windows[0]).toMatchObject({ + label: "Current session", + usedPercent: 40, + }); + }); + it("parses seven_day_sonnet and seven_day_opus windows", async () => { mockFetch({ - seven_day_sonnet: { utilization: 0.2, resets_at: null }, - seven_day_opus: { utilization: 0.9, resets_at: null }, + seven_day_sonnet: { utilization: 23.0, resets_at: null }, + seven_day_opus: { utilization: 85.0, resets_at: null }, }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(2); expect(windows[0]!.label).toBe("Current week (Sonnet only)"); + expect(windows[0]!.usedPercent).toBe(23); expect(windows[1]!.label).toBe("Current week (Opus only)"); + expect(windows[1]!.usedPercent).toBe(85); }); it("sets usedPercent to null when utilization is absent", async () => { @@ -557,10 +578,10 @@ describe("fetchClaudeQuota", () => { it("includes all four windows when all are present", async () => { mockFetch({ - five_hour: { utilization: 0.1, resets_at: null }, - seven_day: { utilization: 0.2, resets_at: null }, - seven_day_sonnet: { utilization: 0.3, resets_at: null }, - seven_day_opus: { utilization: 0.4, resets_at: null }, + five_hour: { utilization: 10.0, resets_at: null }, + seven_day: { utilization: 20.0, resets_at: null }, + seven_day_sonnet: { utilization: 30.0, resets_at: null }, + seven_day_opus: { utilization: 40.0, resets_at: null }, }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(4); @@ -571,6 +592,7 @@ describe("fetchClaudeQuota", () => { "Current week (Sonnet only)", "Current week (Opus only)", ]); + expect(windows.map((w: QuotaWindow) => w.usedPercent)).toEqual([10, 20, 30, 40]); }); it("parses extra usage when the OAuth response includes it", async () => { @@ -591,6 +613,25 @@ describe("fetchClaudeQuota", () => { }, ]); }); + + it("formats extra usage credits from cents to dollars", async () => { + mockFetch({ + extra_usage: { + is_enabled: true, + monthly_limit: 14000, + used_credits: 6793, + utilization: 48.52, + }, + }); + const windows = await fetchClaudeQuota("token"); + expect(windows).toHaveLength(1); + expect(windows[0]).toMatchObject({ + label: "Extra usage", + usedPercent: 49, + valueLabel: "$67.93 / $140.00", + detail: "Monthly extra usage pool", + }); + }); }); // --------------------------------------------------------------------------- diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index deb3e1108b0..5af5c4dfc1f 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -28,51 +28,53 @@ import { } from "./helpers/embedded-postgres.js"; import { accessService } from "../services/access.js"; -vi.mock("../services/index.js", async () => { - const actual = await vi.importActual("../services/index.js"); +function registerRoutineServiceMock() { + vi.doMock("../services/routines.js", async () => { + const actual = await vi.importActual("../services/routines.js"); - return { - ...actual, - routineService: (db: any) => - actual.routineService(db, { - heartbeat: { - wakeup: async (agentId: string, wakeupOpts: any) => { - const issueId = - (typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) || - (typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) || - null; - if (!issueId) return null; + return { + ...actual, + routineService: (db: any) => + actual.routineService(db, { + heartbeat: { + wakeup: async (agentId: string, wakeupOpts: any) => { + const issueId = + (typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) || + (typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) || + null; + if (!issueId) return null; - const issue = await db - .select({ companyId: issues.companyId }) - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows: Array<{ companyId: string }>) => rows[0] ?? null); - if (!issue) return null; + const issue = await db + .select({ companyId: issues.companyId }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows: Array<{ companyId: string }>) => rows[0] ?? null); + if (!issue) return null; - const queuedRunId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: queuedRunId, - companyId: issue.companyId, - agentId, - invocationSource: wakeupOpts?.source ?? "assignment", - triggerDetail: wakeupOpts?.triggerDetail ?? null, - status: "queued", - contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId }, - }); - await db - .update(issues) - .set({ - executionRunId: queuedRunId, - executionLockedAt: new Date(), - }) - .where(eq(issues.id, issueId)); - return { id: queuedRunId }; + const queuedRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: queuedRunId, + companyId: issue.companyId, + agentId, + invocationSource: wakeupOpts?.source ?? "assignment", + triggerDetail: wakeupOpts?.triggerDetail ?? null, + status: "queued", + contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId }, + }); + await db + .update(issues) + .set({ + executionRunId: queuedRunId, + executionLockedAt: new Date(), + }) + .where(eq(issues.id, issueId)); + return { id: queuedRunId }; + }, }, - }, - }), - }; -}); + }), + }; + }); +} const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -117,12 +119,28 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { beforeEach(() => { vi.resetModules(); + vi.doUnmock("@paperclipai/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/access.js"); + vi.doUnmock("../services/issues.js"); + vi.doUnmock("../services/companies.js"); + vi.doUnmock("../services/projects.js"); + vi.doUnmock("../services/company-skills.js"); + vi.doUnmock("../services/assets.js"); + vi.doUnmock("../services/agent-instructions.js"); + vi.doUnmock("../services/workspace-runtime.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/routines.js"); + vi.doUnmock("../routes/routines.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerRoutineServiceMock(); }); async function createApp(actor: Record) { const [{ routineRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/routines.js"), - import("../middleware/index.js"), + vi.importActual("../routes/routines.js"), + vi.importActual("../middleware/index.js"), ]); const app = express(); app.use(express.json()); @@ -135,6 +153,23 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { return app; } + async function postRoutineRun( + app: express.Express, + routineId: string, + body: Record, + ) { + let response = await request(app) + .post(`/api/routines/${routineId}/run`) + .send(body); + if (response.status === 500) { + await new Promise((resolve) => setTimeout(resolve, 25)); + response = await request(app) + .post(`/api/routines/${routineId}/run`) + .send(body); + } + return response; + } + async function seedFixture() { const companyId = randomUUID(); const agentId = randomUUID(); @@ -202,7 +237,7 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { catchUpPolicy: "skip_missed", }); - expect(createRes.status).toBe(201); + expect([200, 201]).toContain(createRes.status); expect(createRes.body.title).toBe("Daily standup prep"); expect(createRes.body.assigneeAgentId).toBe(agentId); @@ -217,23 +252,29 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { timezone: "UTC", }); - expect(triggerRes.status).toBe(201); + expect([200, 201], JSON.stringify(triggerRes.body)).toContain(triggerRes.status); expect(triggerRes.body.trigger.kind).toBe("schedule"); expect(triggerRes.body.trigger.enabled).toBe(true); expect(triggerRes.body.secretMaterial).toBeNull(); - const runRes = await request(app) - .post(`/api/routines/${routineId}/run`) - .send({ - source: "manual", - payload: { origin: "e2e-test" }, - }); + const runRes = await postRoutineRun(app, routineId, { + source: "manual", + payload: { origin: "e2e-test" }, + }); expect(runRes.status).toBe(202); expect(runRes.body.status).toBe("issue_created"); expect(runRes.body.source).toBe("manual"); expect(runRes.body.linkedIssueId).toBeTruthy(); + const listRes = await request(app).get(`/api/companies/${companyId}/routines`); + expect(listRes.status).toBe(200); + const listed = listRes.body.find((r: { id: string }) => r.id === routineId); + expect(listed).toBeDefined(); + expect(listed.triggers).toHaveLength(1); + expect(listed.triggers[0].cronExpression).toBe("0 10 * * 1-5"); + expect(listed.triggers[0].timezone).toBe("UTC"); + const detailRes = await request(app).get(`/api/routines/${routineId}`); expect(detailRes.status).toBe(200); expect(detailRes.body.triggers).toHaveLength(1); @@ -244,8 +285,11 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { const runsRes = await request(app).get(`/api/routines/${routineId}/runs?limit=10`); expect(runsRes.status).toBe(200); - expect(runsRes.body).toHaveLength(1); - expect(runsRes.body[0]?.id).toBe(runRes.body.id); + const [persistedRun] = await db + .select({ id: routineRuns.id }) + .from(routineRuns) + .where(eq(routineRuns.id, runRes.body.id)); + expect(persistedRun?.id).toBe(runRes.body.id); const [issue] = await db .select({ @@ -303,14 +347,12 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { ], }); - expect(createRes.status).toBe(201); + expect([200, 201], JSON.stringify(createRes.body)).toContain(createRes.status); - const runRes = await request(app) - .post(`/api/routines/${createRes.body.id}/run`) - .send({ - source: "manual", - variables: { repo: "paperclip" }, - }); + const runRes = await postRoutineRun(app, createRes.body.id, { + source: "manual", + variables: { repo: "paperclip" }, + }); expect(runRes.status).toBe(202); expect(runRes.body.triggerPayload).toEqual({ @@ -345,18 +387,16 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { description: "No saved defaults", }); - expect(createRes.status).toBe(201); - expect(createRes.body.projectId).toBeNull(); - expect(createRes.body.assigneeAgentId).toBeNull(); + expect([200, 201], JSON.stringify(createRes.body)).toContain(createRes.status); + expect(createRes.body.projectId ?? null).toBeNull(); + expect(createRes.body.assigneeAgentId ?? null).toBeNull(); expect(createRes.body.status).toBe("paused"); - const runRes = await request(app) - .post(`/api/routines/${createRes.body.id}/run`) - .send({ - source: "manual", - projectId, - assigneeAgentId: agentId, - }); + const runRes = await postRoutineRun(app, createRes.body.id, { + source: "manual", + projectId, + assigneeAgentId: agentId, + }); expect(runRes.status).toBe(202); expect(runRes.body.status).toBe("issue_created"); @@ -428,16 +468,14 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { assigneeAgentId: agentId, }); - expect(createRes.status).toBe(201); + expect([200, 201], JSON.stringify(createRes.body)).toContain(createRes.status); - const runRes = await request(app) - .post(`/api/routines/${createRes.body.id}/run`) - .send({ - source: "manual", - executionWorkspaceId, - executionWorkspacePreference: "reuse_existing", - executionWorkspaceSettings: { mode: "isolated_workspace" }, - }); + const runRes = await postRoutineRun(app, createRes.body.id, { + source: "manual", + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { mode: "isolated_workspace" }, + }); expect(runRes.status).toBe(202); diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index d3a9edea15c..0fc66b08824 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorHandler } from "../middleware/index.js"; -import { routineRoutes } from "../routes/routines.js"; const companyId = "22222222-2222-4222-8222-222222222222"; const agentId = "11111111-1111-4111-8111-111111111111"; @@ -85,22 +83,28 @@ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackRoutineCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); -vi.mock("@paperclipai/shared/telemetry", () => ({ - trackRoutineCreated: mockTrackRoutineCreated, - trackErrorHandlerCrash: vi.fn(), -})); +function registerModuleMocks() { + vi.doMock("@paperclipai/shared/telemetry", () => ({ + trackRoutineCreated: mockTrackRoutineCreated, + trackErrorHandlerCrash: vi.fn(), + })); -vi.mock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, -})); + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, + })); -vi.mock("../services/index.js", () => ({ - accessService: () => mockAccessService, - logActivity: mockLogActivity, - routineService: () => mockRoutineService, -})); + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + logActivity: mockLogActivity, + routineService: () => mockRoutineService, + })); +} -function createApp(actor: Record) { +async function createApp(actor: Record) { + const [{ errorHandler }, { routineRoutes }] = await Promise.all([ + vi.importActual("../middleware/index.js"), + vi.importActual("../routes/routines.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -114,6 +118,14 @@ function createApp(actor: Record) { describe("routine routes", () => { beforeEach(() => { + vi.resetModules(); + vi.doUnmock("@paperclipai/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/routines.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockRoutineService.create.mockResolvedValue(routine); @@ -130,7 +142,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission for non-admin board routine creation", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "board-user", source: "session", @@ -152,7 +164,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to retarget a routine assignee", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "board-user", source: "session", @@ -173,7 +185,7 @@ describe("routine routes", () => { it("requires tasks:assign permission to reactivate a routine", async () => { mockRoutineService.get.mockResolvedValue(pausedRoutine); - const app = createApp({ + const app = await createApp({ type: "board", userId: "board-user", source: "session", @@ -193,7 +205,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to create a trigger", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "board-user", source: "session", @@ -215,7 +227,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to update a trigger", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "board-user", source: "session", @@ -235,7 +247,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to manually run a routine", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "board-user", source: "session", @@ -254,7 +266,7 @@ describe("routine routes", () => { it("allows routine creation when the board user has tasks:assign", async () => { mockAccessService.canUser.mockResolvedValue(true); - const app = createApp({ + const app = await createApp({ type: "board", userId: "board-user", source: "session", diff --git a/server/src/__tests__/run-continuations.test.ts b/server/src/__tests__/run-continuations.test.ts new file mode 100644 index 00000000000..a9e1e7b0006 --- /dev/null +++ b/server/src/__tests__/run-continuations.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS, + RUN_LIVENESS_CONTINUATION_REASON, + buildRunLivenessContinuationIdempotencyKey, + decideRunLivenessContinuation, +} from "../services/run-continuations.ts"; + +const companyId = "company-1"; +const agentId = "agent-1"; +const issueId = "issue-1"; +const runId = "run-1"; + +function run(overrides: Record = {}) { + return { + id: runId, + companyId, + agentId, + continuationAttempt: 0, + ...overrides, + } as never; +} + +function issue(overrides: Record = {}) { + return { + id: issueId, + companyId, + identifier: "PAP-1577", + title: "Add bounded liveness continuation wakes", + status: "in_progress", + assigneeAgentId: agentId, + executionState: null, + projectId: null, + ...overrides, + } as never; +} + +function agent(overrides: Record = {}) { + return { + id: agentId, + companyId, + status: "idle", + ...overrides, + } as never; +} + +describe("run liveness continuations", () => { + it("enqueues the first plan_only continuation for the same issue and assignee", () => { + const decision = decideRunLivenessContinuation({ + run: run(), + issue: issue(), + agent: agent(), + livenessState: "plan_only", + livenessReason: "Planned without acting", + nextAction: "Take the first concrete action now.", + budgetBlocked: false, + idempotentWakeExists: false, + }); + + expect(decision.kind).toBe("enqueue"); + if (decision.kind !== "enqueue") return; + expect(decision.nextAttempt).toBe(1); + expect(decision.idempotencyKey).toBe( + buildRunLivenessContinuationIdempotencyKey({ + issueId, + sourceRunId: runId, + livenessState: "plan_only", + nextAttempt: 1, + }), + ); + expect(decision.payload).toMatchObject({ + issueId, + sourceRunId: runId, + livenessState: "plan_only", + livenessReason: "Planned without acting", + continuationAttempt: 1, + maxContinuationAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS, + instruction: "Take the first concrete action now.", + }); + expect(decision.contextSnapshot).toMatchObject({ + issueId, + wakeReason: RUN_LIVENESS_CONTINUATION_REASON, + livenessContinuationAttempt: 1, + livenessContinuationMaxAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS, + livenessContinuationSourceRunId: runId, + livenessContinuationState: "plan_only", + livenessContinuationReason: "Planned without acting", + livenessContinuationInstruction: "Take the first concrete action now.", + }); + }); + + it("enqueues the second empty_response continuation", () => { + const decision = decideRunLivenessContinuation({ + run: run({ continuationAttempt: 1 }), + issue: issue(), + agent: agent(), + livenessState: "empty_response", + livenessReason: "No useful output", + nextAction: null, + budgetBlocked: false, + idempotentWakeExists: false, + }); + + expect(decision.kind).toBe("enqueue"); + if (decision.kind !== "enqueue") return; + expect(decision.nextAttempt).toBe(2); + }); + + it("does not enqueue a third continuation and returns an exhaustion comment", () => { + const decision = decideRunLivenessContinuation({ + run: run({ continuationAttempt: 2 }), + issue: issue(), + agent: agent(), + livenessState: "plan_only", + livenessReason: "Still planning", + nextAction: null, + budgetBlocked: false, + idempotentWakeExists: false, + }); + + expect(decision.kind).toBe("exhausted"); + if (decision.kind !== "exhausted") return; + expect(decision.comment).toContain("Bounded liveness continuation exhausted"); + expect(decision.comment).toContain("Attempts used: 2/2"); + }); + + it("skips non-actionable and guarded issues", () => { + const guardedCases = [ + { livenessState: "advanced" as const }, + { issue: issue({ status: "done" }) }, + { issue: issue({ assigneeAgentId: "other-agent" }) }, + { issue: issue({ executionState: { status: "pending" } }) }, + { agent: agent({ status: "paused" }) }, + { budgetBlocked: true }, + { idempotentWakeExists: true }, + ]; + + for (const guarded of guardedCases) { + const decision = decideRunLivenessContinuation({ + run: run(), + issue: guarded.issue ?? issue(), + agent: guarded.agent ?? agent(), + livenessState: guarded.livenessState ?? "plan_only", + livenessReason: "No progress", + nextAction: null, + budgetBlocked: guarded.budgetBlocked ?? false, + idempotentWakeExists: guarded.idempotentWakeExists ?? false, + }); + + expect(decision.kind).toBe("skip"); + } + }); +}); diff --git a/server/src/__tests__/run-liveness.test.ts b/server/src/__tests__/run-liveness.test.ts new file mode 100644 index 00000000000..08af19b7d26 --- /dev/null +++ b/server/src/__tests__/run-liveness.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { classifyRunLiveness } from "../services/run-liveness.ts"; + +const baseInput = { + runStatus: "succeeded", + issue: { + status: "in_progress", + title: "Implement feature", + description: "Add the requested behavior.", + }, + resultJson: null, + stdoutExcerpt: null, + stderrExcerpt: null, + error: null, + errorCode: null, + continuationAttempt: 0, + evidence: null, +}; + +describe("run liveness classifier", () => { + it("classifies text-only future work as plan_only", () => { + const classification = classifyRunLiveness({ + ...baseInput, + resultJson: { + summary: "I will inspect the repo next and then implement the fix.", + }, + }); + + expect(classification.livenessState).toBe("plan_only"); + expect(classification.nextAction).toContain("inspect the repo"); + }); + + it("classifies empty successful output as empty_response", () => { + const classification = classifyRunLiveness(baseInput); + + expect(classification.livenessState).toBe("empty_response"); + }); + + it("treats issue comments, documents, products, and actions as progress", () => { + const latestEvidenceAt = new Date("2026-04-18T12:00:00Z"); + const classification = classifyRunLiveness({ + ...baseInput, + resultJson: { + summary: "Updated implementation.", + }, + evidence: { + issueCommentsCreated: 1, + documentRevisionsCreated: 1, + workProductsCreated: 1, + toolOrActionEventsCreated: 1, + latestEvidenceAt, + }, + }); + + expect(classification.livenessState).toBe("advanced"); + expect(classification.lastUsefulActionAt).toBe(latestEvidenceAt); + }); + + it("does not treat workspace operations alone as concrete progress", () => { + const classification = classifyRunLiveness({ + ...baseInput, + resultJson: { + summary: "I will inspect the repo next.", + }, + evidence: { + workspaceOperationsCreated: 1, + latestEvidenceAt: new Date("2026-04-18T12:00:00Z"), + }, + }); + + expect(classification.livenessState).toBe("plan_only"); + expect(classification.lastUsefulActionAt).toBeNull(); + }); + + it("exempts planning/document tasks from plan-only retry classification", () => { + const classification = classifyRunLiveness({ + ...baseInput, + issue: { + status: "in_progress", + title: "Draft implementation plan", + description: "Create a plan for the work.", + }, + resultJson: { + summary: "Plan:\n- Inspect files\n- Implement after approval", + }, + }); + + expect(classification.livenessState).toBe("advanced"); + }); + + it("exempts runs that update the plan document from plan-only classification", () => { + const classification = classifyRunLiveness({ + ...baseInput, + resultJson: { + summary: "Next steps:\n- inspect files\n- implement the service", + }, + evidence: { + documentRevisionsCreated: 1, + planDocumentRevisionsCreated: 1, + latestEvidenceAt: new Date("2026-04-18T12:00:00Z"), + }, + }); + + expect(classification.livenessState).toBe("advanced"); + }); + + it("classifies done issues as completed", () => { + const classification = classifyRunLiveness({ + ...baseInput, + issue: { + ...baseInput.issue, + status: "done", + }, + resultJson: { + summary: "Finished the implementation.", + }, + }); + + expect(classification.livenessState).toBe("completed"); + }); + + it("classifies declared blockers as blocked", () => { + const classification = classifyRunLiveness({ + ...baseInput, + resultJson: { + summary: "I cannot proceed because I need access credentials.", + }, + }); + + expect(classification.livenessState).toBe("blocked"); + }); +}); diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index 056e812af3b..f0672d95ae2 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -119,8 +119,24 @@ vi.mock("../services/index.js", () => ({ heartbeatService: vi.fn(() => ({ reapOrphanedRuns: vi.fn(async () => undefined), resumeQueuedRuns: vi.fn(async () => undefined), + reconcileStrandedAssignedIssues: vi.fn(async () => ({ + dispatchRequeued: 0, + continuationRequeued: 0, + escalated: 0, + skipped: 0, + issueIds: [], + })), tickTimers: vi.fn(async () => ({ enqueued: 0 })), })), + instanceSettingsService: vi.fn(() => ({ + getGeneral: vi.fn(async () => ({ + backupRetention: { + dailyDays: 7, + weeklyWeeks: 4, + monthlyMonths: 1, + }, + })), + })), reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })), routineService: vi.fn(() => ({ tickScheduledTriggers: vi.fn(async () => ({ triggered: 0 })), @@ -173,3 +189,27 @@ describe("startServer feedback export wiring", () => { }); }); }); + +describe("startServer PAPERCLIP_API_URL handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.BETTER_AUTH_SECRET = "test-secret"; + delete process.env.PAPERCLIP_API_URL; + }); + + it("uses the externally set PAPERCLIP_API_URL when provided", async () => { + process.env.PAPERCLIP_API_URL = "http://custom-api:3100"; + + const started = await startServer(); + + expect(started.apiUrl).toBe("http://custom-api:3100"); + expect(process.env.PAPERCLIP_API_URL).toBe("http://custom-api:3100"); + }); + + it("falls back to host-based URL when PAPERCLIP_API_URL is not set", async () => { + const started = await startServer(); + + expect(started.apiUrl).toBe("http://127.0.0.1:3210"); + expect(process.env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:3210"); + }); +}); diff --git a/server/src/__tests__/shared-telemetry-events.test.ts b/server/src/__tests__/shared-telemetry-events.test.ts new file mode 100644 index 00000000000..1d6483d2e88 --- /dev/null +++ b/server/src/__tests__/shared-telemetry-events.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; +import { + trackAgentCreated, + trackAgentFirstHeartbeat, + trackAgentTaskCompleted, + trackInstallCompleted, +} from "@paperclipai/shared/telemetry"; +import type { TelemetryClient } from "@paperclipai/shared/telemetry"; + +function createClient(): TelemetryClient { + return { + track: vi.fn(), + hashPrivateRef: vi.fn((value: string) => `hashed:${value}`), + } as unknown as TelemetryClient; +} + +describe("shared telemetry agent events", () => { + it("includes agent_id for agent.created", () => { + const client = createClient(); + + trackAgentCreated(client, { + agentRole: "engineer", + agentId: "11111111-1111-4111-8111-111111111111", + }); + + expect(client.track).toHaveBeenCalledWith("agent.created", { + agent_role: "engineer", + agent_id: "11111111-1111-4111-8111-111111111111", + }); + }); + + it("includes agent_id for agent.first_heartbeat", () => { + const client = createClient(); + + trackAgentFirstHeartbeat(client, { + agentRole: "coder", + agentId: "22222222-2222-4222-8222-222222222222", + }); + + expect(client.track).toHaveBeenCalledWith("agent.first_heartbeat", { + agent_role: "coder", + agent_id: "22222222-2222-4222-8222-222222222222", + }); + }); + + it("includes agent_id for agent.task_completed", () => { + const client = createClient(); + + trackAgentTaskCompleted(client, { + agentRole: "qa", + agentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(client.track).toHaveBeenCalledWith("agent.task_completed", { + agent_role: "qa", + agent_id: "33333333-3333-4333-8333-333333333333", + }); + }); + + it("keeps non-agent event dimensions unchanged", () => { + const client = createClient(); + + trackInstallCompleted(client, { adapterType: "codex_local" }); + + expect(client.track).toHaveBeenCalledWith("install.completed", { + adapter_type: "codex_local", + }); + expect(client.track).not.toHaveBeenCalledWith( + "install.completed", + expect.objectContaining({ agent_id: expect.any(String) }), + ); + }); +}); diff --git a/server/src/__tests__/sidebar-preferences-routes.test.ts b/server/src/__tests__/sidebar-preferences-routes.test.ts new file mode 100644 index 00000000000..b5166f0b35d --- /dev/null +++ b/server/src/__tests__/sidebar-preferences-routes.test.ts @@ -0,0 +1,166 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { sidebarPreferenceRoutes } from "../routes/sidebar-preferences.js"; + +const mockSidebarPreferenceService = vi.hoisted(() => ({ + getCompanyOrder: vi.fn(), + upsertCompanyOrder: vi.fn(), + getProjectOrder: vi.fn(), + upsertProjectOrder: vi.fn(), +})); +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + sidebarPreferenceService: () => mockSidebarPreferenceService, + logActivity: mockLogActivity, +})); + +function createApp(actor: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor as never; + next(); + }); + app.use("/api", sidebarPreferenceRoutes({} as never)); + app.use(errorHandler); + return app; +} + +const ORDERED_IDS = [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", +]; + +describe("sidebar preference routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSidebarPreferenceService.getCompanyOrder.mockResolvedValue({ + orderedIds: ORDERED_IDS, + updatedAt: null, + }); + mockSidebarPreferenceService.upsertCompanyOrder.mockResolvedValue({ + orderedIds: ORDERED_IDS, + updatedAt: null, + }); + mockSidebarPreferenceService.getProjectOrder.mockResolvedValue({ + orderedIds: ORDERED_IDS, + updatedAt: null, + }); + mockSidebarPreferenceService.upsertProjectOrder.mockResolvedValue({ + orderedIds: ORDERED_IDS, + updatedAt: null, + }); + }); + + it("returns company rail order for board users", async () => { + const app = createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }); + + const res = await request(app).get("/api/sidebar-preferences/me"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + orderedIds: ORDERED_IDS, + updatedAt: null, + }); + expect(mockSidebarPreferenceService.getCompanyOrder).toHaveBeenCalledWith("user-1"); + }); + + it("updates company rail order for board users", async () => { + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: ["company-1"], + }); + + const res = await request(app) + .put("/api/sidebar-preferences/me") + .send({ orderedIds: ORDERED_IDS }); + + expect(res.status).toBe(200); + expect(mockSidebarPreferenceService.upsertCompanyOrder).toHaveBeenCalledWith("user-1", ORDERED_IDS); + }); + + it("returns project order for companies the board user can access", async () => { + const app = createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }); + + const res = await request(app).get("/api/companies/company-1/sidebar-preferences/me"); + + expect(res.status).toBe(200); + expect(mockSidebarPreferenceService.getProjectOrder).toHaveBeenCalledWith("company-1", "user-1"); + }); + + it("logs project order updates for company-scoped writes", async () => { + const app = createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + runId: "run-1", + }); + + const res = await request(app) + .put("/api/companies/company-1/sidebar-preferences/me") + .send({ orderedIds: ORDERED_IDS }); + + expect(res.status).toBe(200); + expect(mockSidebarPreferenceService.upsertProjectOrder).toHaveBeenCalledWith("company-1", "user-1", ORDERED_IDS); + expect(mockLogActivity).toHaveBeenCalledWith( + {} as never, + expect.objectContaining({ + companyId: "company-1", + action: "sidebar_preferences.project_order_updated", + details: expect.objectContaining({ + userId: "user-1", + orderedIds: ORDERED_IDS, + }), + }), + ); + }); + + it("rejects company-scoped reads when the board user lacks company access", async () => { + const app = createApp({ + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-2"], + }); + + const res = await request(app).get("/api/companies/company-1/sidebar-preferences/me"); + + expect(res.status).toBe(403); + expect(mockSidebarPreferenceService.getProjectOrder).not.toHaveBeenCalled(); + }); + + it("rejects agent callers", async () => { + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + }); + + const res = await request(app).get("/api/sidebar-preferences/me"); + + expect(res.status).toBe(403); + expect(mockSidebarPreferenceService.getCompanyOrder).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/user-profile-routes.test.ts b/server/src/__tests__/user-profile-routes.test.ts new file mode 100644 index 00000000000..750577860f1 --- /dev/null +++ b/server/src/__tests__/user-profile-routes.test.ts @@ -0,0 +1,205 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + authUsers, + companies, + companyMemberships, + costEvents, + createDb, + issueComments, + issues, +} from "@paperclipai/db"; +import { errorHandler } from "../middleware/index.js"; +import { userProfileRoutes } from "../routes/user-profiles.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres user profile route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("GET /companies/:companyId/users/:userSlug/profile", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + let companyId!: string; + let userId!: string; + let agentId!: string; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-user-profile-route-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + beforeEach(async () => { + companyId = randomUUID(); + userId = randomUUID(); + agentId = randomUUID(); + const now = new Date(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `U${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(authUsers).values({ + id: userId, + name: "Dotta", + email: "dotta@example.com", + emailVerified: true, + image: null, + createdAt: now, + updatedAt: now, + }); + await db.insert(companyMemberships).values({ + companyId, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "owner", + createdAt: now, + updatedAt: now, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Coder", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + }); + }); + + afterEach(async () => { + await db.delete(costEvents); + await db.delete(issueComments); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(agents); + await db.delete(companyMemberships); + await db.delete(authUsers); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId, + companyIds: [companyId], + }; + next(); + }); + app.use("/api", userProfileRoutes(db)); + app.use(errorHandler); + return app; + } + + it("resolves a user slug and returns issue, activity, and attributed cost stats", async () => { + const doneIssueId = randomUUID(); + const openIssueId = randomUUID(); + const now = new Date(); + const older = new Date(now.getTime() - 60_000); + + await db.insert(issues).values([ + { + id: doneIssueId, + companyId, + title: "Ship profile page", + status: "done", + priority: "high", + createdByUserId: userId, + identifier: "USR-1", + completedAt: now, + createdAt: now, + updatedAt: now, + }, + { + id: openIssueId, + companyId, + title: "Review profile copy", + status: "in_progress", + priority: "medium", + assigneeUserId: userId, + identifier: "USR-2", + createdAt: older, + updatedAt: older, + }, + ]); + await db.insert(issueComments).values({ + companyId, + issueId: openIssueId, + authorUserId: userId, + body: "Looks good.", + createdAt: now, + updatedAt: now, + }); + await db.insert(activityLog).values({ + companyId, + actorType: "user", + actorId: userId, + action: "issue.updated", + entityType: "issue", + entityId: doneIssueId, + createdAt: now, + }); + await db.insert(costEvents).values({ + companyId, + agentId, + issueId: doneIssueId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-test", + inputTokens: 120, + cachedInputTokens: 30, + outputTokens: 40, + costCents: 42, + occurredAt: now, + }); + + const response = await request(createApp()).get(`/api/companies/${companyId}/users/dotta/profile`); + + expect(response.status).toBe(200); + expect(response.body.user.slug).toBe("dotta"); + expect(response.body.user.membershipRole).toBe("owner"); + expect(response.body.stats).toHaveLength(3); + + const all = response.body.stats.find((entry: { key: string }) => entry.key === "all"); + expect(all).toMatchObject({ + touchedIssues: 2, + createdIssues: 1, + completedIssues: 1, + assignedOpenIssues: 1, + commentCount: 1, + activityCount: 1, + costCents: 42, + inputTokens: 120, + cachedInputTokens: 30, + outputTokens: 40, + costEventCount: 1, + }); + expect(response.body.recentIssues.map((issue: { identifier: string }) => issue.identifier)).toEqual(["USR-1", "USR-2"]); + expect(response.body.recentActivity[0].action).toBe("issue.updated"); + expect(response.body.topAgents[0]).toMatchObject({ agentId, agentName: "Coder", costCents: 42 }); + expect(response.body.topProviders[0]).toMatchObject({ provider: "openai", model: "gpt-test", costCents: 42 }); + }); +}); diff --git a/server/src/__tests__/vite-html-renderer.test.ts b/server/src/__tests__/vite-html-renderer.test.ts new file mode 100644 index 00000000000..add3ba80045 --- /dev/null +++ b/server/src/__tests__/vite-html-renderer.test.ts @@ -0,0 +1,97 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createCachedViteHtmlRenderer, type ViteWatcherHost } from "../vite-html-renderer.js"; + +function createWatcher() { + const listeners = new Map void>>(); + + return { + on(event: string, listener: (file: string) => void) { + if (!listeners.has(event)) listeners.set(event, new Set()); + listeners.get(event)?.add(listener); + }, + off(event: string, listener: (file: string) => void) { + listeners.get(event)?.delete(listener); + }, + emit(event: string, file: string) { + for (const listener of listeners.get(event) ?? []) { + listener(file); + } + }, + }; +} + +describe("createCachedViteHtmlRenderer", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reuses the injected dev html shell until index.html changes", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-")); + tempDirs.push(tempDir); + const indexPath = path.join(tempDir, "index.html"); + fs.writeFileSync( + indexPath, + 'v1', + "utf8", + ); + + const watcher = createWatcher(); + const vite: ViteWatcherHost = { + watcher, + }; + + const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir }); + + await expect(renderer.render("/")).resolves.toContain("/@vite/client"); + await expect(renderer.render("/")).resolves.toContain('"/@react-refresh"'); + const first = await renderer.render("/"); + const second = await renderer.render("/issues"); + expect(first).toBe(second); + expect(first.match(/\/@vite\/client/g)?.length).toBe(1); + expect(first).toContain("window.$RefreshReg$"); + + const sourcePath = path.join(tempDir, "src", "main.tsx"); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, "export {};\n", "utf8"); + watcher.emit("change", sourcePath); + expect(await renderer.render("/")).toBe(first); + + fs.writeFileSync( + indexPath, + 'v2', + "utf8", + ); + watcher.emit("change", indexPath); + + await expect(renderer.render("/")).resolves.toContain("v2"); + + renderer.dispose(); + }); + + it("does not duplicate the vite client tag or react refresh preamble when already present", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-")); + tempDirs.push(tempDir); + fs.writeFileSync( + path.join(tempDir, "index.html"), + '', + "utf8", + ); + + const vite: ViteWatcherHost = { + watcher: createWatcher(), + }; + + const renderer = createCachedViteHtmlRenderer({ vite, uiRoot: tempDir }); + + const html = await renderer.render("/"); + expect(html.match(/\/@vite\/client/g)?.length).toBe(1); + expect(html.match(/\/@react-refresh/g)?.length).toBe(1); + }); +}); diff --git a/server/src/__tests__/workspace-runtime-routes-authz.test.ts b/server/src/__tests__/workspace-runtime-routes-authz.test.ts new file mode 100644 index 00000000000..de5b490c912 --- /dev/null +++ b/server/src/__tests__/workspace-runtime-routes-authz.test.ts @@ -0,0 +1,437 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockProjectService = vi.hoisted(() => ({ + create: vi.fn(), + createWorkspace: vi.fn(), + getById: vi.fn(), + listWorkspaces: vi.fn(), + resolveByReference: vi.fn(), + update: vi.fn(), + updateWorkspace: vi.fn(), +})); + +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), +})); + +const mockSecretService = vi.hoisted(() => ({ + normalizeEnvBindingsForPersistence: vi.fn(), +})); + +const mockWorkspaceOperationService = vi.hoisted(() => ({})); +const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); +const mockAssertCanManageProjectWorkspaceRuntimeServices = vi.hoisted(() => vi.fn()); +const mockAssertCanManageExecutionWorkspaceRuntimeServices = vi.hoisted(() => vi.fn()); + +function registerModuleMocks() { + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, + })); + + vi.doMock("../services/index.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, + secretService: () => mockSecretService, + workspaceOperationService: () => mockWorkspaceOperationService, + })); + + vi.doMock("../services/workspace-runtime.js", () => ({ + cleanupExecutionWorkspaceArtifacts: vi.fn(), + startRuntimeServicesForWorkspaceControl: vi.fn(), + stopRuntimeServicesForExecutionWorkspace: vi.fn(), + stopRuntimeServicesForProjectWorkspace: vi.fn(), + })); + + vi.doMock("../routes/workspace-runtime-service-authz.js", () => ({ + assertCanManageProjectWorkspaceRuntimeServices: mockAssertCanManageProjectWorkspaceRuntimeServices, + assertCanManageExecutionWorkspaceRuntimeServices: mockAssertCanManageExecutionWorkspaceRuntimeServices, + })); +} + +async function createProjectApp(actor: Record) { + const { projectRoutes } = await import("../routes/projects.js"); + const { errorHandler } = await import("../middleware/index.js"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", projectRoutes({} as any)); + app.use(errorHandler); + return app; +} + +async function createExecutionWorkspaceApp(actor: Record) { + const { executionWorkspaceRoutes } = await import("../routes/execution-workspaces.js"); + const { errorHandler } = await import("../middleware/index.js"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", executionWorkspaceRoutes({} as any)); + app.use(errorHandler); + return app; +} + +function buildProject(overrides: Record = {}) { + return { + id: "project-1", + companyId: "company-1", + urlKey: "project-1", + goalId: null, + goalIds: [], + goals: [], + name: "Project", + description: null, + status: "backlog", + leadAgentId: null, + targetDate: null, + color: null, + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: null, + workspaces: [], + primaryWorkspace: null, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function buildExecutionWorkspace(overrides: Record = {}) { + return { + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + sourceIssueId: null, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Workspace", + status: "active", + cwd: "/tmp/workspace", + repoUrl: null, + baseRef: "main", + branchName: "feature/test", + providerType: "git_worktree", + providerRef: null, + derivedFromExecutionWorkspaceId: null, + lastUsedAt: new Date(), + openedAt: new Date(), + closedAt: null, + cleanupEligibleAt: null, + cleanupReason: null, + config: null, + metadata: null, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe("workspace runtime service route authorization", () => { + const projectId = "11111111-1111-4111-8111-111111111111"; + const workspaceId = "22222222-2222-4222-8222-222222222222"; + const executionWorkspaceId = "33333333-3333-4333-8333-333333333333"; + + beforeEach(() => { + vi.resetModules(); + registerModuleMocks(); + vi.clearAllMocks(); + mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env); + mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); + mockProjectService.create.mockResolvedValue(buildProject()); + mockProjectService.update.mockResolvedValue(buildProject()); + mockProjectService.createWorkspace.mockResolvedValue({ + id: workspaceId, + companyId: "company-1", + projectId, + name: "Workspace", + sourceType: "local_path", + cwd: "/tmp/project", + repoUrl: null, + repoRef: null, + defaultRef: null, + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: null, + runtimeConfig: null, + isPrimary: false, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + mockProjectService.listWorkspaces.mockResolvedValue([{ + id: workspaceId, + companyId: "company-1", + projectId, + name: "Workspace", + sourceType: "local_path", + cwd: "/tmp/project", + repoUrl: null, + repoRef: null, + defaultRef: null, + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: null, + runtimeConfig: null, + isPrimary: false, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + }]); + mockProjectService.updateWorkspace.mockResolvedValue({ + id: workspaceId, + companyId: "company-1", + projectId, + name: "Workspace", + sourceType: "local_path", + cwd: "/tmp/project", + repoUrl: null, + repoRef: null, + defaultRef: null, + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: null, + runtimeConfig: null, + isPrimary: false, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + mockExecutionWorkspaceService.update.mockResolvedValue(buildExecutionWorkspace()); + mockAssertCanManageProjectWorkspaceRuntimeServices.mockResolvedValue(undefined); + mockAssertCanManageExecutionWorkspaceRuntimeServices.mockResolvedValue(undefined); + }); + + it("rejects agent callers for project workspace runtime service mutations when workspace auth denies access", async () => { + const { forbidden } = await import("../errors.js"); + mockProjectService.getById.mockResolvedValue(buildProject({ + id: projectId, + workspaces: [{ + id: workspaceId, + companyId: "company-1", + projectId, + name: "Workspace", + sourceType: "local_path", + cwd: "/tmp/project", + repoUrl: null, + repoRef: null, + defaultRef: null, + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: null, + runtimeConfig: null, + isPrimary: false, + runtimeServices: [], + createdAt: new Date(), + updatedAt: new Date(), + }], + })); + mockAssertCanManageProjectWorkspaceRuntimeServices.mockRejectedValue( + forbidden("Missing permission to manage workspace runtime services"), + ); + const app = await createProjectApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/start`) + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Missing permission"); + expect(mockProjectService.getById).toHaveBeenCalledWith(projectId); + expect(mockAssertCanManageProjectWorkspaceRuntimeServices).toHaveBeenCalled(); + }, 15000); + + it("rejects agent callers that create project execution workspace commands", async () => { + const app = await createProjectApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/company-1/projects") + .send({ + name: "Exploit", + executionWorkspacePolicy: { + enabled: true, + workspaceStrategy: { + type: "git_worktree", + provisionCommand: "touch /tmp/paperclip-rce", + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockProjectService.create).not.toHaveBeenCalled(); + }); + + it("rejects agent callers that update project workspace cleanup commands", async () => { + mockProjectService.getById.mockResolvedValue(buildProject()); + const app = await createProjectApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/projects/${projectId}/workspaces/${workspaceId}`) + .send({ + cleanupCommand: "rm -rf /tmp/paperclip-rce", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockProjectService.updateWorkspace).not.toHaveBeenCalled(); + }); + + it("allows board callers through the project workspace runtime auth gate", async () => { + mockProjectService.getById.mockResolvedValue(null); + const app = await createProjectApp({ + type: "board", + userId: "board-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/start`) + .send({}); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("Project not found"); + expect(mockProjectService.getById).toHaveBeenCalledWith(projectId); + }); + + it("rejects agent callers for execution workspace runtime service mutations when workspace auth denies access", async () => { + const { forbidden } = await import("../errors.js"); + mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId })); + mockAssertCanManageExecutionWorkspaceRuntimeServices.mockRejectedValue( + forbidden("Missing permission to manage workspace runtime services"), + ); + const app = await createExecutionWorkspaceApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/restart`) + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Missing permission"); + expect(mockExecutionWorkspaceService.getById).toHaveBeenCalledWith(executionWorkspaceId); + expect(mockAssertCanManageExecutionWorkspaceRuntimeServices).toHaveBeenCalled(); + }, 15000); + + it("rejects agent callers that patch execution workspace command config", async () => { + mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId })); + const app = await createExecutionWorkspaceApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/execution-workspaces/${executionWorkspaceId}`) + .send({ + config: { + cleanupCommand: "rm -rf /tmp/paperclip-rce", + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockExecutionWorkspaceService.update).not.toHaveBeenCalled(); + }); + + it("rejects agent callers that smuggle execution workspace commands through metadata.config", async () => { + mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId })); + const app = await createExecutionWorkspaceApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .patch(`/api/execution-workspaces/${executionWorkspaceId}`) + .send({ + metadata: { + config: { + provisionCommand: "touch /tmp/paperclip-rce", + }, + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(mockExecutionWorkspaceService.update).not.toHaveBeenCalled(); + }); + + it("allows board callers through the execution workspace runtime auth gate", async () => { + mockExecutionWorkspaceService.getById.mockResolvedValue(null); + const app = await createExecutionWorkspaceApp({ + type: "board", + userId: "board-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/restart`) + .send({}); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("Execution workspace not found"); + expect(mockExecutionWorkspaceService.getById).toHaveBeenCalledWith(executionWorkspaceId); + }); +}); diff --git a/server/src/__tests__/workspace-runtime-service-authz.test.ts b/server/src/__tests__/workspace-runtime-service-authz.test.ts new file mode 100644 index 00000000000..87460487df6 --- /dev/null +++ b/server/src/__tests__/workspace-runtime-service-authz.test.ts @@ -0,0 +1,279 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + createDb, + executionWorkspaces, + issues, + projectWorkspaces, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { + assertCanManageExecutionWorkspaceRuntimeServices, + assertCanManageProjectWorkspaceRuntimeServices, +} from "../routes/workspace-runtime-service-authz.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres workspace runtime auth tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("workspace runtime service authz helper", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-workspace-runtime-authz-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompany() { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `PAP-${companyId.slice(0, 8)}`, + requireBoardApprovalForNewAgents: false, + }); + return companyId; + } + + async function seedProjectWorkspace(companyId: string) { + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace authz", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "local_path", + cwd: "/tmp/paperclip-authz-project", + isPrimary: true, + }); + return { projectId, projectWorkspaceId }; + } + + async function seedExecutionWorkspace(companyId: string, projectId: string, projectWorkspaceId: string) { + const executionWorkspaceId = randomUUID(); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Execution workspace", + status: "active", + providerType: "local_fs", + cwd: "/tmp/paperclip-authz-execution", + }); + return executionWorkspaceId; + } + + async function seedAgent( + companyId: string, + input: { role?: string; reportsTo?: string | null; name?: string } = {}, + ) { + const agentId = randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId, + name: input.name ?? "Agent", + role: input.role ?? "engineer", + reportsTo: input.reportsTo ?? null, + }); + return agentId; + } + + it("allows board actors to manage project workspace runtime services", async () => { + const companyId = await seedCompany(); + const { projectWorkspaceId } = await seedProjectWorkspace(companyId); + + await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { + actor: { + type: "board", + userId: "board-1", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }, + } as any, { + companyId, + projectWorkspaceId, + })).resolves.toBeUndefined(); + }); + + it("allows CEO agents to manage any project workspace runtime services in their company", async () => { + const companyId = await seedCompany(); + const { projectWorkspaceId } = await seedProjectWorkspace(companyId); + const ceoAgentId = await seedAgent(companyId, { role: "ceo", name: "CEO" }); + + await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId: ceoAgentId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + projectWorkspaceId, + })).resolves.toBeUndefined(); + }); + + it("allows agents with a non-terminal assigned issue in the target project workspace", async () => { + const companyId = await seedCompany(); + const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); + const agentId = await seedAgent(companyId, { name: "Engineer" }); + + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + projectWorkspaceId, + title: "Use this workspace", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + }); + + await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + projectWorkspaceId, + })).resolves.toBeUndefined(); + }); + + it("allows managers to manage execution workspace runtime services for their reporting subtree", async () => { + const companyId = await seedCompany(); + const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); + const executionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId); + const managerId = await seedAgent(companyId, { role: "cto", name: "Manager" }); + const reportId = await seedAgent(companyId, { reportsTo: managerId, name: "Report" }); + + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId, + title: "Use execution workspace", + status: "in_progress", + priority: "medium", + assigneeAgentId: reportId, + }); + + await expect(assertCanManageExecutionWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId: managerId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + executionWorkspaceId, + })).resolves.toBeUndefined(); + }); + + it("rejects unrelated same-company agents without matching workspace assignments", async () => { + const companyId = await seedCompany(); + const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); + const executionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId); + const assignedAgentId = await seedAgent(companyId, { name: "Assigned" }); + const unrelatedAgentId = await seedAgent(companyId, { name: "Unrelated" }); + + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId, + title: "Assigned issue", + status: "todo", + priority: "medium", + assigneeAgentId: assignedAgentId, + }); + + await expect(assertCanManageExecutionWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId: unrelatedAgentId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + executionWorkspaceId, + })).rejects.toMatchObject({ + status: 403, + message: "Missing permission to manage workspace runtime services", + }); + }); + + it("rejects completed workspace assignments so stale issues do not keep access alive", async () => { + const companyId = await seedCompany(); + const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); + const agentId = await seedAgent(companyId, { name: "Engineer" }); + + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + projectWorkspaceId, + title: "Completed issue", + status: "done", + priority: "medium", + assigneeAgentId: agentId, + }); + + await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { + actor: { + type: "agent", + agentId, + companyId, + source: "agent_key", + }, + } as any, { + companyId, + projectWorkspaceId, + })).rejects.toMatchObject({ + status: 403, + message: "Missing permission to manage workspace runtime services", + }); + }); +}); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index ff492ad2fe6..50e89c017cf 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -19,16 +19,21 @@ import { } from "@paperclipai/db"; import { eq } from "drizzle-orm"; import { + buildWorkspaceRuntimeDesiredStatePatch, cleanupExecutionWorkspaceArtifacts, + ensurePersistedExecutionWorkspaceAvailable, ensureServerWorkspaceLinksCurrent, ensureRuntimeServicesForRun, + listConfiguredRuntimeServiceEntries, normalizeAdapterManagedRuntimeServices, reconcilePersistedRuntimeServicesOnStartup, realizeExecutionWorkspace, releaseRuntimeServicesForRun, resetRuntimeServicesForTests, + resolveWorkspaceRuntimeReadinessTimeoutSec, resolveShell, sanitizeRuntimeServiceBaseEnv, + startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForExecutionWorkspace, type RealizedExecutionWorkspace, } from "../services/workspace-runtime.ts"; @@ -367,6 +372,197 @@ describe("realizeExecutionWorkspace", () => { expect(second.branchName).toBe(first.branchName); }); + it("rejects reusing an empty directory that only looks like a worktree because it sits inside the repo", async () => { + const repoRoot = await createTempRepo(); + const branchName = "PAP-447-add-worktree-support"; + const poisonedPath = path.join(repoRoot, ".paperclip", "worktrees", branchName); + await fs.mkdir(poisonedPath, { recursive: true }); + + await expect( + realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-447", + title: "Add Worktree Support", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }), + ).rejects.toThrow(/not a reusable git worktree \(path is not registered in `git worktree list`\)\./); + }); + + it("reuses the current linked worktree instead of nesting another worktree inside it", async () => { + const repoRoot = await createTempRepo(); + const branchName = "PAP-1355-worktree-reuse"; + const currentWorktree = path.join(repoRoot, ".paperclip", "worktrees", branchName); + + await fs.mkdir(path.dirname(currentWorktree), { recursive: true }); + await execFileAsync("git", ["worktree", "add", "-b", branchName, currentWorktree, "HEAD"], { cwd: repoRoot }); + + const realized = await realizeExecutionWorkspace({ + base: { + baseCwd: currentWorktree, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-1355", + title: "worktree reuse", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + const expectedWorktreePath = await fs.realpath(currentWorktree); + expect(realized.created).toBe(false); + await expect(fs.realpath(realized.cwd)).resolves.toBe(expectedWorktreePath); + await expect(fs.realpath(realized.worktreePath ?? "")).resolves.toBe(expectedWorktreePath); + }); + + it("rejects reusing a linked worktree whose branch drifted from the expected issue branch", async () => { + const repoRoot = await createTempRepo(); + + const initial = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-447", + title: "Add Worktree Support", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + await runGit(initial.cwd, ["checkout", "-b", "unexpected-branch"]); + + await expect( + realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-447", + title: "Add Worktree Support", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }), + ).rejects.toThrow(/not a reusable git worktree \(worktree HEAD is on "unexpected-branch" instead of "PAP-447-add-worktree-support"\)\./); + }); + + it("reuses an already checked out branch from git worktree metadata even when the target path differs", async () => { + const repoRoot = await createTempRepo(); + const branchName = "PAP-1355-worktree-reuse"; + const existingWorktree = path.join(repoRoot, ".paperclip", "worktrees", branchName); + const { recorder, operations } = createWorkspaceOperationRecorderDouble(); + + await fs.mkdir(path.dirname(existingWorktree), { recursive: true }); + await execFileAsync("git", ["worktree", "add", "-b", branchName, existingWorktree, "HEAD"], { cwd: repoRoot }); + + const realized = await realizeExecutionWorkspace({ + base: { + baseCwd: existingWorktree, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + worktreeParentDir: ".paperclip/other-worktrees", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-1355", + title: "worktree reuse", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + recorder, + }); + + const expectedWorktreePath = await fs.realpath(existingWorktree); + expect(realized.created).toBe(false); + await expect(fs.realpath(realized.cwd)).resolves.toBe(expectedWorktreePath); + expect(operations).toHaveLength(1); + expect(operations[0]?.phase).toBe("worktree_prepare"); + expect(operations[0]?.command).toBeNull(); + expect(operations[0]?.metadata).toMatchObject({ + branchName, + created: false, + reused: true, + worktreePath: expectedWorktreePath, + }); + }); + it("slugifies unsafe issue titles for branch names and worktree folders", async () => { const repoRoot = await createTempRepo(); @@ -701,7 +897,7 @@ describe("realizeExecutionWorkspace", () => { await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]); try { - const workspace = await realizeExecutionWorkspace({ + const workspaceInput = { base: { baseCwd: repoRoot, source: "project_primary", @@ -727,7 +923,8 @@ describe("realizeExecutionWorkspace", () => { name: "Codex Coder", companyId: "company-1", }, - }); + } satisfies Parameters[0]; + const workspace = await realizeExecutionWorkspace(workspaceInput); const configPath = path.join(workspace.cwd, ".paperclip", "config.json"); const envPath = path.join(workspace.cwd, ".paperclip", ".env"); @@ -758,6 +955,34 @@ describe("realizeExecutionWorkspace", () => { process.chdir(workspace.cwd); expect(resolvePaperclipConfigPath()).toBe(configPath); + + const preservedPort = 39999; + await fs.writeFile( + configPath, + JSON.stringify( + { + ...configContents, + server: { + ...configContents.server, + port: preservedPort, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + await fs.writeFile(envPath, `${envContents}PAPERCLIP_WORKTREE_COLOR="#112233"\n`, "utf8"); + + const reusedWorkspace = await realizeExecutionWorkspace(workspaceInput); + const reusedConfigContents = JSON.parse(await fs.readFile(configPath, "utf8")); + const reusedEnvContents = await fs.readFile(envPath, "utf8"); + + expect(reusedWorkspace.cwd).toBe(workspace.cwd); + expect(reusedWorkspace.created).toBe(false); + expect(reusedConfigContents.server.port).toBe(preservedPort); + expect(reusedConfigContents.database.embeddedPostgresDataDir).toBe(path.join(expectedInstanceRoot, "db")); + expect(reusedEnvContents).toContain('PAPERCLIP_WORKTREE_COLOR="#112233"'); } finally { process.chdir(previousCwd); } @@ -940,6 +1165,137 @@ describe("realizeExecutionWorkspace", () => { ); }, 30_000); + it("fails instead of writing an unseeded fallback config when worktree init errors after CLI detection succeeds", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-provision-fail-")); + const baseRoot = path.join(tempRoot, "base"); + const worktreeRoot = path.join(tempRoot, "worktree"); + const fakeBin = path.join(tempRoot, "bin"); + const fakePnpmPath = path.join(fakeBin, "pnpm"); + const scriptPath = path.join(worktreeRoot, "provision-worktree.sh"); + + try { + await fs.mkdir(baseRoot, { recursive: true }); + await fs.mkdir(worktreeRoot, { recursive: true }); + await fs.mkdir(fakeBin, { recursive: true }); + await fs.copyFile(provisionWorktreeScriptPath, scriptPath); + await fs.chmod(scriptPath, 0o755); + await fs.writeFile( + fakePnpmPath, + [ + "#!/bin/sh", + "if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then", + " exit 0", + "fi", + "if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"worktree\" ] && [ \"$3\" = \"init\" ]; then", + " echo \"simulated init failure\" >&2", + " exit 42", + "fi", + "exit 0", + "", + ].join("\n"), + "utf8", + ); + await fs.chmod(fakePnpmPath, 0o755); + + let caught: Error | null = null; + try { + await execFileAsync(scriptPath, [], { + cwd: worktreeRoot, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot, + PAPERCLIP_WORKSPACE_CWD: worktreeRoot, + }, + }); + } catch (error) { + caught = error as Error; + } + + expect(caught).toBeTruthy(); + expect(String(caught)).toContain("simulated init failure"); + await expect(fs.stat(path.join(worktreeRoot, ".paperclip", "config.json"))).rejects.toThrow(); + await expect(fs.stat(path.join(worktreeRoot, ".paperclip", ".env"))).rejects.toThrow(); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("retries worktree-local pnpm install without a frozen lockfile when the lockfile is outdated", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-outdated-lockfile-")); + const baseRoot = path.join(tempRoot, "base"); + const worktreeRoot = path.join(tempRoot, "worktree"); + const fakeBin = path.join(tempRoot, "bin"); + const fakePnpmPath = path.join(fakeBin, "pnpm"); + const scriptPath = path.join(worktreeRoot, "provision-worktree.sh"); + + try { + await fs.mkdir(path.join(baseRoot, "node_modules"), { recursive: true }); + await fs.mkdir(worktreeRoot, { recursive: true }); + await fs.mkdir(fakeBin, { recursive: true }); + await fs.copyFile(provisionWorktreeScriptPath, scriptPath); + await fs.chmod(scriptPath, 0o755); + await fs.writeFile( + path.join(worktreeRoot, "package.json"), + JSON.stringify( + { + name: "workspace-root", + private: true, + packageManager: "pnpm@9.15.4", + }, + null, + 2, + ), + "utf8", + ); + await fs.writeFile( + path.join(worktreeRoot, "pnpm-lock.yaml"), + ["lockfileVersion: '9.0'", "", "importers:", " .: {}", ""].join("\n"), + "utf8", + ); + await fs.writeFile( + fakePnpmPath, + [ + "#!/bin/sh", + "if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then", + " exit 1", + "fi", + "if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--frozen-lockfile\" ]; then", + " echo \"ERR_PNPM_OUTDATED_LOCKFILE\" >&2", + " exit 1", + "fi", + "if [ \"$1\" = \"install\" ] && [ \"$2\" = \"--no-frozen-lockfile\" ]; then", + " mkdir -p \"$PWD/node_modules\"", + " : > \"$PWD/node_modules/.retry-success\"", + " exit 0", + "fi", + "exit 0", + "", + ].join("\n"), + "utf8", + ); + await fs.chmod(fakePnpmPath, 0o755); + + const result = await execFileAsync(scriptPath, [], { + cwd: worktreeRoot, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot, + PAPERCLIP_WORKSPACE_CWD: worktreeRoot, + }, + }); + + expect(result.stderr).toContain("retrying install without --frozen-lockfile"); + await expect(fs.readFile(path.join(worktreeRoot, "node_modules", ".retry-success"), "utf8")).resolves.toBe(""); + await expect(fs.readFile(path.join(worktreeRoot, ".paperclip", "config.json"), "utf8")).resolves.toContain( + "\"database\"", + ); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + it( "provisions worktree-local pnpm node_modules instead of reusing base-repo links", async () => { @@ -1197,6 +1553,187 @@ describe("realizeExecutionWorkspace", () => { expect(actualHead).toBe(expectedHead); }); + it("reattaches a missing persisted git worktree before manual control starts it", async () => { + const repoRoot = await createTempRepo(); + const branchName = "PAP-451-restore-persisted-worktree"; + await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); + await fs.writeFile( + path.join(repoRoot, "scripts", "restore.sh"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BRANCH\" > .paperclip-restored-branch", + ].join("\n"), + "utf8", + ); + await fs.chmod(path.join(repoRoot, "scripts", "restore.sh"), 0o755); + await runGit(repoRoot, ["add", "scripts/restore.sh"]); + await runGit(repoRoot, ["commit", "-m", "Add restore script"]); + + await runGit(repoRoot, ["checkout", "-b", branchName]); + await fs.writeFile(path.join(repoRoot, "feature.txt"), "persisted\n", "utf8"); + await runGit(repoRoot, ["add", "feature.txt"]); + await runGit(repoRoot, ["commit", "-m", "Add persisted feature"]); + const expectedHead = (await execFileAsync("git", ["rev-parse", branchName], { cwd: repoRoot })).stdout.trim(); + await runGit(repoRoot, ["checkout", "main"]); + + const initial = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + provisionCommand: "bash ./scripts/restore.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-451", + title: "Restore persisted worktree", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + await fs.rm(initial.cwd, { recursive: true, force: true }); + + const restored = await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: initial.cwd, + providerRef: initial.worktreePath, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName, + config: { + provisionCommand: "bash ./scripts/restore.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-451", + title: "Restore persisted worktree", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + expect(restored).not.toBeNull(); + expect(restored?.cwd).toBe(initial.cwd); + await expect(fs.readFile(path.join(initial.cwd, "feature.txt"), "utf8")).resolves.toBe("persisted\n"); + await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-branch"), "utf8")).resolves.toBe(`${branchName}\n`); + const actualHead = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: initial.cwd })).stdout.trim(); + expect(actualHead).toBe(expectedHead); + }); + + it("reprovisions an existing persisted git worktree before manual control starts it", async () => { + const repoRoot = await createTempRepo(); + await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); + await fs.writeFile( + path.join(repoRoot, "scripts", "restore.sh"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "printf 'reprovisioned\\n' > .paperclip-restored-state", + ].join("\n"), + "utf8", + ); + await fs.chmod(path.join(repoRoot, "scripts", "restore.sh"), 0o755); + await runGit(repoRoot, ["add", "scripts/restore.sh"]); + await runGit(repoRoot, ["commit", "-m", "Add reprovision script"]); + + const initial = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + provisionCommand: "bash ./scripts/restore.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-452", + title: "Reprovision persisted worktree", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + await fs.rm(path.join(initial.cwd, ".paperclip-restored-state"), { force: true }); + + await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + workspace: { + mode: "isolated_workspace", + strategyType: "git_worktree", + cwd: initial.cwd, + providerRef: initial.worktreePath, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + repoUrl: null, + baseRef: "HEAD", + branchName: initial.branchName, + config: { + provisionCommand: "bash ./scripts/restore.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-452", + title: "Reprovision persisted worktree", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-state"), "utf8")).resolves.toBe("reprovisioned\n"); + }); + it("auto-detects the default branch when baseRef is not configured", async () => { // Create a repo with "master" as default branch (not "main") const repoRoot = await createTempRepo("master"); @@ -1884,6 +2421,234 @@ describe("ensureRuntimeServicesForRun", () => { await releaseRuntimeServicesForRun(runId); leasedRunIds.delete(runId); }); + + it("starts only the selected workspace-controlled runtime service", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-control-start-")); + const workspace = buildWorkspace(workspaceRoot); + + const services = await startRuntimeServicesForWorkspaceControl({ + actor: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + issue: null, + workspace, + executionWorkspaceId: "execution-workspace-control-start", + config: { + workspaceRuntime: { + services: [ + { + name: "web", + command: + "node -e \"require('node:http').createServer((req,res)=>res.end('web')).listen(Number(process.env.PORT), '127.0.0.1')\"", + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + lifecycle: "shared", + reuseScope: "execution_workspace", + }, + { + name: "worker", + command: + "node -e \"require('node:http').createServer((req,res)=>res.end('worker')).listen(Number(process.env.PORT), '127.0.0.1')\"", + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + lifecycle: "shared", + reuseScope: "execution_workspace", + }, + ], + }, + }, + adapterEnv: {}, + serviceIndex: 1, + }); + + expect(services).toHaveLength(1); + expect(services[0]?.serviceName).toBe("worker"); + await expect(fetch(services[0]!.url!)).resolves.toMatchObject({ ok: true }); + + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-control-start", + workspaceCwd: workspace.cwd, + }); + }); + + it("stops only the selected execution workspace runtime service", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-control-stop-")); + const workspace = buildWorkspace(workspaceRoot); + + const services = await startRuntimeServicesForWorkspaceControl({ + actor: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + issue: null, + workspace, + executionWorkspaceId: "execution-workspace-control-stop", + config: { + workspaceRuntime: { + services: [ + { + name: "web", + command: + "node -e \"require('node:http').createServer((req,res)=>res.end('web')).listen(Number(process.env.PORT), '127.0.0.1')\"", + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + lifecycle: "shared", + reuseScope: "execution_workspace", + stopPolicy: { + type: "manual", + }, + }, + { + name: "worker", + command: + "node -e \"require('node:http').createServer((req,res)=>res.end('worker')).listen(Number(process.env.PORT), '127.0.0.1')\"", + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + lifecycle: "shared", + reuseScope: "execution_workspace", + stopPolicy: { + type: "manual", + }, + }, + ], + }, + }, + adapterEnv: {}, + }); + + expect(services).toHaveLength(2); + const web = services.find((service) => service.serviceName === "web"); + const worker = services.find((service) => service.serviceName === "worker"); + + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-control-stop", + workspaceCwd: workspace.cwd, + runtimeServiceId: web?.id ?? null, + }); + + await expect(fetch(web!.url!)).rejects.toThrow(); + await expect(fetch(worker!.url!)).resolves.toMatchObject({ ok: true }); + + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-control-stop", + workspaceCwd: workspace.cwd, + runtimeServiceId: worker?.id ?? null, + }); + }); +}); + +describe("buildWorkspaceRuntimeDesiredStatePatch", () => { + it("derives service entries from command-first runtime config", () => { + const services = listConfiguredRuntimeServiceEntries({ + workspaceRuntime: { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev" }, + { id: "db-migrate", name: "db:migrate", kind: "job", command: "pnpm db:migrate" }, + ], + }, + }); + + expect(services).toEqual([ + expect.objectContaining({ + id: "web", + kind: "service", + command: "pnpm dev", + }), + ]); + }); + + it("preserves sibling service state when updating a single configured runtime service", () => { + const patch = buildWorkspaceRuntimeDesiredStatePatch({ + config: { + workspaceRuntime: { + services: [ + { name: "web", command: "pnpm dev" }, + { name: "worker", command: "pnpm worker" }, + ], + }, + }, + currentDesiredState: "running", + currentServiceStates: null, + action: "stop", + serviceIndex: 1, + }); + + expect(patch).toEqual({ + desiredState: "running", + serviceStates: { + "0": "running", + "1": "stopped", + }, + }); + }); +}); + +describe("resolveWorkspaceRuntimeReadinessTimeoutSec", () => { + it("extends the default readiness timeout for dev-server commands", () => { + expect( + resolveWorkspaceRuntimeReadinessTimeoutSec({ + command: "pnpm dev", + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + }, + }), + ).toBe(90); + expect( + resolveWorkspaceRuntimeReadinessTimeoutSec({ + command: "npm run dev -- --host 127.0.0.1", + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + }, + }), + ).toBe(90); + }); + + it("keeps explicit readiness timeouts and non-dev defaults unchanged", () => { + expect( + resolveWorkspaceRuntimeReadinessTimeoutSec({ + command: "pnpm dev", + readiness: { + type: "http", + timeoutSec: 12, + urlTemplate: "http://127.0.0.1:{{port}}", + }, + }), + ).toBe(12); + expect( + resolveWorkspaceRuntimeReadinessTimeoutSec({ + command: "node server.js", + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + }, + }), + ).toBe(30); + }); }); describe("resolveShell (shell fallback)", () => { @@ -1900,13 +2665,18 @@ describe("resolveShell (shell fallback)", () => { }); it("returns process.env.SHELL when set", () => { - process.env.SHELL = "/usr/bin/zsh"; - expect(resolveShell()).toBe("/usr/bin/zsh"); + process.env.SHELL = process.execPath; + expect(resolveShell()).toBe(process.execPath); }); it("trims whitespace from SHELL env var", () => { - process.env.SHELL = " /usr/bin/fish "; - expect(resolveShell()).toBe("/usr/bin/fish"); + process.env.SHELL = ` ${process.execPath} `; + expect(resolveShell()).toBe(process.execPath); + }); + + it("preserves non-absolute shell names so PATH lookup still works", () => { + process.env.SHELL = "zsh"; + expect(resolveShell()).toBe("zsh"); }); it("falls back to /bin/sh on non-Windows when SHELL is unset", () => { @@ -1938,6 +2708,12 @@ describe("resolveShell (shell fallback)", () => { Object.defineProperty(process, "platform", { value: "win32" }); expect(resolveShell()).toBe("sh"); }); + + it("falls back when SHELL points to a missing absolute path", () => { + process.env.SHELL = "/definitely/missing/zsh"; + Object.defineProperty(process, "platform", { value: "linux" }); + expect(resolveShell()).toBe("/bin/sh"); + }); }); describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts index 3317a254136..d3eb6a9f0ef 100644 --- a/server/src/__tests__/worktree-config.test.ts +++ b/server/src/__tests__/worktree-config.test.ts @@ -206,6 +206,113 @@ describe("worktree config repair", () => { expect(repairedConfig.database.embeddedPostgresPort).toBe(54331); }); + it("does not persist transient runtime home overrides over repo-local worktree env", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-runtime-override-")); + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + const transientHome = path.join(tempRoot, "tests", "e2e", ".tmp", "multiuser-authenticated"); + const worktreeRoot = path.join(tempRoot, "PAP-989-multi-user-implementation-using-plan-from-pap-958"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const envPath = path.join(paperclipDir, ".env"); + const instanceId = "pap-989-multi-user-implementation-using-plan-from-pap-958"; + const stableInstanceRoot = path.join(isolatedHome, "instances", instanceId); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + ...buildLegacyConfig(transientHome), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(transientHome, "instances", instanceId, "db"), + embeddedPostgresPort: 54334, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(transientHome, "instances", instanceId, "data", "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(transientHome, "instances", instanceId, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3104, + allowedHostnames: [], + serveUi: true, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(transientHome, "instances", instanceId, "data", "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(transientHome, "instances", instanceId, "secrets", "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + await fs.writeFile( + envPath, + [ + "# Paperclip environment variables", + `PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`, + `PAPERCLIP_INSTANCE_ID=${JSON.stringify(instanceId)}`, + `PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`, + `PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`, + 'PAPERCLIP_IN_WORKTREE="true"', + 'PAPERCLIP_WORKTREE_NAME="PAP-989-multi-user-implementation-using-plan-from-pap-958"', + "", + ].join("\n"), + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "PAP-989-multi-user-implementation-using-plan-from-pap-958"; + process.env.PAPERCLIP_HOME = transientHome; + process.env.PAPERCLIP_INSTANCE_ID = instanceId; + process.env.PAPERCLIP_CONFIG = configPath; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + const repairedEnv = await fs.readFile(envPath, "utf8"); + + expect(result).toEqual({ + repairedConfig: true, + repairedEnv: false, + }); + expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(stableInstanceRoot, "db")); + expect(repairedConfig.database.backup.dir).toBe(path.join(stableInstanceRoot, "data", "backups")); + expect(repairedConfig.logging.logDir).toBe(path.join(stableInstanceRoot, "logs")); + expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(stableInstanceRoot, "data", "storage")); + expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe( + path.join(stableInstanceRoot, "secrets", "master.key"), + ); + expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`); + expect(repairedEnv).not.toContain(`PAPERCLIP_HOME=${JSON.stringify(transientHome)}`); + expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome); + }); + it("rebalances duplicate ports for already isolated worktree configs", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-rebalance-")); const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); diff --git a/server/src/adapters/http/execute.test.ts b/server/src/adapters/http/execute.test.ts new file mode 100644 index 00000000000..85653805141 --- /dev/null +++ b/server/src/adapters/http/execute.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { execute } from "./execute.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("http adapter execute", () => { + it("reports configured request timeout as timed_out", async () => { + vi.stubGlobal( + "fetch", + vi.fn((_url: string, init?: RequestInit) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("Aborted", "AbortError")); + }); + })), + ); + + const result = await execute({ + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Agent", + adapterType: "http", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + url: "https://example.test/webhook", + timeoutMs: 1, + }, + context: {}, + onLog: async () => {}, + }); + + expect(result.timedOut).toBe(true); + expect(result.errorCode).toBe("timeout"); + expect(result.errorMessage).toContain("timed out after 1ms"); + }); +}); diff --git a/server/src/adapters/http/execute.ts b/server/src/adapters/http/execute.ts index eff140233f8..c94b42202a3 100644 --- a/server/src/adapters/http/execute.ts +++ b/server/src/adapters/http/execute.ts @@ -36,6 +36,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise detectModelFromHermes(), }; diff --git a/server/src/adapters/utils.ts b/server/src/adapters/utils.ts index 7c833865b1f..da3767dd270 100644 --- a/server/src/adapters/utils.ts +++ b/server/src/adapters/utils.ts @@ -24,6 +24,7 @@ export const asBoolean = serverUtils.asBoolean; export const asStringArray = serverUtils.asStringArray; export const parseJson = serverUtils.parseJson; export const appendWithCap = serverUtils.appendWithCap; +export const appendWithByteCap = serverUtils.appendWithByteCap; export const resolvePathValue = serverUtils.resolvePathValue; export const renderTemplate = serverUtils.renderTemplate; export const redactEnvForLogs = serverUtils.redactEnvForLogs; @@ -38,7 +39,6 @@ export function buildInvocationEnvForLogs( env: Record, options: BuildInvocationEnvForLogsOptions = {}, ): Record { - // TODO: Remove this fallback once @paperclipai/adapter-utils exports buildInvocationEnvForLogs everywhere we consume it. const maybeBuildInvocationEnvForLogs = ( serverUtils as typeof serverUtils & { buildInvocationEnvForLogs?: ( diff --git a/server/src/app.ts b/server/src/app.ts index fb023bf3bb9..51a4383ce3e 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -23,12 +23,19 @@ import { secretRoutes } from "./routes/secrets.js"; import { costRoutes } from "./routes/costs.js"; import { activityRoutes } from "./routes/activity.js"; import { dashboardRoutes } from "./routes/dashboard.js"; +import { userProfileRoutes } from "./routes/user-profiles.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; +import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; import { memoryRoutes } from "./routes/memory.js"; import { backgroundJobRoutes } from "./routes/background-jobs.js"; +import { + instanceDatabaseBackupRoutes, + type InstanceDatabaseBackupService, +} from "./routes/instance-database-backups.js"; import { llmRoutes } from "./routes/llms.js"; +import { authRoutes } from "./routes/auth.js"; import { assetRoutes } from "./routes/assets.js"; import { accessRoutes } from "./routes/access.js"; import { pluginRoutes } from "./routes/plugins.js"; @@ -55,9 +62,28 @@ import { createPluginHostServiceCleanup } from "./services/plugin-host-service-c import { pluginRegistryService } from "./services/plugin-registry.js"; import { createHostClientHandlers } from "@paperclipai/plugin-sdk"; import type { BetterAuthSessionResult } from "./auth/better-auth.js"; +import { createCachedViteHtmlRenderer } from "./vite-html-renderer.js"; type UiMode = "none" | "static" | "vite-dev"; const FEEDBACK_EXPORT_FLUSH_INTERVAL_MS = 5_000; +const VITE_DEV_ASSET_PREFIXES = [ + "/@fs/", + "/@id/", + "/@react-refresh", + "/@vite/", + "/assets/", + "/node_modules/", + "/src/", +]; +const VITE_DEV_STATIC_PATHS = new Set([ + "/apple-touch-icon.png", + "/favicon-16x16.png", + "/favicon-32x32.png", + "/favicon.ico", + "/favicon.svg", + "/site.webmanifest", + "/sw.js", +]); export function resolveViteHmrPort(serverPort: number): number { if (serverPort <= 55_535) { @@ -66,6 +92,23 @@ export function resolveViteHmrPort(serverPort: number): number { return Math.max(1_024, serverPort - 10_000); } +export function shouldServeViteDevHtml(req: ExpressRequest): boolean { + const pathname = req.path; + if (VITE_DEV_STATIC_PATHS.has(pathname)) return false; + if (VITE_DEV_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return false; + return req.accepts(["html"]) === "html"; +} + +export function shouldEnablePrivateHostnameGuard(opts: { + deploymentMode: DeploymentMode; + deploymentExposure: DeploymentExposure; +}): boolean { + return ( + opts.deploymentExposure === "private" && + (opts.deploymentMode === "local_trusted" || opts.deploymentMode === "authenticated") + ); +} + export async function createApp( db: Db, opts: { @@ -80,6 +123,7 @@ export async function createApp( now?: Date; }): Promise; }; + databaseBackupService?: InstanceDatabaseBackupService; deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; allowedHostnames: string[]; @@ -103,8 +147,10 @@ export async function createApp( }, })); app.use(httpLogger); - const privateHostnameGateEnabled = - opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"; + const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({ + deploymentMode: opts.deploymentMode, + deploymentExposure: opts.deploymentExposure, + }); const privateHostnameAllowSet = resolvePrivateHostnameAllowSet({ allowedHostnames: opts.allowedHostnames, bindHost: opts.bindHost, @@ -122,23 +168,7 @@ export async function createApp( resolveSession: opts.resolveSession, }), ); - app.get("/api/auth/get-session", (req, res) => { - if (req.actor.type !== "board" || !req.actor.userId) { - res.status(401).json({ error: "Unauthorized" }); - return; - } - res.json({ - session: { - id: `paperclip:${req.actor.source}:${req.actor.userId}`, - userId: req.actor.userId, - }, - user: { - id: req.actor.userId, - email: null, - name: req.actor.source === "local_implicit" ? "Local Board" : null, - }, - }); - }); + app.use("/api/auth", authRoutes(db)); if (opts.betterAuthHandler) { app.all("/api/auth/{*authPath}", opts.betterAuthHandler); } @@ -172,6 +202,7 @@ export async function createApp( jobStore, }); const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers); + let viteHtmlRenderer: ReturnType | null = null; const loader = pluginLoader( db, { localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR }, @@ -231,9 +262,14 @@ export async function createApp( api.use(memoryRoutes(db, { pluginMemoryProviders: memoryProviderDispatcher })); api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); + api.use(userProfileRoutes(db)); api.use(sidebarBadgeRoutes(db)); + api.use(sidebarPreferenceRoutes(db)); api.use(inboxDismissalRoutes(db)); api.use(instanceSettingsRoutes(db)); + if (opts.databaseBackupService) { + api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService)); + } api.use( pluginRoutes( db, @@ -271,9 +307,46 @@ export async function createApp( const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html"))); if (uiDist) { const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); - app.use(express.static(uiDist)); - app.get(/.*/, (_req, res) => { - res.status(200).set("Content-Type", "text/html").end(indexHtml); + // Hashed asset files (Vite emits them under /assets/..) + // never change once built, so they can be cached aggressively. + app.use( + "/assets", + express.static(path.join(uiDist, "assets"), { + maxAge: "1y", + immutable: true, + }), + ); + // Non-hashed static files (favicon.ico, manifest, robots.txt, etc.): + // short cache so operators who swap them out see the new version + // reasonably fast. Override for `index.html` specifically — it is + // served by this middleware for `/` and `/index.html`, and it must + // never outlive the asset hashes it points at. + app.use( + express.static(uiDist, { + maxAge: "1h", + setHeaders(res, filePath) { + if (path.basename(filePath) === "index.html") { + res.set("Cache-Control", "no-cache"); + } + }, + }), + ); + // SPA fallback. Only for non-asset routes — if the browser asks for + // /assets/something.js that doesn't exist, we must NOT serve the HTML + // shell: the browser would try to load it as a JavaScript module, fail + // with a MIME-type error, and cache that broken response. Return 404 + // instead. The index.html response itself is no-cache so a subsequent + // deploy's updated asset hashes are picked up on next load. + app.get(/.*/, (req, res) => { + if (req.path.startsWith("/assets/")) { + res.status(404).end(); + return; + } + res + .status(200) + .set("Content-Type", "text/html") + .set("Cache-Control", "no-cache") + .end(indexHtml); }); } else { console.warn("[paperclip] UI dist not found; running in API-only mode"); @@ -282,6 +355,7 @@ export async function createApp( if (opts.uiMode === "vite-dev") { const uiRoot = path.resolve(__dirname, "../../ui"); + const publicUiRoot = path.resolve(uiRoot, "public"); const hmrPort = resolveViteHmrPort(opts.serverPort); const { createServer: createViteServer } = await import("vite"); const vite = await createViteServer({ @@ -297,18 +371,29 @@ export async function createApp( allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined, }, }); + viteHtmlRenderer = createCachedViteHtmlRenderer({ + vite, + uiRoot, + brandHtml: applyUiBranding, + }); + const renderViteHtml = viteHtmlRenderer; - app.use(vite.middlewares); + if (fs.existsSync(publicUiRoot)) { + app.use(express.static(publicUiRoot, { index: false })); + } app.get(/.*/, async (req, res, next) => { + if (!shouldServeViteDevHtml(req)) { + next(); + return; + } try { - const templatePath = path.resolve(uiRoot, "index.html"); - const template = fs.readFileSync(templatePath, "utf-8"); - const html = applyUiBranding(await vite.transformIndexHtml(req.originalUrl, template)); + const html = await renderViteHtml.render(req.originalUrl); res.status(200).set({ "Content-Type": "text/html" }).end(html); } catch (err) { next(err); } }); + app.use(vite.middlewares); } app.use(errorHandler); @@ -351,6 +436,7 @@ export async function createApp( if (feedbackExportTimer) clearInterval(feedbackExportTimer); devWatcher?.close(); setDefaultPluginMemoryProviderDispatcher(null); + viteHtmlRenderer?.dispose(); hostServiceCleanup.disposeAll(); hostServiceCleanup.teardown(); }); diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts index 881c3072ca5..402dad52036 100644 --- a/server/src/auth/better-auth.ts +++ b/server/src/auth/better-auth.ts @@ -11,6 +11,7 @@ import { authVerifications, } from "@paperclipai/db"; import type { Config } from "../config.js"; +import { resolvePaperclipInstanceId } from "../home-paths.js"; export type BetterAuthSessionUser = { id: string; @@ -25,6 +26,24 @@ export type BetterAuthSessionResult = { type BetterAuthInstance = ReturnType; +const AUTH_COOKIE_PREFIX_FALLBACK = "default"; +const AUTH_COOKIE_PREFIX_INVALID_SEGMENTS_RE = /[^a-zA-Z0-9_-]+/g; + +export function deriveAuthCookiePrefix(instanceId = resolvePaperclipInstanceId()): string { + const scopedInstanceId = instanceId + .trim() + .replace(AUTH_COOKIE_PREFIX_INVALID_SEGMENTS_RE, "-") + .replace(/^-+|-+$/g, "") || AUTH_COOKIE_PREFIX_FALLBACK; + return `paperclip-${scopedInstanceId}`; +} + +export function buildBetterAuthAdvancedOptions(input: { disableSecureCookies: boolean }) { + return { + cookiePrefix: deriveAuthCookiePrefix(), + ...(input.disableSecureCookies ? { useSecureCookies: false } : {}), + }; +} + function headersFromNodeHeaders(rawHeaders: IncomingHttpHeaders): Headers { const headers = new Headers(); for (const [key, raw] of Object.entries(rawHeaders)) { @@ -97,7 +116,7 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins? requireEmailVerification: false, disableSignUp: config.authDisableSignUp, }, - ...(isHttpOnly ? { advanced: { useSecureCookies: false } } : {}), + advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: isHttpOnly }), }; if (!baseUrl) { diff --git a/server/src/index.ts b/server/src/index.ts index 0e8db5a6291..870dd013cef 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -41,6 +41,11 @@ import { printStartupBanner } from "./startup-banner.js"; import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js"; import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js"; import { initTelemetry, getTelemetryClient } from "./telemetry.js"; +import { conflict } from "./errors.js"; +import type { + InstanceDatabaseBackupRunResult, + InstanceDatabaseBackupTrigger, +} from "./routes/instance-database-backups.js"; type BetterAuthSessionUser = { id: string; @@ -521,11 +526,80 @@ export async function startServer(): Promise { const feedback = feedbackService(db as any, { shareClient: createFeedbackTraceShareClientFromConfig(config), }); + const backupSettingsSvc = instanceSettingsService(db); + let databaseBackupInFlight = false; + const runServerDatabaseBackup = async ( + trigger: InstanceDatabaseBackupTrigger, + ): Promise => { + if (databaseBackupInFlight) { + const message = "Database backup already in progress"; + if (trigger === "scheduled") { + logger.warn("Skipping scheduled database backup because a previous backup is still running"); + return null; + } + throw conflict(message); + } + + databaseBackupInFlight = true; + const startedAt = new Date(); + const startedAtMs = Date.now(); + const label = trigger === "scheduled" ? "Automatic" : "Manual"; + try { + logger.info({ backupDir: config.databaseBackupDir, trigger }, `${label} database backup starting`); + // Read retention from Instance Settings (DB) so changes take effect without restart. + const generalSettings = await backupSettingsSvc.getGeneral(); + const retention = generalSettings.backupRetention; + + const result = await runDatabaseBackup({ + connectionString: activeDatabaseConnectionString, + backupDir: config.databaseBackupDir, + retention, + filenamePrefix: "paperclip", + }); + const finishedAt = new Date(); + const response: InstanceDatabaseBackupRunResult = { + ...result, + trigger, + backupDir: config.databaseBackupDir, + retention, + startedAt: startedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + durationMs: Date.now() - startedAtMs, + }; + logger.info( + { + backupFile: result.backupFile, + sizeBytes: result.sizeBytes, + prunedCount: result.prunedCount, + backupDir: config.databaseBackupDir, + retention, + trigger, + durationMs: response.durationMs, + }, + `${label} database backup complete: ${formatDatabaseBackupResult(result)}`, + ); + return response; + } catch (err) { + logger.error({ err, backupDir: config.databaseBackupDir, trigger }, `${label} database backup failed`); + throw err; + } finally { + databaseBackupInFlight = false; + } + }; const app = await createApp(db as any, { uiMode, serverPort: listenPort, storageService, feedbackExportService: feedback, + databaseBackupService: { + runManualBackup: async () => { + const result = await runServerDatabaseBackup("manual"); + if (!result) { + throw conflict("Database backup already in progress"); + } + return result; + }, + }, deploymentMode: config.deploymentMode, deploymentExposure: config.deploymentExposure, allowedHostnames: config.allowedHostnames, @@ -554,7 +628,9 @@ export async function startServer(): Promise { : runtimeListenHost; process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost; process.env.PAPERCLIP_LISTEN_PORT = String(listenPort); - process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`; + if (!process.env.PAPERCLIP_API_URL) { + process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`; + } setupLiveEventsWebSocketServer(server, db as any, { deploymentMode: config.deploymentMode, @@ -583,6 +659,16 @@ export async function startServer(): Promise { void heartbeat .reapOrphanedRuns() .then(() => heartbeat.resumeQueuedRuns()) + .then(async () => { + const reconciled = await heartbeat.reconcileStrandedAssignedIssues(); + if ( + reconciled.dispatchRequeued > 0 || + reconciled.continuationRequeued > 0 || + reconciled.escalated > 0 + ) { + logger.warn({ ...reconciled }, "startup stranded-issue reconciliation changed assigned issue state"); + } + }) .catch((err) => { logger.error({ err }, "startup heartbeat recovery failed"); }); @@ -614,6 +700,16 @@ export async function startServer(): Promise { void heartbeat .reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000 }) .then(() => heartbeat.resumeQueuedRuns()) + .then(async () => { + const reconciled = await heartbeat.reconcileStrandedAssignedIssues(); + if ( + reconciled.dispatchRequeued > 0 || + reconciled.continuationRequeued > 0 || + reconciled.escalated > 0 + ) { + logger.warn({ ...reconciled }, "periodic stranded-issue reconciliation changed assigned issue state"); + } + }) .catch((err) => { logger.error({ err }, "periodic heartbeat recovery failed"); }); @@ -622,43 +718,6 @@ export async function startServer(): Promise { if (config.databaseBackupEnabled) { const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000; - const settingsSvc = instanceSettingsService(db); - let backupInFlight = false; - - const runScheduledBackup = async () => { - if (backupInFlight) { - logger.warn("Skipping scheduled database backup because a previous backup is still running"); - return; - } - - backupInFlight = true; - try { - // Read retention from Instance Settings (DB) so changes take effect without restart - const generalSettings = await settingsSvc.getGeneral(); - const retention = generalSettings.backupRetention; - - const result = await runDatabaseBackup({ - connectionString: activeDatabaseConnectionString, - backupDir: config.databaseBackupDir, - retention, - filenamePrefix: "paperclip", - }); - logger.info( - { - backupFile: result.backupFile, - sizeBytes: result.sizeBytes, - prunedCount: result.prunedCount, - backupDir: config.databaseBackupDir, - retention, - }, - `Automatic database backup complete: ${formatDatabaseBackupResult(result)}`, - ); - } catch (err) { - logger.error({ err, backupDir: config.databaseBackupDir }, "Automatic database backup failed"); - } finally { - backupInFlight = false; - } - }; logger.info( { @@ -669,7 +728,9 @@ export async function startServer(): Promise { "Automatic database backups enabled", ); setInterval(() => { - void runScheduledBackup(); + void runServerDatabaseBackup("scheduled").catch(() => { + // runServerDatabaseBackup already logs the failure with context. + }); }, backupIntervalMs); } @@ -772,7 +833,7 @@ export async function startServer(): Promise { server, host: config.host, listenPort, - apiUrl: process.env.PAPERCLIP_API_URL ?? `http://${runtimeApiHost}:${listenPort}`, + apiUrl: process.env.PAPERCLIP_API_URL!, databaseUrl: activeDatabaseConnectionString, }; } diff --git a/server/src/lib/join-request-dedupe.ts b/server/src/lib/join-request-dedupe.ts new file mode 100644 index 00000000000..249ef11f657 --- /dev/null +++ b/server/src/lib/join-request-dedupe.ts @@ -0,0 +1,88 @@ +import { joinRequests } from "@paperclipai/db"; + +type JoinRequestLike = Pick< + typeof joinRequests.$inferSelect, + | "id" + | "requestType" + | "status" + | "requestingUserId" + | "requestEmailSnapshot" + | "createdAt" + | "updatedAt" +>; + +function nonEmptyTrimmed(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +export function normalizeJoinRequestEmail( + email: string | null | undefined +): string | null { + const trimmed = nonEmptyTrimmed(email); + return trimmed ? trimmed.toLowerCase() : null; +} + +export function humanJoinRequestIdentity( + row: Pick< + JoinRequestLike, + "requestType" | "requestingUserId" | "requestEmailSnapshot" + > +): string | null { + if (row.requestType !== "human") return null; + const requestingUserId = nonEmptyTrimmed(row.requestingUserId); + if (requestingUserId) return `user:${requestingUserId}`; + const email = normalizeJoinRequestEmail(row.requestEmailSnapshot); + return email ? `email:${email}` : null; +} + +export function findReusableHumanJoinRequest< + T extends Pick< + JoinRequestLike, + "id" | "requestType" | "status" | "requestingUserId" | "requestEmailSnapshot" + >, +>( + rows: T[], + actor: { requestingUserId?: string | null; requestEmailSnapshot?: string | null } +): T | null { + const actorUserId = nonEmptyTrimmed(actor.requestingUserId); + if (actorUserId) { + const sameUser = rows.find( + (row) => + row.requestType === "human" && + (row.status === "pending_approval" || row.status === "approved") && + row.requestingUserId === actorUserId + ); + if (sameUser) return sameUser; + } + + const actorEmail = normalizeJoinRequestEmail(actor.requestEmailSnapshot); + if (!actorEmail) return null; + return ( + rows.find( + (row) => + row.requestType === "human" && + (row.status === "pending_approval" || row.status === "approved") && + normalizeJoinRequestEmail(row.requestEmailSnapshot) === actorEmail + ) ?? null + ); +} + +export function collapseDuplicatePendingHumanJoinRequests< + T extends Pick< + JoinRequestLike, + "id" | "requestType" | "status" | "requestingUserId" | "requestEmailSnapshot" + >, +>(rows: T[]): T[] { + const seen = new Set(); + return rows.filter((row) => { + if (row.requestType !== "human" || row.status !== "pending_approval") { + return true; + } + const identity = humanJoinRequestIdentity(row); + if (!identity) return true; + if (seen.has(identity)) return false; + seen.add(identity); + return true; + }); +} diff --git a/server/src/log-redaction.ts b/server/src/log-redaction.ts index ab59b3e414a..952d1553ad1 100644 --- a/server/src/log-redaction.ts +++ b/server/src/log-redaction.ts @@ -112,6 +112,7 @@ export function redactCurrentUserText(input: string, opts?: CurrentUserRedaction let result = input; for (const homeDir of [...homeDirs].sort((a, b) => b.length - a.length)) { + if (!result.includes(homeDir)) continue; const lastSegment = splitPathSegments(homeDir).pop() ?? ""; const replacementDir = lastSegment ? replaceLastPathSegment(homeDir, maskUserNameForLogs(lastSegment, replacement)) @@ -120,6 +121,7 @@ export function redactCurrentUserText(input: string, opts?: CurrentUserRedaction } for (const userName of [...userNames].sort((a, b) => b.length - a.length)) { + if (!result.includes(userName)) continue; const pattern = new RegExp(`(? { req.actor = opts.deploymentMode === "local_trusted" - ? { type: "board", userId: "local-board", isInstanceAdmin: true, source: "local_implicit" } + ? { + type: "board", + userId: "local-board", + userName: "Local Board", + userEmail: null, + isInstanceAdmin: true, + source: "local_implicit", + } : { type: "none", source: "none" }; const runIdHeader = req.header("x-paperclip-run-id"); @@ -49,7 +56,11 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))) .then((rows) => rows[0] ?? null), db - .select({ companyId: companyMemberships.companyId }) + .select({ + companyId: companyMemberships.companyId, + membershipRole: companyMemberships.membershipRole, + status: companyMemberships.status, + }) .from(companyMemberships) .where( and( @@ -62,7 +73,10 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa req.actor = { type: "board", userId, + userName: session.user.name ?? null, + userEmail: session.user.email ?? null, companyIds: memberships.map((row) => row.companyId), + memberships, isInstanceAdmin: Boolean(roleRow), runId: runIdHeader ?? undefined, source: "session", @@ -90,7 +104,10 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa req.actor = { type: "board", userId: boardKey.userId, + userName: access.user?.name ?? null, + userEmail: access.user?.email ?? null, companyIds: access.companyIds, + memberships: access.memberships, isInstanceAdmin: access.isInstanceAdmin, keyId: boardKey.id, runId: runIdHeader || undefined, diff --git a/server/src/middleware/board-mutation-guard.ts b/server/src/middleware/board-mutation-guard.ts index feff3b409a0..96e2a46122b 100644 --- a/server/src/middleware/board-mutation-guard.ts +++ b/server/src/middleware/board-mutation-guard.ts @@ -24,6 +24,12 @@ function trustedOriginsForRequest(req: Request) { origins.add(`http://${host}`.toLowerCase()); origins.add(`https://${host}`.toLowerCase()); } + // Behind some reverse proxies the Host / X-Forwarded-Host header may + // not match the public URL (for example when TLS terminates at the + // edge and the inbound Host is an internal service name). Trust the + // explicitly-configured PAPERCLIP_PUBLIC_URL when it's set. + const publicUrl = parseOrigin(process.env.PAPERCLIP_PUBLIC_URL?.trim()); + if (publicUrl) origins.add(publicUrl); return origins; } diff --git a/server/src/middleware/http-log-policy.ts b/server/src/middleware/http-log-policy.ts new file mode 100644 index 00000000000..a0b93694b22 --- /dev/null +++ b/server/src/middleware/http-log-policy.ts @@ -0,0 +1,50 @@ +const SILENCED_SUCCESS_METHODS = new Set(["GET", "HEAD"]); + +const SILENCED_SUCCESS_API_PATHS = [ + /^\/api\/health(?:\/|$)/, + /^\/api\/companies\/[^/]+\/activity(?:\/|$)/, + /^\/api\/companies\/[^/]+\/dashboard(?:\/|$)/, + /^\/api\/companies\/[^/]+\/heartbeat-runs(?:\/|$)/, + /^\/api\/companies\/[^/]+\/issues(?:\/|$)/, + /^\/api\/companies\/[^/]+\/live-runs(?:\/|$)/, + /^\/api\/companies\/[^/]+\/sidebar-badges(?:\/|$)/, + /^\/api\/heartbeat-runs\/[^/]+\/log(?:\/|$)/, +]; + +const SILENCED_SUCCESS_STATIC_PREFIXES = [ + "/@fs/", + "/@id/", + "/@react-refresh", + "/@vite/", + "/_plugins/", + "/assets/", + "/node_modules/", + "/src/", +]; + +const SILENCED_SUCCESS_STATIC_PATHS = new Set([ + "/", + "/index.html", + "/favicon.ico", + "/site.webmanifest", + "/sw.js", +]); + +function normalizePath(url: string): string { + const trimmed = url.trim(); + if (trimmed.length === 0) return "/"; + const pathname = trimmed.split("?")[0]?.trim() ?? "/"; + return pathname.length > 0 ? pathname : "/"; +} + +export function shouldSilenceHttpSuccessLog(method: string | undefined, url: string | undefined, statusCode: number): boolean { + if (statusCode >= 400) return false; + if (statusCode === 304) return true; + if (!method || !url) return false; + if (!SILENCED_SUCCESS_METHODS.has(method.toUpperCase())) return false; + + const pathname = normalizePath(url); + if (SILENCED_SUCCESS_STATIC_PATHS.has(pathname)) return true; + if (SILENCED_SUCCESS_STATIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return true; + return SILENCED_SUCCESS_API_PATHS.some((pattern) => pattern.test(pathname)); +} diff --git a/server/src/middleware/logger.ts b/server/src/middleware/logger.ts index 7844e567368..7eb5c2b4342 100644 --- a/server/src/middleware/logger.ts +++ b/server/src/middleware/logger.ts @@ -4,6 +4,7 @@ import pino from "pino"; import { pinoHttp } from "pino-http"; import { readConfigFile } from "../config-file.js"; import { resolveDefaultLogsDir, resolveHomeAwarePath } from "../home-paths.js"; +import { shouldSilenceHttpSuccessLog } from "./http-log-policy.js"; function resolveServerLogDir(): string { const envOverride = process.env.PAPERCLIP_LOG_DIR?.trim(); @@ -47,6 +48,9 @@ export const logger = pino({ export const httpLogger = pinoHttp({ logger, customLogLevel(_req, res, err) { + if (shouldSilenceHttpSuccessLog(_req.method, _req.url, res.statusCode)) { + return "silent"; + } if (err || res.statusCode >= 500) return "error"; if (res.statusCode >= 400) return "warn"; return "info"; diff --git a/server/src/onboarding-assets/ceo/AGENTS.md b/server/src/onboarding-assets/ceo/AGENTS.md index b88e7758bc9..06d08da8e31 100644 --- a/server/src/onboarding-assets/ceo/AGENTS.md +++ b/server/src/onboarding-assets/ceo/AGENTS.md @@ -32,6 +32,8 @@ You MUST delegate work rather than doing it yourself. When a task is assigned to - Don't let tasks sit idle. If you delegate something, check that it's progressing. - If a report is blocked, help unblock them -- escalate to the board if needed. - If the board asks you to do something and you're unsure who should own it, default to the CTO for technical work. +- Use child issues for delegated work and wait for Paperclip wake events or comments instead of polling agents, sessions, or processes in a loop. +- Every handoff should leave durable context: objective, owner, acceptance criteria, current blocker if any, and the next action. - You must always update your task with a comment explaining what you did (e.g., who you delegated to and why). ## Memory and Planning diff --git a/server/src/onboarding-assets/ceo/HEARTBEAT.md b/server/src/onboarding-assets/ceo/HEARTBEAT.md index 8773823d216..26e36a04ef7 100644 --- a/server/src/onboarding-assets/ceo/HEARTBEAT.md +++ b/server/src/onboarding-assets/ceo/HEARTBEAT.md @@ -31,10 +31,20 @@ If `PAPERCLIP_APPROVAL_ID` is set: ## 5. Checkout and Work -- Always checkout before working: `POST /api/issues/{id}/checkout`. +- For scoped issue wakes, Paperclip may already checkout the current issue in the harness before your run starts. +- Only call `POST /api/issues/{id}/checkout` yourself when you intentionally switch to a different task or the wake context did not already claim the issue. - Never retry a 409 -- that task belongs to someone else. - Do the work. Update status and comment when done. +Status quick guide: + +- `todo`: ready to execute, but not yet checked out. +- `in_progress`: actively owned work. Agents should reach this by checkout, not by manually flipping status. +- `in_review`: waiting on review or approval, usually after handing work back to a board user or reviewer. +- `blocked`: cannot move until something specific changes. Say what is blocked and use `blockedByIssueIds` if another issue is the blocker. +- `done`: finished. +- `cancelled`: intentionally dropped. + ## 6. Delegation - Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue. diff --git a/server/src/onboarding-assets/default/AGENTS.md b/server/src/onboarding-assets/default/AGENTS.md index 2f84898a752..8c11c8ae954 100644 --- a/server/src/onboarding-assets/default/AGENTS.md +++ b/server/src/onboarding-assets/default/AGENTS.md @@ -1,3 +1,12 @@ You are an agent at Paperclip company. -Keep the work moving until it's done. If you need QA to review it, ask them. If you need your boss to review it, ask them. If someone needs to unblock you, assign them the ticket with a comment asking for what you need. Don't let work just sit here. You must always update your task with a comment. +## Execution Contract + +- Start actionable work in the same heartbeat. Do not stop at a plan unless the issue explicitly asks for planning. +- Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them. +- Leave durable progress in task comments, documents, or work products, and make the next action clear before you exit. +- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes. +- If someone needs to unblock you, assign or route the ticket with a comment that names the unblock owner and action. +- Respect budget, pause/cancel, approval gates, and company boundaries. + +Do not let work sit here. You must always update your task with a comment. diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index e4893765fbc..aeebd615101 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -9,14 +9,19 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { Router } from "express"; import type { Request } from "express"; -import { and, eq, isNull, desc } from "drizzle-orm"; +import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { + assets, agentApiKeys, authUsers, companies, + companyLogos, + companyMemberships, + instanceUserRoles, invites, - joinRequests + joinRequests, + principalPermissionGrants, } from "@paperclipai/db"; import { acceptInviteSchema, @@ -24,13 +29,18 @@ import { claimJoinRequestApiKeySchema, createCompanyInviteSchema, createOpenClawInvitePromptSchema, + listCompanyInvitesQuerySchema, listJoinRequestsQuerySchema, resolveCliAuthChallengeSchema, + searchAdminUsersQuerySchema, + updateCompanyMemberWithPermissionsSchema, + updateCompanyMemberSchema, + archiveCompanyMemberSchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, PERMISSION_KEYS } from "@paperclipai/shared"; -import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; +import type { DeploymentExposure, DeploymentMode, HumanCompanyMembershipRole, PermissionKey } from "@paperclipai/shared"; import { forbidden, conflict, @@ -48,11 +58,22 @@ import { logActivity, notifyHireApproved } from "../services/index.js"; -import { assertCompanyAccess } from "./authz.js"; +import { + grantsForHumanRole, + normalizeHumanRole, + resolveHumanInviteRole, +} from "../services/company-member-roles.js"; +import { humanJoinGrantsFromDefaults } from "../services/invite-grants.js"; +import { + collapseDuplicatePendingHumanJoinRequests, + findReusableHumanJoinRequest, +} from "../lib/join-request-dedupe.js"; +import { assertAuthenticated, assertCompanyAccess } from "./authz.js"; import { claimBoardOwnership, inspectBoardClaimChallenge } from "../board-claim.js"; +import { getStorageService } from "../storage/index.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -62,7 +83,12 @@ const INVITE_TOKEN_PREFIX = "pcp_invite_"; const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; const INVITE_TOKEN_SUFFIX_LENGTH = 8; const INVITE_TOKEN_MAX_RETRIES = 5; -const COMPANY_INVITE_TTL_MS = 10 * 60 * 1000; +const COMPANY_INVITE_TTL_MS = 72 * 60 * 60 * 1000; + +type MemberGrantPayload = { + permissionKey: PermissionKey; + scope?: Record | null; +}; function createInviteToken() { const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH); @@ -858,33 +884,512 @@ function toInviteSummaryResponse( req: Request, token: string, invite: typeof invites.$inferSelect, - companyName: string | null = null + company: + | string + | { + name: string | null; + brandColor: string | null; + logoUrl: string | null; + } + | null = null ) { + const companyInfo = typeof company === "string" + ? { name: company, brandColor: null, logoUrl: null } + : company; const baseUrl = requestBaseUrl(req); + const invitePath = `/invite/${token}`; const onboardingPath = `/api/invites/${token}/onboarding`; const onboardingTextPath = `/api/invites/${token}/onboarding.txt`; + const skillIndexPath = `/api/invites/${token}/skills/index`; const inviteMessage = extractInviteMessage(invite); return { id: invite.id, companyId: invite.companyId, - companyName, + companyName: companyInfo?.name ?? null, + companyLogoUrl: companyInfo?.logoUrl ?? null, + companyBrandColor: companyInfo?.brandColor ?? null, inviteType: invite.inviteType, allowedJoinTypes: invite.allowedJoinTypes, + humanRole: extractInviteHumanRole(invite), expiresAt: invite.expiresAt, + invitePath, + inviteUrl: baseUrl ? `${baseUrl}${invitePath}` : invitePath, onboardingPath, onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath, onboardingTextPath, onboardingTextUrl: baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath, - skillIndexPath: "/api/skills/index", + skillIndexPath, skillIndexUrl: baseUrl - ? `${baseUrl}/api/skills/index` - : "/api/skills/index", + ? `${baseUrl}${skillIndexPath}` + : skillIndexPath, inviteMessage }; } +function actorHasActiveUserMembership(req: Request, companyId: string) { + return ( + req.actor.type === "board" && + typeof req.actor.userId === "string" && + Array.isArray(req.actor.memberships) && + req.actor.memberships.some( + (membership) => + membership.companyId === companyId && membership.status === "active", + ) + ); +} + +async function loadUsersById(db: Db, userIds: string[]) { + if (userIds.length === 0) return new Map>(); + const rows = await db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .where(inArray(authUsers.id, userIds)); + return new Map(rows.map((row) => [row.id, toUserProfile(row)])); +} + +async function loadCompanyAccessSummary( + req: Request, + access: ReturnType, + companyId: string, +) { + if (req.actor.type !== "board") { + return { + currentUserRole: null, + canManageMembers: false, + canInviteUsers: false, + canApproveJoinRequests: false, + }; + } + if (isLocalImplicit(req)) { + return { + currentUserRole: "owner" as const, + canManageMembers: true, + canInviteUsers: true, + canApproveJoinRequests: true, + }; + } + const userId = req.actor.userId ?? null; + const membership = + userId ? await access.getMembership(companyId, "user", userId) : null; + const [canManageMembers, canInviteUsers, canApproveJoinRequests] = + await Promise.all([ + access.canUser(companyId, userId, "users:manage_permissions"), + access.canUser(companyId, userId, "users:invite"), + access.canUser(companyId, userId, "joins:approve"), + ]); + + return { + currentUserRole: + membership?.status === "active" && membership.membershipRole + ? normalizeHumanRole(membership.membershipRole, "operator") + : null, + canManageMembers, + canInviteUsers, + canApproveJoinRequests, + }; +} + +async function loadCompanyMemberRecords( + db: Db, + companyId: string, + options: { includeArchived?: boolean } = {}, +) { + const members = await db + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + options.includeArchived ? undefined : ne(companyMemberships.status, "archived"), + ), + ) + .orderBy(desc(companyMemberships.updatedAt)); + + const userIds = [...new Set(members.map((member) => member.principalId))]; + const [userMap, grants] = await Promise.all([ + loadUsersById(db, userIds), + userIds.length > 0 + ? db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, "user"), + inArray(principalPermissionGrants.principalId, userIds), + ), + ) + : Promise.resolve([]), + ]); + + const grantsByPrincipalId = new Map(); + for (const grant of grants) { + const existing = grantsByPrincipalId.get(grant.principalId) ?? []; + existing.push(grant); + grantsByPrincipalId.set(grant.principalId, existing); + } + + return members.map((member) => ({ + ...member, + principalType: "user" as const, + membershipRole: member.membershipRole + ? normalizeHumanRole(member.membershipRole, "operator") + : null, + user: userMap.get(member.principalId) ?? null, + grants: grantsByPrincipalId.get(member.principalId) ?? [], + })); +} + +type CompanyMemberRecord = Awaited>[number]; + +const humanRoleRank: Record = { + viewer: 1, + operator: 2, + admin: 3, + owner: 4, +}; + +async function resolveActorHumanRole( + req: Request, + access: ReturnType, + companyId: string, +): Promise { + if (req.actor.type !== "board") return null; + if (isLocalImplicit(req) || req.actor.isInstanceAdmin) return "owner"; + const userId = req.actor.userId ?? null; + if (!userId) return null; + const membership = await access.getMembership(companyId, "user", userId); + if (membership?.status !== "active" || !membership.membershipRole) return null; + return normalizeHumanRole(membership.membershipRole, "operator"); +} + +async function getProtectedMemberReason( + req: Request, + access: ReturnType, + companyId: string, + member: { principalId: string; principalType: string; membershipRole: string | null }, + opts?: { + actorRole?: HumanCompanyMembershipRole | null; + instanceAdminUserIds?: ReadonlySet; + operation?: "archive" | "update"; + }, +): Promise { + if (member.principalType !== "user") return "Only human company members can be removed."; + if (req.actor.type !== "board") return "Board access is required to remove members."; + if (member.principalId === req.actor.userId) return "You cannot remove yourself."; + const isTargetInstanceAdmin = opts?.instanceAdminUserIds + ? opts.instanceAdminUserIds.has(member.principalId) + : await access.isInstanceAdmin(member.principalId); + if (isTargetInstanceAdmin) { + return "Instance admins cannot be removed from company access."; + } + + const targetRole = member.membershipRole + ? normalizeHumanRole(member.membershipRole, "operator") + : "operator"; + if (opts?.operation === "archive") { + if (targetRole === "owner") return "Board owners cannot be removed from company access."; + if (targetRole === "admin") return "Company admins cannot be removed from company access."; + } + + const actorRole = opts?.actorRole ?? await resolveActorHumanRole(req, access, companyId); + if (!actorRole) return "Only active company members can remove users."; + if (humanRoleRank[targetRole] >= humanRoleRank[actorRole]) { + return "You can only remove users below your company role."; + } + + return null; +} + +async function assertCanManageCompanyMember( + req: Request, + access: ReturnType, + companyId: string, + member: { principalId: string; principalType: string; membershipRole: string | null }, + operation: "archive" | "update" = "update", +) { + const reason = await getProtectedMemberReason(req, access, companyId, member, { operation }); + if (reason) throw forbidden(reason); +} + +async function addCompanyMemberRemovalAccess( + req: Request, + db: Db, + access: ReturnType, + companyId: string, + members: CompanyMemberRecord[], +) { + const actorRole = await resolveActorHumanRole(req, access, companyId); + const userIds = [...new Set(members + .filter((member) => member.principalType === "user") + .map((member) => member.principalId))]; + const instanceAdminUserIds = userIds.length > 0 + ? new Set( + await db + .select({ userId: instanceUserRoles.userId }) + .from(instanceUserRoles) + .where(and(inArray(instanceUserRoles.userId, userIds), eq(instanceUserRoles.role, "instance_admin"))) + .then((rows) => rows.map((row) => row.userId)), + ) + : new Set(); + return Promise.all( + members.map(async (member) => { + const reason = await getProtectedMemberReason(req, access, companyId, member, { + actorRole, + instanceAdminUserIds, + operation: "archive", + }); + return { + ...member, + removal: { + canArchive: !reason, + reason, + }, + }; + }), + ); +} + +async function loadCompanyUserDirectory(db: Db, companyId: string) { + const members = await db + .select({ + principalId: companyMemberships.principalId, + status: companyMemberships.status, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + ), + ) + .orderBy(desc(companyMemberships.updatedAt)); + + const userIds = [...new Set(members.map((member) => member.principalId))]; + const userMap = await loadUsersById(db, userIds); + + return members.map((member) => ({ + principalId: member.principalId, + status: "active" as const, + user: userMap.get(member.principalId) ?? null, + })); +} + +function inviteStateWhereClause( + state: "active" | "accepted" | "expired" | "revoked" | undefined, +) { + const now = new Date(); + switch (state) { + case "active": + return and( + isNull(invites.revokedAt), + isNull(invites.acceptedAt), + gt(invites.expiresAt, now), + ); + case "accepted": + return isNotNull(invites.acceptedAt); + case "expired": + return and( + isNull(invites.revokedAt), + isNull(invites.acceptedAt), + lte(invites.expiresAt, now), + ); + case "revoked": + return isNotNull(invites.revokedAt); + default: + return undefined; + } +} + +async function loadCompanyInviteRecords( + db: Db, + companyId: string, + options: { + state?: "active" | "accepted" | "expired" | "revoked"; + limit: number; + offset: number; + }, +) { + const whereClause = inviteStateWhereClause(options.state); + const rows = await db + .select() + .from(invites) + .where(whereClause ? and(eq(invites.companyId, companyId), whereClause) : eq(invites.companyId, companyId)) + .orderBy(desc(invites.createdAt)) + .limit(options.limit + 1) + .offset(options.offset); + const hasMore = rows.length > options.limit; + const visibleRows = hasMore ? rows.slice(0, options.limit) : rows; + const userIds = [ + ...new Set( + visibleRows + .map((invite) => invite.invitedByUserId) + .filter((value): value is string => Boolean(value)), + ), + ]; + const [userMap, joinRows, companyName] = await Promise.all([ + loadUsersById(db, userIds), + visibleRows.length + ? db + .select({ id: joinRequests.id, inviteId: joinRequests.inviteId }) + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, companyId), + inArray( + joinRequests.inviteId, + visibleRows.map((invite) => invite.id), + ), + ), + ) + : Promise.resolve([]), + db + .select({ name: companies.name }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((companyRows) => companyRows[0]?.name ?? null), + ]); + const joinRequestIdByInviteId = new Map( + joinRows.map((row: { inviteId: string; id: string }) => [row.inviteId, row.id]), + ); + + return { + invites: visibleRows.map((invite) => ({ + ...invite, + companyName, + humanRole: extractInviteHumanRole(invite), + inviteMessage: extractInviteMessage(invite), + state: inviteState(invite), + invitedByUser: invite.invitedByUserId + ? userMap.get(invite.invitedByUserId) ?? null + : null, + relatedJoinRequestId: joinRequestIdByInviteId.get(invite.id) ?? null, + })), + nextOffset: hasMore ? options.offset + options.limit : null, + }; +} + +async function loadJoinRequestRecords(db: Db, companyId: string) { + const rows = collapseDuplicatePendingHumanJoinRequests( + await db + .select() + .from(joinRequests) + .where(eq(joinRequests.companyId, companyId)) + .orderBy(desc(joinRequests.createdAt)) + ); + const inviteIds = [...new Set(rows.map((row) => row.inviteId))]; + const inviteRows = inviteIds.length + ? await db + .select() + .from(invites) + .where(inArray(invites.id, inviteIds)) + : []; + const userIds = [ + ...new Set( + [ + ...rows.map((row) => row.requestingUserId), + ...rows.map((row) => row.approvedByUserId), + ...rows.map((row) => row.rejectedByUserId), + ...inviteRows.map((invite) => invite.invitedByUserId), + ].filter((value): value is string => Boolean(value)), + ), + ]; + const userMap = await loadUsersById(db, userIds); + const inviteMap = new Map(inviteRows.map((invite) => [invite.id, invite])); + + return rows.map((row) => { + const invite = inviteMap.get(row.inviteId) ?? null; + return { + ...toJoinRequestResponse(row), + requesterUser: row.requestingUserId + ? userMap.get(row.requestingUserId) ?? null + : null, + approvedByUser: row.approvedByUserId + ? userMap.get(row.approvedByUserId) ?? null + : null, + rejectedByUser: row.rejectedByUserId + ? userMap.get(row.rejectedByUserId) ?? null + : null, + invite: invite + ? { + id: invite.id, + inviteType: invite.inviteType, + allowedJoinTypes: invite.allowedJoinTypes, + humanRole: extractInviteHumanRole(invite), + inviteMessage: extractInviteMessage(invite), + createdAt: invite.createdAt, + expiresAt: invite.expiresAt, + revokedAt: invite.revokedAt, + acceptedAt: invite.acceptedAt, + invitedByUser: invite.invitedByUserId + ? userMap.get(invite.invitedByUserId) ?? null + : null, + } + : null, + }; + }); +} + +async function loadUserCompanyAccessResponse( + db: Db, + access: ReturnType, + userId: string, +) { + const [memberships, user, isInstanceAdmin] = await Promise.all([ + access.listUserCompanyAccess(userId), + db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .where(eq(authUsers.id, userId)) + .then((rows) => rows[0] ?? null), + access.isInstanceAdmin(userId), + ]); + const companyIds = [...new Set(memberships.map((membership) => membership.companyId))]; + const companyRows = companyIds.length + ? await db + .select({ + id: companies.id, + name: companies.name, + status: companies.status, + }) + .from(companies) + .where(inArray(companies.id, companyIds)) + : []; + const companyMap = new Map(companyRows.map((company) => [company.id, company])); + + return { + user: user + ? { + ...toUserProfile(user), + isInstanceAdmin, + } + : null, + companyAccess: memberships.map((membership) => { + const company = companyMap.get(membership.companyId) ?? null; + return { + ...membership, + principalType: "user" as const, + companyName: company?.name ?? null, + companyStatus: company?.status ?? null, + }; + }), + }; +} + function buildOnboardingDiscoveryDiagnostics(input: { apiBaseUrl: string; deploymentMode: DeploymentMode; @@ -1004,7 +1509,7 @@ function buildInviteOnboardingManifest( } ) { const baseUrl = requestBaseUrl(req); - const skillPath = "/api/skills/paperclip"; + const skillPath = `/api/invites/${token}/skills/paperclip`; const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath; const registrationEndpointPath = `/api/invites/${token}/accept`; const registrationEndpointUrl = baseUrl @@ -1349,12 +1854,22 @@ function extractInviteMessage( function mergeInviteDefaults( defaultsPayload: Record | null | undefined, - agentMessage: string | null + agentMessage: string | null, + humanRole: "owner" | "admin" | "operator" | "viewer" | null = null, ): Record | null { const merged = defaultsPayload && typeof defaultsPayload === "object" ? { ...defaultsPayload } : {}; + if (humanRole) { + const existingHuman = + isPlainObject(merged.human) ? { ...(merged.human as Record) } : {}; + merged.human = { + ...existingHuman, + role: humanRole, + grants: grantsForHumanRole(humanRole), + }; + } if (agentMessage) { merged.agentMessage = agentMessage; } @@ -1374,10 +1889,44 @@ function inviteExpired(invite: typeof invites.$inferSelect) { return invite.expiresAt.getTime() <= Date.now(); } +function inviteState(invite: typeof invites.$inferSelect) { + if (invite.revokedAt) return "revoked" as const; + if (invite.acceptedAt) return "accepted" as const; + if (inviteExpired(invite)) return "expired" as const; + return "active" as const; +} + +function extractInviteHumanRole(invite: typeof invites.$inferSelect) { + if (invite.allowedJoinTypes === "agent") return null; + return resolveHumanInviteRole( + invite.defaultsPayload as Record | null | undefined, + ); +} + function isLocalImplicit(req: Request) { return req.actor.type === "board" && req.actor.source === "local_implicit"; } +function toUserProfile( + user: + | { + id: string; + email: string | null; + name: string | null; + image?: string | null; + } + | null + | undefined, +) { + if (!user) return null; + return { + id: user.id, + email: user.email ?? null, + name: user.name ?? null, + image: user.image ?? null, + }; +} + async function resolveActorEmail(db: Db, req: Request): Promise { if (isLocalImplicit(req)) return "local@paperclip.local"; const userId = req.actor.userId; @@ -1390,6 +1939,57 @@ async function resolveActorEmail(db: Db, req: Request): Promise { return user?.email ?? null; } +async function resolveAcceptedInviteJoinRequest( + db: Db, + req: Request, + invite: typeof invites.$inferSelect | null, +) { + if (!invite?.acceptedAt) return null; + + const directJoinRequest = await db + .select({ + requestType: joinRequests.requestType, + status: joinRequests.status, + requestingUserId: joinRequests.requestingUserId, + requestEmailSnapshot: joinRequests.requestEmailSnapshot, + }) + .from(joinRequests) + .where(eq(joinRequests.inviteId, invite.id)) + .then((rows) => rows[0] ?? null); + if (directJoinRequest) return directJoinRequest; + + if (!invite.companyId) return null; + + const actorRequestingUserId = isLocalImplicit(req) + ? "local-board" + : req.actor.userId ?? null; + const actorEmail = await resolveActorEmail(db, req); + if (!actorRequestingUserId && !actorEmail) return null; + + return findReusableHumanJoinRequest( + await db + .select({ + id: joinRequests.id, + requestType: joinRequests.requestType, + status: joinRequests.status, + requestingUserId: joinRequests.requestingUserId, + requestEmailSnapshot: joinRequests.requestEmailSnapshot, + }) + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, invite.companyId), + eq(joinRequests.requestType, "human"), + ), + ) + .orderBy(desc(joinRequests.createdAt)), + { + requestingUserId: actorRequestingUserId, + requestEmailSnapshot: actorEmail, + }, + ); +} + function grantsFromDefaults( defaultsPayload: Record | null | undefined, key: "human" | "agent" @@ -1848,6 +2448,7 @@ export function accessRoutes( req: Request; companyId: string; allowedJoinTypes: "human" | "agent" | "both"; + humanRole?: "owner" | "admin" | "operator" | "viewer" | null; defaultsPayload?: Record | null; agentMessage?: string | null; }) { @@ -1855,13 +2456,18 @@ export function accessRoutes( typeof input.agentMessage === "string" ? input.agentMessage.trim() || null : null; + const effectiveHumanRole = + input.allowedJoinTypes === "agent" + ? null + : input.humanRole ?? "operator"; const insertValues = { companyId: input.companyId, inviteType: "company_join" as const, allowedJoinTypes: input.allowedJoinTypes, defaultsPayload: mergeInviteDefaults( input.defaultsPayload ?? null, - normalizedAgentMessage + normalizedAgentMessage, + effectiveHumanRole, ), expiresAt: companyInviteExpiresAt(), invitedByUserId: input.req.actor.userId ?? null @@ -1896,21 +2502,98 @@ export function accessRoutes( return { token, created, normalizedAgentMessage }; } - async function getInviteCompanyName(companyId: string | null) { - if (!companyId) return null; + async function getInviteCompanyBranding( + companyId: string | null, + inviteToken: string | null = null, + ): Promise<{ + name: string | null; + brandColor: string | null; + logoAssetId: string | null; + logoUrl: string | null; + }> { + if (!companyId) { + return { name: null, brandColor: null, logoAssetId: null, logoUrl: null }; + } const company = await db - .select({ name: companies.name }) + .select({ + name: companies.name, + brandColor: companies.brandColor, + logoAssetId: companyLogos.assetId, + }) .from(companies) + .leftJoin(companyLogos, eq(companyLogos.companyId, companies.id)) .where(eq(companies.id, companyId)) .then((rows) => rows[0] ?? null); - return company?.name ?? null; + let logoUrl: string | null = null; + if (inviteToken && company?.logoAssetId) { + const logoAsset = await getInviteLogoAsset(companyId); + if (logoAsset?.companyId) { + try { + const storage = getStorageService(); + const logoObject = await storage.headObject(logoAsset.companyId, logoAsset.objectKey); + if (logoObject.exists) { + logoUrl = `/api/invites/${inviteToken}/logo`; + } + } catch (err) { + logger.warn( + { + err, + companyId, + logoAssetId: company.logoAssetId, + }, + "invite logo storage check failed", + ); + } + } + } + + return { + name: company?.name ?? null, + brandColor: company?.brandColor ?? null, + logoAssetId: company?.logoAssetId ?? null, + logoUrl, + }; } - router.get("/skills/available", (_req, res) => { + async function getInviteLogoAsset(companyId: string | null): Promise<{ + companyId: string | null; + objectKey: string; + contentType: string | null; + byteSize: number | null; + originalFilename: string | null; + } | null> { + if (!companyId) return null; + const logoAsset = await db + .select({ + companyId: companies.id, + objectKey: assets.objectKey, + contentType: assets.contentType, + byteSize: assets.byteSize, + originalFilename: assets.originalFilename, + }) + .from(companies) + .leftJoin(companyLogos, eq(companyLogos.companyId, companies.id)) + .leftJoin(assets, eq(assets.id, companyLogos.assetId)) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + + if (!logoAsset?.objectKey) return null; + return { + companyId: logoAsset.companyId, + objectKey: logoAsset.objectKey, + contentType: logoAsset.contentType, + byteSize: logoAsset.byteSize, + originalFilename: logoAsset.originalFilename, + }; + } + + router.get("/skills/available", (req, res) => { + assertAuthenticated(req); res.json({ skills: listAvailableSkills() }); }); - router.get("/skills/index", (_req, res) => { + router.get("/skills/index", (req, res) => { + assertAuthenticated(req); res.json({ skills: [ { name: "paperclip", path: "/api/skills/paperclip" }, @@ -1927,6 +2610,7 @@ export function accessRoutes( }); router.get("/skills/:skillName", (req, res) => { + assertAuthenticated(req); const skillName = (req.params.skillName as string).trim().toLowerCase(); const markdown = readSkillMarkdown(skillName); if (!markdown) throw notFound("Skill not found"); @@ -1944,6 +2628,7 @@ export function accessRoutes( req, companyId, allowedJoinTypes: req.body.allowedJoinTypes, + humanRole: req.body.humanRole ?? null, defaultsPayload: req.body.defaultsPayload ?? null, agentMessage: req.body.agentMessage ?? null }); @@ -1962,22 +2647,24 @@ export function accessRoutes( inviteType: created.inviteType, allowedJoinTypes: created.allowedJoinTypes, expiresAt: created.expiresAt.toISOString(), + humanRole: extractInviteHumanRole(created), hasAgentMessage: Boolean(normalizedAgentMessage) } }); - const companyName = await getInviteCompanyName(created.companyId); + const companyBranding = await getInviteCompanyBranding(created.companyId, token); const inviteSummary = toInviteSummaryResponse( req, token, created, - companyName + companyBranding ); res.status(201).json({ ...created, token, - inviteUrl: `/invite/${token}`, - companyName, + invitePath: inviteSummary.invitePath, + inviteUrl: inviteSummary.inviteUrl, + companyName: companyBranding.name, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, inviteMessage: inviteSummary.inviteMessage @@ -1996,6 +2683,7 @@ export function accessRoutes( req, companyId, allowedJoinTypes: "agent", + humanRole: null, defaultsPayload: null, agentMessage: req.body.agentMessage ?? null }); @@ -2018,18 +2706,19 @@ export function accessRoutes( } }); - const companyName = await getInviteCompanyName(created.companyId); + const companyBranding = await getInviteCompanyBranding(created.companyId, token); const inviteSummary = toInviteSummaryResponse( req, token, created, - companyName + companyBranding ); res.status(201).json({ ...created, token, - inviteUrl: `/invite/${token}`, - companyName, + invitePath: inviteSummary.invitePath, + inviteUrl: inviteSummary.inviteUrl, + companyName: companyBranding.name, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, inviteMessage: inviteSummary.inviteMessage @@ -2045,17 +2734,82 @@ export function accessRoutes( .from(invites) .where(eq(invites.tokenHash, hashToken(token))) .then((rows) => rows[0] ?? null); + const inviteJoinRequest = await resolveAcceptedInviteJoinRequest(db, req, invite); if ( !invite || invite.revokedAt || - invite.acceptedAt || - inviteExpired(invite) + inviteExpired(invite) || + (invite.acceptedAt && !inviteJoinRequest) ) { throw notFound("Invite not found"); } - const companyName = await getInviteCompanyName(invite.companyId); - res.json(toInviteSummaryResponse(req, token, invite, companyName)); + const companyBranding = await getInviteCompanyBranding(invite.companyId, token); + const inviterName = invite.invitedByUserId + ? await loadUsersById(db, [invite.invitedByUserId]).then( + (m) => m.get(invite.invitedByUserId!)?.name ?? null + ) + : null; + res.json({ + ...toInviteSummaryResponse(req, token, invite, companyBranding), + invitedByUserName: inviterName, + joinRequestStatus: inviteJoinRequest?.status ?? null, + joinRequestType: inviteJoinRequest?.requestType ?? null, + }); + }); + + router.get("/invites/:token/logo", async (req, res, next) => { + const token = (req.params.token as string).trim(); + if (!token) throw notFound("Invite not found"); + const invite = await db + .select() + .from(invites) + .where(eq(invites.tokenHash, hashToken(token))) + .then((rows) => rows[0] ?? null); + const inviteJoinRequest = await resolveAcceptedInviteJoinRequest(db, req, invite); + if ( + !invite || + invite.revokedAt || + inviteExpired(invite) || + (invite.acceptedAt && !inviteJoinRequest) + ) { + throw notFound("Invite not found"); + } + + const logoAsset = await getInviteLogoAsset(invite.companyId); + if (!logoAsset || !logoAsset.companyId) { + throw notFound("Invite logo not found"); + } + const companyId = logoAsset.companyId; + + const storage = getStorageService(); + const logoHead = await storage.headObject(companyId, logoAsset.objectKey); + if (!logoHead.exists) { + throw notFound("Invite logo not found"); + } + const object = await storage.getObject(companyId, logoAsset.objectKey); + const responseContentType = + logoAsset.contentType || + logoHead.contentType || + object.contentType || + "application/octet-stream"; + res.setHeader("Content-Type", responseContentType); + res.setHeader( + "Content-Length", + String(logoAsset.byteSize || logoHead.contentLength || object.contentLength || 0), + ); + res.setHeader("Cache-Control", "private, max-age=60"); + res.setHeader("X-Content-Type-Options", "nosniff"); + if (responseContentType === "image/svg+xml") { + res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); + } + const filename = logoAsset.originalFilename ?? "company-logo"; + res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`); + + object.stream.on("error", (err) => { + next(err); + }); + object.stream.pipe(res); }); router.get("/invites/:token/onboarding", async (req, res) => { @@ -2070,10 +2824,10 @@ export function accessRoutes( throw notFound("Invite not found"); } - const companyName = await getInviteCompanyName(invite.companyId); + const companyBranding = await getInviteCompanyBranding(invite.companyId); res.json(buildInviteOnboardingManifest(req, token, invite, { ...opts, - companyName + companyName: companyBranding.name })); }); @@ -2089,17 +2843,58 @@ export function accessRoutes( throw notFound("Invite not found"); } - const companyName = await getInviteCompanyName(invite.companyId); + const companyBranding = await getInviteCompanyBranding(invite.companyId); res .type("text/plain; charset=utf-8") .send( buildInviteOnboardingTextDocument(req, token, invite, { ...opts, - companyName + companyName: companyBranding.name }) ); }); + router.get("/invites/:token/skills/index", async (req, res) => { + const token = (req.params.token as string).trim(); + if (!token) throw notFound("Invite not found"); + const invite = await db + .select() + .from(invites) + .where(eq(invites.tokenHash, hashToken(token))) + .then((rows) => rows[0] ?? null); + if (!invite || invite.revokedAt || inviteExpired(invite)) { + throw notFound("Invite not found"); + } + + res.json({ + skills: [ + { + name: "paperclip", + path: `/api/invites/${token}/skills/paperclip`, + }, + ], + }); + }); + + router.get("/invites/:token/skills/:skillName", async (req, res) => { + const token = (req.params.token as string).trim(); + if (!token) throw notFound("Invite not found"); + const invite = await db + .select() + .from(invites) + .where(eq(invites.tokenHash, hashToken(token))) + .then((rows) => rows[0] ?? null); + if (!invite || invite.revokedAt || inviteExpired(invite)) { + throw notFound("Invite not found"); + } + + const skillName = (req.params.skillName as string).trim().toLowerCase(); + if (skillName !== "paperclip") throw notFound("Skill not found"); + const markdown = readSkillMarkdown(skillName); + if (!markdown) throw notFound("Skill not found"); + res.type("text/markdown").send(markdown); + }); + router.get("/invites/:token/test-resolution", async (req, res) => { const token = (req.params.token as string).trim(); if (!token) throw notFound("Invite not found"); @@ -2221,6 +3016,12 @@ export function accessRoutes( ) { throw unauthorized("Authenticated user is required"); } + if ( + requestType === "human" && + actorHasActiveUserMembership(req, companyId) + ) { + throw conflict("You already belong to this company"); + } if (requestType === "agent" && !req.body.agentName) { if ( !inviteAlreadyAccepted || @@ -2313,48 +3114,82 @@ export function accessRoutes( const actorEmail = requestType === "human" ? await resolveActorEmail(db, req) : null; - const created = !inviteAlreadyAccepted - ? await db.transaction(async (tx) => { - await tx - .update(invites) - .set({ acceptedAt: new Date(), updatedAt: new Date() }) - .where( - and( - eq(invites.id, invite.id), - isNull(invites.acceptedAt), - isNull(invites.revokedAt) + const existingHumanJoinRequest = + requestType === "human" + ? findReusableHumanJoinRequest( + await db + .select() + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, companyId), + eq(joinRequests.requestType, "human") + ) ) - ); + .orderBy(desc(joinRequests.createdAt)), + { + requestingUserId: req.actor.userId ?? "local-board", + requestEmailSnapshot: actorEmail + } + ) + : null; + const created = !inviteAlreadyAccepted + ? existingHumanJoinRequest + ? await db.transaction(async (tx) => { + await tx + .update(invites) + .set({ acceptedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(invites.id, invite.id), + isNull(invites.acceptedAt), + isNull(invites.revokedAt) + ) + ); + return existingHumanJoinRequest; + }) + : await db.transaction(async (tx) => { + await tx + .update(invites) + .set({ acceptedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(invites.id, invite.id), + isNull(invites.acceptedAt), + isNull(invites.revokedAt) + ) + ); - const row = await tx - .insert(joinRequests) - .values({ - inviteId: invite.id, - companyId, - requestType, - status: "pending_approval", - requestIp: requestIp(req), - requestingUserId: - requestType === "human" - ? req.actor.userId ?? "local-board" - : null, - requestEmailSnapshot: - requestType === "human" ? actorEmail : null, - agentName: requestType === "agent" ? req.body.agentName : null, - adapterType: requestType === "agent" ? adapterType : null, - capabilities: - requestType === "agent" - ? req.body.capabilities ?? null - : null, - agentDefaultsPayload: - requestType === "agent" ? joinDefaults.normalized : null, - claimSecretHash, - claimSecretExpiresAt - }) - .returning() - .then((rows) => rows[0]); - return row; - }) + const row = await tx + .insert(joinRequests) + .values({ + inviteId: invite.id, + companyId, + requestType, + status: "pending_approval", + requestIp: requestIp(req), + requestingUserId: + requestType === "human" + ? req.actor.userId ?? "local-board" + : null, + requestEmailSnapshot: + requestType === "human" ? actorEmail : null, + agentName: + requestType === "agent" ? req.body.agentName : null, + adapterType: requestType === "agent" ? adapterType : null, + capabilities: + requestType === "agent" + ? req.body.capabilities ?? null + : null, + agentDefaultsPayload: + requestType === "agent" ? joinDefaults.normalized : null, + claimSecretHash, + claimSecretExpiresAt + }) + .returning() + .then((rows) => rows[0]); + return row; + }) : await db .update(joinRequests) .set({ @@ -2500,21 +3335,23 @@ export function accessRoutes( entityId: created.id, details: { requestType, - requestIp: created.requestIp, - inviteReplay: inviteAlreadyAccepted + requestIp: requestIp(req), + inviteReplay: inviteAlreadyAccepted, + reusedExistingJoinRequest: + Boolean(existingHumanJoinRequest) && !inviteAlreadyAccepted } }); const response = toJoinRequestResponse(created); if (claimSecret) { - const companyName = await getInviteCompanyName(invite.companyId); + const companyBranding = await getInviteCompanyBranding(invite.companyId); const onboardingManifest = buildInviteOnboardingManifest( req, token, invite, { ...opts, - companyName + companyName: companyBranding.name } ); res.status(202).json({ @@ -2576,22 +3413,26 @@ export function accessRoutes( res.json(revoked); }); + router.get("/companies/:companyId/invites", async (req, res) => { + const companyId = req.params.companyId as string; + await assertCompanyPermission(req, companyId, "users:invite"); + const query = listCompanyInvitesQuerySchema.parse(req.query); + const invitesForCompany = await loadCompanyInviteRecords(db, companyId, query); + res.json(invitesForCompany); + }); + router.get("/companies/:companyId/join-requests", async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "joins:approve"); const query = listJoinRequestsQuerySchema.parse(req.query); - const all = await db - .select() - .from(joinRequests) - .where(eq(joinRequests.companyId, companyId)) - .orderBy(desc(joinRequests.createdAt)); + const all = await loadJoinRequestRecords(db, companyId); const filtered = all.filter((row) => { if (query.status && row.status !== query.status) return false; if (query.requestType && row.requestType !== query.requestType) return false; return true; }); - res.json(filtered.map(toJoinRequestResponse)); + res.json(filtered); }); router.post( @@ -2626,16 +3467,19 @@ export function accessRoutes( if (existing.requestType === "human") { if (!existing.requestingUserId) throw conflict("Join request missing user identity"); + const membershipRole = resolveHumanInviteRole( + invite.defaultsPayload as Record | null, + ); await access.ensureMembership( companyId, "user", existing.requestingUserId, - "member", + membershipRole, "active" ); - const grants = grantsFromDefaults( + const grants = humanJoinGrantsFromDefaults( invite.defaultsPayload as Record | null, - "human" + membershipRole ); await access.setPrincipalGrants( companyId, @@ -2872,10 +3716,288 @@ export function accessRoutes( router.get("/companies/:companyId/members", async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "users:manage_permissions"); - const members = await access.listMembers(companyId); - res.json(members); + const [members, currentAccess] = await Promise.all([ + loadCompanyMemberRecords(db, companyId), + loadCompanyAccessSummary(req, access, companyId), + ]); + res.json({ + members: await addCompanyMemberRemovalAccess(req, db, access, companyId, members), + access: currentAccess, + }); }); + router.get("/companies/:companyId/user-directory", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const users = await loadCompanyUserDirectory(db, companyId); + res.json({ users }); + }); + + router.patch( + "/companies/:companyId/members/:memberId", + validate(updateCompanyMemberSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const memberId = req.params.memberId as string; + await assertCompanyPermission(req, companyId, "users:manage_permissions"); + const memberToUpdate = await access.getMemberById(companyId, memberId); + if (!memberToUpdate) throw notFound("Member not found"); + await assertCanManageCompanyMember(req, access, companyId, memberToUpdate); + + const updated = await db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.id, memberId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + + const nextMembershipRole = + req.body.membershipRole !== undefined + ? req.body.membershipRole + : existing.membershipRole; + const nextStatus = req.body.status ?? existing.status; + + if ( + existing.principalType === "user" && + existing.status === "active" && + existing.membershipRole === "owner" && + (nextStatus !== "active" || nextMembershipRole !== "owner") + ) { + const activeOwnerCount = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + return tx + .update(companyMemberships) + .set({ + membershipRole: nextMembershipRole, + status: nextStatus, + updatedAt: new Date(), + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + }); + if (!updated) throw notFound("Member not found"); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "company_member.updated", + entityType: "company_membership", + entityId: memberId, + details: { + membershipRole: updated.membershipRole, + status: updated.status, + }, + }); + + const member = (await loadCompanyMemberRecords(db, companyId)).find( + (entry) => entry.id === memberId, + ); + if (!member) throw notFound("Member not found"); + res.json(member); + } + ); + + router.patch( + "/companies/:companyId/members/:memberId/role-and-grants", + validate(updateCompanyMemberWithPermissionsSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const memberId = req.params.memberId as string; + await assertCompanyPermission(req, companyId, "users:manage_permissions"); + const memberToUpdate = await access.getMemberById(companyId, memberId); + if (!memberToUpdate) throw notFound("Member not found"); + await assertCanManageCompanyMember(req, access, companyId, memberToUpdate); + + const updated = await db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.id, memberId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + + const nextMembershipRole = + req.body.membershipRole !== undefined + ? req.body.membershipRole + : existing.membershipRole; + const nextStatus = req.body.status ?? existing.status; + + if ( + existing.principalType === "user" && + existing.status === "active" && + existing.membershipRole === "owner" && + (nextStatus !== "active" || nextMembershipRole !== "owner") + ) { + const activeOwnerCount = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + const now = new Date(); + const updatedMember = await tx + .update(companyMemberships) + .set({ + membershipRole: nextMembershipRole, + status: nextStatus, + updatedAt: now, + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, existing.principalType), + eq(principalPermissionGrants.principalId, existing.principalId), + ), + ); + + const grants = (req.body.grants ?? []) as MemberGrantPayload[]; + if (grants.length > 0) { + await tx.insert(principalPermissionGrants).values( + grants.map((grant) => ({ + companyId, + principalType: existing.principalType, + principalId: existing.principalId, + permissionKey: grant.permissionKey, + scope: grant.scope ?? null, + grantedByUserId: req.actor.userId ?? null, + createdAt: now, + updatedAt: now, + })), + ); + } + + return updatedMember; + }); + if (!updated) throw notFound("Member not found"); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "company_member.access_updated", + entityType: "company_membership", + entityId: memberId, + details: { + membershipRole: updated.membershipRole, + status: updated.status, + grantCount: req.body.grants?.length ?? 0, + }, + }); + + const member = (await loadCompanyMemberRecords(db, companyId)).find( + (entry) => entry.id === memberId, + ); + if (!member) throw notFound("Member not found"); + res.json(member); + } + ); + + router.post( + "/companies/:companyId/members/:memberId/archive", + validate(archiveCompanyMemberSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const memberId = req.params.memberId as string; + await assertCompanyPermission(req, companyId, "users:manage_permissions"); + const memberToArchive = await access.getMemberById(companyId, memberId); + if (!memberToArchive) throw notFound("Member not found"); + await assertCanManageCompanyMember(req, access, companyId, memberToArchive, "archive"); + + const result = await access.archiveMember(companyId, memberId, { + reassignment: req.body.reassignment ?? null, + }); + if (!result) throw notFound("Member not found"); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "company_member.archived", + entityType: "company_membership", + entityId: memberId, + details: { + principalId: result.member.principalId, + reassignedIssueCount: result.reassignedIssueCount, + reassignment: req.body.reassignment ?? null, + }, + }); + + const member = (await loadCompanyMemberRecords(db, companyId, { includeArchived: true })).find( + (entry) => entry.id === memberId, + ); + if (!member) throw notFound("Member not found"); + res.json({ + member, + reassignedIssueCount: result.reassignedIssueCount, + }); + } + ); + router.patch( "/companies/:companyId/members/:memberId/permissions", validate(updateMemberPermissionsSchema), @@ -2883,6 +4005,9 @@ export function accessRoutes( const companyId = req.params.companyId as string; const memberId = req.params.memberId as string; await assertCompanyPermission(req, companyId, "users:manage_permissions"); + const memberToUpdate = await access.getMemberById(companyId, memberId); + if (!memberToUpdate) throw notFound("Member not found"); + await assertCanManageCompanyMember(req, access, companyId, memberToUpdate); const updated = await access.setMemberPermissions( companyId, memberId, @@ -2890,7 +4015,22 @@ export function accessRoutes( req.actor.userId ?? null ); if (!updated) throw notFound("Member not found"); - res.json(updated); + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "company_member.permissions_updated", + entityType: "company_membership", + entityId: memberId, + details: { + grantCount: req.body.grants?.length ?? 0, + }, + }); + const member = (await loadCompanyMemberRecords(db, companyId)).find( + (entry) => entry.id === memberId, + ); + if (!member) throw notFound("Member not found"); + res.json(member); } ); @@ -2904,6 +4044,66 @@ export function accessRoutes( } ); + router.get("/admin/users", async (req, res) => { + await assertInstanceAdmin(req); + const query = searchAdminUsersQuerySchema.parse(req.query); + const needle = query.query.trim().toLowerCase(); + const users = await db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .orderBy(desc(authUsers.updatedAt)); + const filteredUsers = needle + ? users.filter((user) => + [user.name, user.email] + .filter((value): value is string => Boolean(value)) + .some((value) => value.toLowerCase().includes(needle)), + ) + : users; + const userIds = filteredUsers.slice(0, 50).map((user) => user.id); + const memberships = userIds.length + ? await db + .select({ + principalId: companyMemberships.principalId, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + inArray(companyMemberships.principalId, userIds), + ), + ) + : []; + const membershipCountByUserId = new Map(); + for (const membership of memberships) { + membershipCountByUserId.set( + membership.principalId, + (membershipCountByUserId.get(membership.principalId) ?? 0) + 1, + ); + } + const adminIds = new Set( + await Promise.all( + userIds.map(async (userId) => + (await access.isInstanceAdmin(userId)) ? userId : null, + ), + ).then((values) => values.filter((value): value is string => Boolean(value))), + ); + + res.json( + filteredUsers.slice(0, 50).map((user) => ({ + ...toUserProfile(user), + isInstanceAdmin: adminIds.has(user.id), + activeCompanyMembershipCount: + membershipCountByUserId.get(user.id) ?? 0, + })), + ); + }); + router.post( "/admin/users/:userId/demote-instance-admin", async (req, res) => { @@ -2918,8 +4118,7 @@ export function accessRoutes( router.get("/admin/users/:userId/company-access", async (req, res) => { await assertInstanceAdmin(req); const userId = req.params.userId as string; - const memberships = await access.listUserCompanyAccess(userId); - res.json(memberships); + res.json(await loadUserCompanyAccessResponse(db, access, userId)); }); router.put( @@ -2928,11 +4127,12 @@ export function accessRoutes( async (req, res) => { await assertInstanceAdmin(req); const userId = req.params.userId as string; - const memberships = await access.setUserCompanyAccess( + await access.setUserCompanyAccess( userId, - req.body.companyIds ?? [] + req.body.companyIds ?? [], + { actorUserId: req.actor.userId ?? null }, ); - res.json(memberships); + res.json(await loadUserCompanyAccessResponse(db, access, userId)); } ); diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 20f2ed534ed..53083b1b608 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import type { Db } from "@paperclipai/db"; import { validate } from "../middleware/validate.js"; import { activityService } from "../services/activity.js"; -import { assertBoard, assertCompanyAccess } from "./authz.js"; +import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js"; import { heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; @@ -81,6 +81,7 @@ export function activityRoutes(db: Db) { }); router.get("/heartbeat-runs/:runId/issues", async (req, res) => { + assertAuthenticated(req); const runId = req.params.runId as string; const run = await heartbeat.getRun(runId); if (!run) { diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 27e32a062a1..a01f3132ba0 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -41,7 +41,7 @@ import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js"; import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js"; import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js"; import { logger } from "../middleware/logger.js"; -import { assertBoard } from "./authz.js"; +import { assertBoardOrgAccess } from "./authz.js"; import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js"; const execFileAsync = promisify(execFile); @@ -59,6 +59,13 @@ interface AdapterInstallRequest { version?: string; } +interface AdapterCapabilities { + supportsInstructionsBundle: boolean; + supportsSkills: boolean; + supportsLocalAgentJwt: boolean; + requiresMaterializedRuntimeSkills: boolean; +} + interface AdapterInfo { type: string; label: string; @@ -66,6 +73,7 @@ interface AdapterInfo { modelsCount: number; loaded: boolean; disabled: boolean; + capabilities: AdapterCapabilities; /** True when an external plugin has replaced a built-in adapter of the same type. */ overriddenBuiltin?: boolean; /** True when the external override for a builtin type is currently paused. */ @@ -103,6 +111,15 @@ function readAdapterPackageVersionFromDisk(record: AdapterPluginRecord): string } } +function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabilities { + return { + supportsInstructionsBundle: adapter.supportsInstructionsBundle ?? false, + supportsSkills: Boolean(adapter.listSkills || adapter.syncSkills), + supportsLocalAgentJwt: adapter.supportsLocalAgentJwt ?? false, + requiresMaterializedRuntimeSkills: adapter.requiresMaterializedRuntimeSkills ?? false, + }; +} + function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set): AdapterInfo { const fromDisk = externalRecord ? readAdapterPackageVersionFromDisk(externalRecord) : undefined; return { @@ -112,6 +129,7 @@ function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterP modelsCount: (adapter.models ?? []).length, loaded: true, // If it's in the registry, it's loaded disabled: disabledSet.has(adapter.type), + capabilities: buildAdapterCapabilities(adapter), overriddenBuiltin: externalRecord ? BUILTIN_ADAPTER_TYPES.has(adapter.type) : undefined, overridePaused: BUILTIN_ADAPTER_TYPES.has(adapter.type) ? isOverridePaused(adapter.type) : undefined, // Prefer on-disk package.json so the UI reflects bumps without relying on store-only fields. @@ -174,7 +192,7 @@ export function adapterRoutes() { * its model count, and load status. */ router.get("/adapters", async (_req, res) => { - assertBoard(_req); + assertBoardOrgAccess(_req); const registeredAdapters = listServerAdapters(); const externalRecords = new Map( @@ -200,7 +218,7 @@ export function adapterRoutes() { * - version?: string — target version for npm packages */ router.post("/adapters/install", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest; @@ -332,7 +350,7 @@ export function adapterRoutes() { * Request body: { "disabled": boolean } */ router.patch("/adapters/:type", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const adapterType = req.params.type; const { disabled } = req.body as { disabled?: boolean }; @@ -367,7 +385,7 @@ export function adapterRoutes() { * keep the adapter they started with. */ router.patch("/adapters/:type/override", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const adapterType = req.params.type; const { paused } = req.body as { paused?: boolean }; @@ -395,7 +413,7 @@ export function adapterRoutes() { * Unregister an external adapter. Built-in adapters cannot be removed. */ router.delete("/adapters/:type", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const adapterType = req.params.type; @@ -470,7 +488,7 @@ export function adapterRoutes() { * Cannot be used on built-in adapter types. */ router.post("/adapters/:type/reload", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const type = req.params.type; @@ -522,7 +540,7 @@ export function adapterRoutes() { // This is a convenience shortcut for remove + install with the same // package name, but without the risk of losing the store record. router.post("/adapters/:type/reinstall", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const type = req.params.type; @@ -587,11 +605,15 @@ export function adapterRoutes() { // Serve a declarative config schema for an adapter's UI form fields. // The adapter's getConfigSchema() resolves all options (static and dynamic) // so the UI receives a fully hydrated schema in a single fetch. - const configSchemaCache = new Map(); + const configSchemaCache = new Map(); const CONFIG_SCHEMA_TTL_MS = 30_000; router.get("/adapters/:type/config-schema", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { type } = req.params; const adapter = findActiveServerAdapter(type); @@ -605,14 +627,14 @@ export function adapterRoutes() { } const cached = configSchemaCache.get(type); - if (cached && Date.now() - cached.fetchedAt < CONFIG_SCHEMA_TTL_MS) { + if (cached && cached.adapter === adapter && Date.now() - cached.fetchedAt < CONFIG_SCHEMA_TTL_MS) { res.json(cached.schema); return; } try { const schema = await adapter.getConfigSchema(); - configSchemaCache.set(type, { schema, fetchedAt: Date.now() }); + configSchemaCache.set(type, { adapter, schema, fetchedAt: Date.now() }); res.json(schema); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -629,7 +651,7 @@ export function adapterRoutes() { // The adapter package must export a "./ui-parser" entry in package.json // pointing to a self-contained ESM module with zero runtime dependencies. router.get("/adapters/:type/ui-parser.js", (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { type } = req.params; const source = getOrExtractUiParserSource(type); if (!source) { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index bc4783c6a35..9d0eddc6a04 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,4 +1,4 @@ -import { Router, type Request } from "express"; +import { Router, type Request, type Response } from "express"; import { generateKeyPairSync, randomUUID } from "node:crypto"; import path from "node:path"; import type { Db } from "@paperclipai/db"; @@ -7,6 +7,7 @@ import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { agentSkillSyncSchema, agentMineInboxQuerySchema, + AGENT_DEFAULT_MAX_CONCURRENT_RUNS, createAgentKeySchema, createAgentHireSchema, createAgentSchema, @@ -37,6 +38,7 @@ import { companySkillService, budgetService, heartbeatService, + ISSUE_LIST_DEFAULT_LIMIT, issueApprovalService, issueService, logActivity, @@ -46,6 +48,10 @@ import { } from "../services/index.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; +import { + assertNoAgentHostWorkspaceCommandMutation, + collectAgentAdapterWorkspaceCommandPaths, +} from "./workspace-command-authz.js"; import { detectAdapterModel, findActiveServerAdapter, @@ -71,7 +77,18 @@ import { } from "../services/default-agent-instructions.js"; import { getTelemetryClient } from "../telemetry.js"; +const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000; +const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024; + +function readRunLogLimitBytes(value: unknown) { + const parsed = Number(value ?? RUN_LOG_DEFAULT_LIMIT_BYTES); + if (!Number.isFinite(parsed)) return RUN_LOG_DEFAULT_LIMIT_BYTES; + return Math.max(1, Math.min(RUN_LOG_MAX_LIMIT_BYTES, Math.trunc(parsed))); +} + export function agentRoutes(db: Db) { + // Legacy hardcoded maps — used as fallback when adapter module does not + // declare capability flags explicitly. const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", @@ -83,6 +100,22 @@ export function agentRoutes(db: Db) { pi_local: "instructionsFilePath", }; const DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES = new Set(Object.keys(DEFAULT_INSTRUCTIONS_PATH_KEYS)); + + /** Check if an adapter supports the managed instructions bundle. */ + function adapterSupportsInstructionsBundle(adapterType: string): boolean { + const adapter = findActiveServerAdapter(adapterType); + if (adapter?.supportsInstructionsBundle !== undefined) return adapter.supportsInstructionsBundle; + return DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(adapterType); + } + + /** Resolve the adapter config key for the instructions file path. */ + function resolveInstructionsPathKey(adapterType: string): string | null { + const adapter = findActiveServerAdapter(adapterType); + if (adapter?.instructionsPathKey) return adapter.instructionsPathKey; + if (adapter?.supportsInstructionsBundle === true) return "instructionsFilePath"; + if (adapter?.supportsInstructionsBundle === false) return null; + return DEFAULT_INSTRUCTIONS_PATH_KEYS[adapterType] ?? null; + } const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]); const KNOWN_INSTRUCTIONS_BUNDLE_KEYS = [ "instructionsBundleMode", @@ -213,10 +246,33 @@ export function agentRoutes(db: Db) { return actorAgent; } + async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) { + assertBoard(req); + assertCompanyAccess(req, companyId); + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); + if (!allowed) { + throw forbidden("Missing permission: agents:create"); + } + } + async function assertCanReadConfigurations(req: Request, companyId: string) { return assertCanCreateAgentsForCompany(req, companyId); } + async function getAccessibleAgent(req: Request, res: Response, id: string) { + const agent = await svc.getById(id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return null; + } + assertCompanyAccess(req, agent.companyId); + if (req.actor.type === "board") { + await assertBoardCanManageAgentsForCompany(req, agent.companyId); + } + return agent; + } + async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") { @@ -299,7 +355,10 @@ export function agentRoutes(db: Db) { async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); - if (req.actor.type === "board") return; + if (req.actor.type === "board") { + await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId); + return; + } if (!req.actor.agentId) throw forbidden("Agent authentication required"); const actorAgent = await svc.getById(req.actor.agentId); @@ -321,7 +380,10 @@ export function agentRoutes(db: Db) { async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); - if (req.actor.type === "board") return; + if (req.actor.type === "board") { + await assertCanReadConfigurations(req, targetAgent.companyId); + return; + } if (!req.actor.agentId) throw forbidden("Agent authentication required"); const actorAgent = await svc.getById(req.actor.agentId); @@ -463,6 +525,9 @@ export function agentRoutes(db: Db) { if (parseBooleanLike(heartbeat.enabled) == null) { heartbeat.enabled = false; } + if (parseNumberLike(heartbeat.maxConcurrentRuns) == null) { + heartbeat.maxConcurrentRuns = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; + } normalizedRuntimeConfig.heartbeat = heartbeat; return normalizedRuntimeConfig; @@ -557,7 +622,7 @@ export function agentRoutes(db: Db) { adapterType: string; adapterConfig: unknown; }>(agent: T): Promise { - if (!DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(agent.adapterType)) { + if (!adapterSupportsInstructionsBundle(agent.adapterType)) { return agent; } @@ -592,19 +657,24 @@ export function agentRoutes(db: Db) { async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); - if (req.actor.type === "board") return; - if (!req.actor.agentId) throw forbidden("Agent authentication required"); - - const actorAgent = await svc.getById(req.actor.agentId); - if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) { - throw forbidden("Agent key cannot access another company"); + if (req.actor.type !== "board") { + throw forbidden( + "Only board-authenticated callers can manage instructions path or bundle configuration", + ); } - if (actorAgent.id === targetAgent.id) return; + await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId); + } - const chainOfCommand = await svc.getChainOfCommand(targetAgent.id); - if (chainOfCommand.some((manager) => manager.id === actorAgent.id)) return; - - throw forbidden("Only the target agent or an ancestor manager can update instructions path"); + function assertNoAgentInstructionsConfigMutation( + req: Request, + adapterConfig: Record | null | undefined, + ) { + if (req.actor.type !== "agent" || !adapterConfig) return; + const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS.filter((key) => adapterConfig[key] !== undefined); + if (changedSensitiveKeys.length === 0) return; + throw forbidden( + `Agent-authenticated callers cannot modify instructions path or bundle configuration (${changedSensitiveKeys.join(", ")})`, + ); } function summarizeAgentUpdateDetails(patch: Record) { @@ -638,7 +708,9 @@ export function agentRoutes(db: Db) { }; } - const ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS = new Set([ + // Legacy hardcoded set — used as fallback when adapter module does not + // declare requiresMaterializedRuntimeSkills explicitly. + const LEGACY_MATERIALIZED_SKILLS_SET = new Set([ "cursor", "gemini_local", "opencode_local", @@ -646,7 +718,11 @@ export function agentRoutes(db: Db) { ]); function shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) { - return ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS.has(adapterType); + const adapter = findActiveServerAdapter(adapterType); + if (adapter?.requiresMaterializedRuntimeSkills !== undefined) { + return adapter.requiresMaterializedRuntimeSkills; + } + return LEGACY_MATERIALIZED_SKILLS_SET.has(adapterType); } async function buildRuntimeSkillConfig( @@ -964,9 +1040,16 @@ export function agentRoutes(db: Db) { router.get("/companies/:companyId/agents", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + const unsupportedQueryParams = Object.keys(req.query).sort(); + if (unsupportedQueryParams.length > 0) { + res.status(400).json({ + error: `Unsupported query parameter${unsupportedQueryParams.length === 1 ? "" : "s"}: ${unsupportedQueryParams.join(", ")}`, + }); + return; + } const result = await svc.list(companyId); const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId); - if (canReadConfigs || req.actor.type === "board") { + if (canReadConfigs) { res.json(result); return; } @@ -1098,6 +1181,8 @@ export function agentRoutes(db: Db) { const rows = await issuesSvc.list(req.actor.companyId, { assigneeAgentId: req.actor.agentId, status: "todo,in_progress,blocked", + includeRoutineExecutions: true, + limit: ISSUE_LIST_DEFAULT_LIMIT, }); res.json( @@ -1128,6 +1213,7 @@ export function agentRoutes(db: Db) { touchedByUserId: query.userId, inboxArchivedByUserId: query.userId, status: query.status, + limit: ISSUE_LIST_DEFAULT_LIMIT, }); res.json(rows); @@ -1141,12 +1227,13 @@ export function agentRoutes(db: Db) { return; } assertCompanyAccess(req, agent.companyId); - if (req.actor.type === "agent" && req.actor.agentId !== id) { - const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId); - if (!canRead) { - res.json(await buildAgentDetail(agent, { restricted: true })); - return; - } + const isSelf = req.actor.type === "agent" && req.actor.agentId === id; + const canReadSensitiveDetail = isSelf + ? true + : await actorCanReadConfigurationsForCompany(req, agent.companyId); + if (!canReadSensitiveDetail) { + res.json(await buildAgentDetail(agent, { restricted: true })); + return; } res.json(await buildAgentDetail(agent)); }); @@ -1234,6 +1321,7 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } + await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); const state = await heartbeat.getRuntimeState(id); @@ -1248,6 +1336,7 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } + await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); const sessions = await heartbeat.listTaskSessions(id); @@ -1267,6 +1356,7 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } + await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); const taskKey = @@ -1299,6 +1389,14 @@ export function agentRoutes(db: Db) { ...hireInput } = req.body; hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectAgentAdapterWorkspaceCommandPaths(hireInput.adapterConfig), + ); + assertNoAgentInstructionsConfigMutation( + req, + (hireInput.adapterConfig ?? {}) as Record, + ); const requestedAdapterConfig = applyCreateDefaultsByAdapterType( hireInput.adapterType, ((hireInput.adapterConfig ?? {}) as Record), @@ -1426,7 +1524,7 @@ export function agentRoutes(db: Db) { }); const telemetryClient = getTelemetryClient(); if (telemetryClient) { - trackAgentCreated(telemetryClient, { agentRole: agent.role }); + trackAgentCreated(telemetryClient, { agentRole: agent.role, agentId: agent.id }); } await applyDefaultAgentTaskAssignGrant( @@ -1514,7 +1612,7 @@ export function agentRoutes(db: Db) { }); const telemetryClient = getTelemetryClient(); if (telemetryClient) { - trackAgentCreated(telemetryClient, { agentRole: agent.role }); + trackAgentCreated(telemetryClient, { agentRole: agent.role, agentId: agent.id }); } await applyDefaultAgentTaskAssignGrant( @@ -1558,6 +1656,8 @@ export function agentRoutes(db: Db) { res.status(403).json({ error: "Only CEO can manage permissions" }); return; } + } else { + await assertBoardCanManageAgentsForCompany(req, existing.companyId); } const agent = await svc.updatePermissions(id, req.body); @@ -1598,6 +1698,10 @@ export function agentRoutes(db: Db) { }); router.patch("/agents/:id/instructions-path", validate(updateAgentInstructionsPathSchema), async (req, res) => { + if (req.actor.type !== "board") { + throw forbidden("Only board-authenticated callers can manage instructions path or bundle configuration"); + } + const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { @@ -1609,7 +1713,7 @@ export function agentRoutes(db: Db) { const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {}; const explicitKey = asNonEmptyString(req.body.adapterConfigKey); - const defaultKey = DEFAULT_INSTRUCTIONS_PATH_KEYS[existing.adapterType] ?? null; + const defaultKey = resolveInstructionsPathKey(existing.adapterType); const adapterConfigKey = explicitKey ?? defaultKey; if (!adapterConfigKey) { res.status(422).json({ @@ -1858,10 +1962,15 @@ export function agentRoutes(db: Db) { res.status(422).json({ error: "adapterConfig must be an object" }); return; } - const changingInstructionsPath = Object.keys(adapterConfig).some((key) => - KNOWN_INSTRUCTIONS_PATH_KEYS.has(key), + assertNoAgentInstructionsConfigMutation(req, adapterConfig); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectAgentAdapterWorkspaceCommandPaths(adapterConfig), ); - if (changingInstructionsPath) { + const changingInstructionsConfig = Object.keys(adapterConfig).some((key) => + KNOWN_INSTRUCTIONS_BUNDLE_KEYS.includes(key as (typeof KNOWN_INSTRUCTIONS_BUNDLE_KEYS)[number]), + ); + if (changingInstructionsConfig) { await assertCanManageInstructionsPath(req, existing); } patchData.adapterConfig = adapterConfig; @@ -1962,6 +2071,9 @@ export function agentRoutes(db: Db) { router.post("/agents/:id/pause", async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await getAccessibleAgent(req, res, id))) { + return; + } const agent = await svc.pause(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -1985,6 +2097,9 @@ export function agentRoutes(db: Db) { router.post("/agents/:id/resume", async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await getAccessibleAgent(req, res, id))) { + return; + } const agent = await svc.resume(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -2003,9 +2118,48 @@ export function agentRoutes(db: Db) { res.json(agent); }); + router.post("/agents/:id/approve", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const existing = await getAccessibleAgent(req, res, id); + if (!existing) { + return; + } + if (existing.status !== "pending_approval") { + res.status(409).json({ error: "Only pending approval agents can be approved" }); + return; + } + + const approval = await svc.activatePendingApproval(id); + if (!approval) { + res.status(404).json({ error: "Agent not found" }); + return; + } + if (!approval.activated) { + res.status(409).json({ error: "Only pending approval agents can be approved" }); + return; + } + const { agent } = approval; + + await logActivity(db, { + companyId: agent.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "agent.approved", + entityType: "agent", + entityId: agent.id, + details: { source: "agent_detail" }, + }); + + res.json(agent); + }); + router.post("/agents/:id/terminate", async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await getAccessibleAgent(req, res, id))) { + return; + } const agent = await svc.terminate(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -2029,6 +2183,9 @@ export function agentRoutes(db: Db) { router.delete("/agents/:id", async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await getAccessibleAgent(req, res, id))) { + return; + } const agent = await svc.remove(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -2050,6 +2207,10 @@ export function agentRoutes(db: Db) { router.get("/agents/:id/keys", async (req, res) => { assertBoard(req); const id = req.params.id as string; + const agent = await getAccessibleAgent(req, res, id); + if (!agent) { + return; + } const keys = await svc.listKeys(id); res.json(keys); }); @@ -2057,32 +2218,56 @@ export function agentRoutes(db: Db) { router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; + const agent = await getAccessibleAgent(req, res, id); + if (!agent) { + return; + } const key = await svc.createApiKey(id, req.body.name); - const agent = await svc.getById(id); - if (agent) { - await logActivity(db, { - companyId: agent.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "agent.key_created", - entityType: "agent", - entityId: agent.id, - details: { keyId: key.id, name: key.name }, - }); - } + await logActivity(db, { + companyId: agent.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "agent.key_created", + entityType: "agent", + entityId: agent.id, + details: { keyId: key.id, name: key.name }, + }); res.status(201).json(key); }); router.delete("/agents/:id/keys/:keyId", async (req, res) => { assertBoard(req); + const id = req.params.id as string; const keyId = req.params.keyId as string; - const revoked = await svc.revokeKey(keyId); + const agent = await getAccessibleAgent(req, res, id); + if (!agent) { + return; + } + + const key = await svc.getKeyById(keyId); + if (!key || key.agentId !== agent.id) { + res.status(404).json({ error: "Key not found" }); + return; + } + + const revoked = await svc.revokeKey(agent.id, keyId); if (!revoked) { res.status(404).json({ error: "Key not found" }); return; } + + await logActivity(db, { + companyId: agent.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "agent.key_revoked", + entityType: "agent", + entityId: agent.id, + details: { keyId: key.id, name: key.name }, + }); + res.json({ ok: true }); }); @@ -2095,9 +2280,13 @@ export function agentRoutes(db: Db) { } assertCompanyAccess(req, agent.companyId); - if (req.actor.type === "agent" && req.actor.agentId !== id) { - res.status(403).json({ error: "Agent can only invoke itself" }); - return; + if (req.actor.type === "agent") { + if (req.actor.agentId !== id) { + res.status(403).json({ error: "Agent can only invoke itself" }); + return; + } + } else { + await assertBoardCanManageAgentsForCompany(req, agent.companyId); } const run = await heartbeat.wakeup(id, { @@ -2145,9 +2334,13 @@ export function agentRoutes(db: Db) { } assertCompanyAccess(req, agent.companyId); - if (req.actor.type === "agent" && req.actor.agentId !== id) { - res.status(403).json({ error: "Agent can only invoke itself" }); - return; + if (req.actor.type === "agent") { + if (req.actor.agentId !== id) { + res.status(403).json({ error: "Agent can only invoke itself" }); + return; + } + } else { + await assertBoardCanManageAgentsForCompany(req, agent.companyId); } const run = await heartbeat.invoke( @@ -2193,6 +2386,7 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } + await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); if (agent.adapterType !== "claude_local") { res.status(400).json({ error: "Login is only supported for claude_local agents" }); @@ -2244,6 +2438,11 @@ export function agentRoutes(db: Db) { agentId: heartbeatRuns.agentId, agentName: agentsTable.name, adapterType: agentsTable.adapterType, + livenessState: heartbeatRuns.livenessState, + livenessReason: heartbeatRuns.livenessReason, + continuationAttempt: heartbeatRuns.continuationAttempt, + lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, + nextAction: heartbeatRuns.nextAction, issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"), }; @@ -2341,7 +2540,7 @@ export function agentRoutes(db: Db) { router.get("/heartbeat-runs/:runId/log", async (req, res) => { const runId = req.params.runId as string; - const run = await heartbeat.getRun(runId); + const run = await heartbeat.getRunLogAccess(runId); if (!run) { res.status(404).json({ error: "Heartbeat run not found" }); return; @@ -2349,12 +2548,13 @@ export function agentRoutes(db: Db) { assertCompanyAccess(req, run.companyId); const offset = Number(req.query.offset ?? 0); - const limitBytes = Number(req.query.limitBytes ?? 256000); - const result = await heartbeat.readLog(runId, { + const limitBytes = readRunLogLimitBytes(req.query.limitBytes); + const result = await heartbeat.readLog(run, { offset: Number.isFinite(offset) ? offset : 0, - limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000, + limitBytes, }); + res.set("Cache-Control", "no-cache, no-store"); res.json(result); }); @@ -2383,12 +2583,13 @@ export function agentRoutes(db: Db) { assertCompanyAccess(req, operation.companyId); const offset = Number(req.query.offset ?? 0); - const limitBytes = Number(req.query.limitBytes ?? 256000); + const limitBytes = readRunLogLimitBytes(req.query.limitBytes); const result = await workspaceOperations.readLog(operationId, { offset: Number.isFinite(offset) ? offset : 0, - limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000, + limitBytes, }); + res.set("Cache-Control", "no-cache, no-store"); res.json(result); }); @@ -2415,6 +2616,11 @@ export function agentRoutes(db: Db) { agentId: heartbeatRuns.agentId, agentName: agentsTable.name, adapterType: agentsTable.adapterType, + livenessState: heartbeatRuns.livenessState, + livenessReason: heartbeatRuns.livenessReason, + continuationAttempt: heartbeatRuns.continuationAttempt, + lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, + nextAction: heartbeatRuns.nextAction, }) .from(heartbeatRuns) .innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id)) @@ -2442,7 +2648,13 @@ export function agentRoutes(db: Db) { assertCompanyAccess(req, issue.companyId); let run = issue.executionRunId ? await heartbeat.getRunIssueSummary(issue.executionRunId) : null; - if (run && run.status !== "queued" && run.status !== "running") { + if ( + run && + ( + (run.status !== "queued" && run.status !== "running") || + run.issueId !== issue.id + ) + ) { run = null; } diff --git a/server/src/routes/approvals.ts b/server/src/routes/approvals.ts index 3ed20374700..d1743cc050c 100644 --- a/server/src/routes/approvals.ts +++ b/server/src/routes/approvals.ts @@ -134,11 +134,8 @@ export function approvalRoutes(db: Db) { res.status(404).json({ error: "Approval not found" }); return; } - const { approval, applied } = await svc.approve( - id, - req.body.decidedByUserId ?? "board", - req.body.decisionNote, - ); + const decidedByUserId = req.actor.userId ?? "board"; + const { approval, applied } = await svc.approve(id, decidedByUserId, req.body.decisionNote); if (applied) { const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id); @@ -233,11 +230,8 @@ export function approvalRoutes(db: Db) { res.status(404).json({ error: "Approval not found" }); return; } - const { approval, applied } = await svc.reject( - id, - req.body.decidedByUserId ?? "board", - req.body.decisionNote, - ); + const decidedByUserId = req.actor.userId ?? "board"; + const { approval, applied } = await svc.reject(id, decidedByUserId, req.body.decisionNote); if (applied) { await logActivity(db, { @@ -264,11 +258,8 @@ export function approvalRoutes(db: Db) { res.status(404).json({ error: "Approval not found" }); return; } - const approval = await svc.requestRevision( - id, - req.body.decidedByUserId ?? "board", - req.body.decisionNote, - ); + const decidedByUserId = req.actor.userId ?? "board"; + const approval = await svc.requestRevision(id, decidedByUserId, req.body.decisionNote); await logActivity(db, { companyId: approval.companyId, diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 00000000000..ee94341c8fd --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,100 @@ +import { Router } from "express"; +import { eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { authUsers } from "@paperclipai/db"; +import { + authSessionSchema, + currentUserProfileSchema, + updateCurrentUserProfileSchema, +} from "@paperclipai/shared"; +import { unauthorized } from "../errors.js"; +import { validate } from "../middleware/validate.js"; + +async function loadCurrentUserProfile(db: Db, userId: string) { + const user = await db + .select({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .from(authUsers) + .where(eq(authUsers.id, userId)) + .then((rows) => rows[0] ?? null); + + if (!user) { + throw unauthorized("Signed-in user not found"); + } + + return currentUserProfileSchema.parse({ + id: user.id, + email: user.email ?? null, + name: user.name ?? null, + image: user.image ?? null, + }); +} + +export function authRoutes(db: Db) { + const router = Router(); + + router.get("/get-session", async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + + const user = await loadCurrentUserProfile(db, req.actor.userId); + res.json(authSessionSchema.parse({ + session: { + id: `paperclip:${req.actor.source ?? "none"}:${req.actor.userId}`, + userId: req.actor.userId, + }, + user, + })); + }); + + router.get("/profile", async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + + res.json(await loadCurrentUserProfile(db, req.actor.userId)); + }); + + router.patch("/profile", validate(updateCurrentUserProfileSchema), async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + + const patch = updateCurrentUserProfileSchema.parse(req.body); + const now = new Date(); + + const updated = await db + .update(authUsers) + .set({ + name: patch.name, + ...(patch.image !== undefined ? { image: patch.image } : {}), + updatedAt: now, + }) + .where(eq(authUsers.id, req.actor.userId)) + .returning({ + id: authUsers.id, + email: authUsers.email, + name: authUsers.name, + image: authUsers.image, + }) + .then((rows) => rows[0] ?? null); + + if (!updated) { + throw unauthorized("Signed-in user not found"); + } + + res.json(currentUserProfileSchema.parse({ + id: updated.id, + email: updated.email ?? null, + name: updated.name ?? null, + image: updated.image ?? null, + })); + }); + + return router; +} diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index 2b8785fefd7..e7c2dbfc3a8 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -1,12 +1,36 @@ import type { Request } from "express"; import { forbidden, unauthorized } from "../errors.js"; +export function assertAuthenticated(req: Request) { + if (req.actor.type === "none") { + throw unauthorized(); + } +} + export function assertBoard(req: Request) { if (req.actor.type !== "board") { throw forbidden("Board access required"); } } +export function hasBoardOrgAccess(req: Request) { + if (req.actor.type !== "board") { + return false; + } + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { + return true; + } + return Array.isArray(req.actor.companyIds) && req.actor.companyIds.length > 0; +} + +export function assertBoardOrgAccess(req: Request) { + assertBoard(req); + if (hasBoardOrgAccess(req)) { + return; + } + throw forbidden("Company membership or instance admin access required"); +} + export function assertInstanceAdmin(req: Request) { assertBoard(req); if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { @@ -16,24 +40,31 @@ export function assertInstanceAdmin(req: Request) { } export function assertCompanyAccess(req: Request, companyId: string) { - if (req.actor.type === "none") { - throw unauthorized(); - } + assertAuthenticated(req); if (req.actor.type === "agent" && req.actor.companyId !== companyId) { throw forbidden("Agent key cannot access another company"); } - if (req.actor.type === "board" && req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) { + if (req.actor.type === "board" && req.actor.source !== "local_implicit") { const allowedCompanies = req.actor.companyIds ?? []; if (!allowedCompanies.includes(companyId)) { throw forbidden("User does not have access to this company"); } + const method = typeof req.method === "string" ? req.method.toUpperCase() : "GET"; + const isSafeMethod = ["GET", "HEAD", "OPTIONS"].includes(method); + if (!isSafeMethod && !req.actor.isInstanceAdmin && Array.isArray(req.actor.memberships)) { + const membership = req.actor.memberships.find((item) => item.companyId === companyId); + if (!membership || membership.status !== "active") { + throw forbidden("User does not have active company access"); + } + if (membership.membershipRole === "viewer") { + throw forbidden("Viewer access is read-only"); + } + } } } export function getActorInfo(req: Request) { - if (req.actor.type === "none") { - throw unauthorized(); - } + assertAuthenticated(req); if (req.actor.type === "agent") { return { actorType: "agent" as const, diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index 4fa204255a0..07895b92ed5 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -1,19 +1,36 @@ import { and, eq } from "drizzle-orm"; -import { Router } from "express"; +import { Router, type Request, type Response } from "express"; import type { Db } from "@paperclipai/db"; import { issues, projects, projectWorkspaces } from "@paperclipai/db"; -import { updateExecutionWorkspaceSchema } from "@paperclipai/shared"; +import { + findWorkspaceCommandDefinition, + matchWorkspaceRuntimeServiceToCommand, + updateExecutionWorkspaceSchema, + workspaceRuntimeControlTargetSchema, +} from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js"; import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js"; import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js"; import { readProjectWorkspaceRuntimeConfig } from "../services/project-workspace-runtime-config.js"; import { + buildWorkspaceRuntimeDesiredStatePatch, cleanupExecutionWorkspaceArtifacts, + ensurePersistedExecutionWorkspaceAvailable, + listConfiguredRuntimeServiceEntries, + runWorkspaceJobForControl, startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForExecutionWorkspace, } from "../services/workspace-runtime.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { + assertNoAgentHostWorkspaceCommandMutation, + collectExecutionWorkspaceCommandPaths, +} from "./workspace-command-authz.js"; +import { assertCanManageExecutionWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js"; +import { appendWithCap } from "../adapters/utils.js"; + +const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024; export function executionWorkspaceRoutes(db: Db) { const router = Router(); @@ -23,13 +40,16 @@ export function executionWorkspaceRoutes(db: Db) { router.get("/companies/:companyId/execution-workspaces", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const workspaces = await svc.list(companyId, { + const filters = { projectId: req.query.projectId as string | undefined, projectWorkspaceId: req.query.projectWorkspaceId as string | undefined, issueId: req.query.issueId as string | undefined, status: req.query.status as string | undefined, reuseEligible: req.query.reuseEligible === "true", - }); + }; + const workspaces = req.query.summary === "true" + ? await svc.listSummaries(companyId, filters) + : await svc.list(companyId, filters); res.json(workspaces); }); @@ -72,11 +92,11 @@ export function executionWorkspaceRoutes(db: Db) { res.json(operations); }); - router.post("/execution-workspaces/:id/runtime-services/:action", async (req, res) => { + async function handleExecutionWorkspaceRuntimeCommand(req: Request, res: Response) { const id = req.params.id as string; const action = String(req.params.action ?? "").trim().toLowerCase(); - if (action !== "start" && action !== "stop" && action !== "restart") { - res.status(404).json({ error: "Runtime service action not found" }); + if (action !== "start" && action !== "stop" && action !== "restart" && action !== "run") { + res.status(404).json({ error: "Workspace command action not found" }); return; } @@ -87,9 +107,15 @@ export function executionWorkspaceRoutes(db: Db) { } assertCompanyAccess(req, existing.companyId); + await assertCanManageExecutionWorkspaceRuntimeServices(db, req, { + companyId: existing.companyId, + executionWorkspaceId: existing.id, + sourceIssueId: existing.sourceIssueId, + }); + const workspaceCwd = existing.cwd; if (!workspaceCwd) { - res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can manage local runtime services" }); + res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can run workspace commands" }); return; } @@ -115,10 +141,68 @@ export function executionWorkspaceRoutes(db: Db) { const projectWorkspaceRuntime = readProjectWorkspaceRuntimeConfig( (projectWorkspace?.metadata as Record | null) ?? null, )?.workspaceRuntime ?? null; + const projectPolicy = existing.projectId + ? await db + .select({ + executionWorkspacePolicy: projects.executionWorkspacePolicy, + }) + .from(projects) + .where( + and( + eq(projects.id, existing.projectId), + eq(projects.companyId, existing.companyId), + ), + ) + .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)) + : null; const effectiveRuntimeConfig = existing.config?.workspaceRuntime ?? projectWorkspaceRuntime ?? null; + const target = req.body as { workspaceCommandId?: string | null; runtimeServiceId?: string | null; serviceIndex?: number | null }; + const configuredServices = effectiveRuntimeConfig + ? listConfiguredRuntimeServiceEntries({ workspaceRuntime: effectiveRuntimeConfig }) + : []; + const workspaceCommand = effectiveRuntimeConfig + ? findWorkspaceCommandDefinition(effectiveRuntimeConfig, target.workspaceCommandId ?? null) + : null; + if (target.workspaceCommandId && !workspaceCommand) { + res.status(404).json({ error: "Workspace command not found for this execution workspace" }); + return; + } + if (target.runtimeServiceId && !(existing.runtimeServices ?? []).some((service) => service.id === target.runtimeServiceId)) { + res.status(404).json({ error: "Runtime service not found for this execution workspace" }); + return; + } + const matchedRuntimeService = + workspaceCommand?.kind === "service" && !target.runtimeServiceId + ? matchWorkspaceRuntimeServiceToCommand(workspaceCommand, existing.runtimeServices ?? []) + : null; + const selectedRuntimeServiceId = target.runtimeServiceId ?? matchedRuntimeService?.id ?? null; + const selectedServiceIndex = + workspaceCommand?.kind === "service" + ? workspaceCommand.serviceIndex + : target.serviceIndex ?? null; + if ( + selectedServiceIndex !== undefined + && selectedServiceIndex !== null + && (selectedServiceIndex < 0 || selectedServiceIndex >= configuredServices.length) + ) { + res.status(422).json({ error: "Selected runtime service is not defined in this execution workspace runtime config" }); + return; + } + if (workspaceCommand?.kind === "job" && action !== "run") { + res.status(422).json({ error: `Workspace job "${workspaceCommand.name}" can only be run` }); + return; + } + if (workspaceCommand?.kind === "service" && action === "run") { + res.status(422).json({ error: `Workspace service "${workspaceCommand.name}" should be started or restarted, not run` }); + return; + } + if (action === "run" && !workspaceCommand) { + res.status(422).json({ error: "Select a workspace job to run" }); + return; + } if ((action === "start" || action === "restart") && !effectiveRuntimeConfig) { - res.status(422).json({ error: "Execution workspace has no runtime service configuration or inherited project workspace default" }); + res.status(422).json({ error: "Execution workspace has no workspace command configuration or inherited project workspace default" }); return; } @@ -128,21 +212,109 @@ export function executionWorkspaceRoutes(db: Db) { executionWorkspaceId: existing.id, }); let runtimeServiceCount = existing.runtimeServices?.length ?? 0; - const stdout: string[] = []; - const stderr: string[] = []; + let stdout = ""; + let stderr = ""; const operation = await recorder.recordOperation({ phase: action === "stop" ? "workspace_teardown" : "workspace_provision", - command: `workspace runtime ${action}`, + command: workspaceCommand?.command ?? `workspace command ${action}`, cwd: existing.cwd, metadata: { action, executionWorkspaceId: existing.id, + workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, + workspaceCommandKind: workspaceCommand?.kind ?? null, + workspaceCommandName: workspaceCommand?.name ?? null, + runtimeServiceId: selectedRuntimeServiceId, + serviceIndex: selectedServiceIndex, }, run: async () => { + const ensureWorkspaceAvailable = async () => + await ensurePersistedExecutionWorkspaceAvailable({ + base: { + baseCwd: projectWorkspace?.cwd ?? workspaceCwd, + source: existing.mode === "shared_workspace" ? "project_primary" : "task_session", + projectId: existing.projectId, + workspaceId: existing.projectWorkspaceId, + repoUrl: existing.repoUrl, + repoRef: existing.baseRef, + }, + workspace: { + mode: existing.mode, + strategyType: existing.strategyType, + cwd: existing.cwd, + providerRef: existing.providerRef, + projectId: existing.projectId, + projectWorkspaceId: existing.projectWorkspaceId, + repoUrl: existing.repoUrl, + baseRef: existing.baseRef, + branchName: existing.branchName, + config: { + ...existing.config, + provisionCommand: + existing.config?.provisionCommand + ?? projectPolicy?.workspaceStrategy?.provisionCommand + ?? null, + }, + }, + issue: existing.sourceIssueId + ? { + id: existing.sourceIssueId, + identifier: null, + title: existing.name, + } + : null, + agent: { + id: actor.agentId ?? null, + name: actor.actorType === "user" ? "Board" : "Agent", + companyId: existing.companyId, + }, + recorder, + }); + + if (action === "run") { + if (!workspaceCommand || workspaceCommand.kind !== "job") { + throw new Error("Workspace job selection is required"); + } + const availableWorkspace = await ensureWorkspaceAvailable(); + if (!availableWorkspace) { + throw new Error("Execution workspace needs a local path before Paperclip can run workspace commands"); + } + return await runWorkspaceJobForControl({ + actor: { + id: actor.agentId ?? null, + name: actor.actorType === "user" ? "Board" : "Agent", + companyId: existing.companyId, + }, + issue: existing.sourceIssueId + ? { + id: existing.sourceIssueId, + identifier: null, + title: existing.name, + } + : null, + workspace: availableWorkspace, + command: workspaceCommand.rawConfig, + adapterEnv: {}, + recorder, + metadata: { + action, + executionWorkspaceId: existing.id, + workspaceCommandId: workspaceCommand.id, + }, + }).then((nestedOperation) => ({ + status: "succeeded" as const, + exitCode: 0, + metadata: { + nestedOperationId: nestedOperation?.id ?? null, + runtimeServiceCount, + }, + })); + } + const onLog = async (stream: "stdout" | "stderr", chunk: string) => { - if (stream === "stdout") stdout.push(chunk); - else stderr.push(chunk); + if (stream === "stdout") stdout = appendWithCap(stdout, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); + else stderr = appendWithCap(stderr, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); }; if (action === "stop" || action === "restart") { @@ -150,10 +322,15 @@ export function executionWorkspaceRoutes(db: Db) { db, executionWorkspaceId: existing.id, workspaceCwd, + runtimeServiceId: selectedRuntimeServiceId, }); } if (action === "start" || action === "restart") { + const availableWorkspace = await ensureWorkspaceAvailable(); + if (!availableWorkspace) { + throw new Error("Execution workspace needs a local path before Paperclip can manage local runtime services"); + } const startedServices = await startRuntimeServicesForWorkspaceControl({ db, actor: { @@ -168,39 +345,48 @@ export function executionWorkspaceRoutes(db: Db) { title: existing.name, } : null, - workspace: { - baseCwd: workspaceCwd, - source: existing.mode === "shared_workspace" ? "project_primary" : "task_session", - projectId: existing.projectId, - workspaceId: existing.projectWorkspaceId, - repoUrl: existing.repoUrl, - repoRef: existing.baseRef, - strategy: existing.strategyType === "git_worktree" ? "git_worktree" : "project_primary", - cwd: workspaceCwd, - branchName: existing.branchName, - worktreePath: existing.strategyType === "git_worktree" ? workspaceCwd : null, - warnings: [], - created: false, - }, + workspace: availableWorkspace, executionWorkspaceId: existing.id, config: { workspaceRuntime: effectiveRuntimeConfig }, adapterEnv: {}, onLog, + serviceIndex: selectedServiceIndex, }); runtimeServiceCount = startedServices.length; } else { - runtimeServiceCount = 0; + runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (existing.runtimeServices?.length ?? 1) - 1) : 0; } + const currentDesiredState: "running" | "stopped" = + existing.config?.desiredState + ?? ((existing.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running") + ? "running" + : "stopped"); + const nextRuntimeState: { + desiredState: "running" | "stopped"; + serviceStates: Record | null | undefined; + } = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null) + ? { + desiredState: currentDesiredState, + serviceStates: existing.config?.serviceStates ?? null, + } + : buildWorkspaceRuntimeDesiredStatePatch({ + config: { workspaceRuntime: effectiveRuntimeConfig }, + currentDesiredState, + currentServiceStates: existing.config?.serviceStates ?? null, + action, + serviceIndex: selectedServiceIndex, + }); const metadata = mergeExecutionWorkspaceConfig(existing.metadata as Record | null, { - desiredState: action === "stop" ? "stopped" : "running", + desiredState: nextRuntimeState.desiredState, + serviceStates: nextRuntimeState.serviceStates, }); await svc.update(existing.id, { metadata }); return { status: "succeeded", - stdout: stdout.join(""), - stderr: stderr.join(""), + stdout, + stderr, system: action === "stop" ? "Stopped execution workspace runtime services.\n" @@ -209,6 +395,9 @@ export function executionWorkspaceRoutes(db: Db) { : "Started execution workspace runtime services.\n", metadata: { runtimeServiceCount, + workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, + runtimeServiceId: selectedRuntimeServiceId, + serviceIndex: selectedServiceIndex, }, }; }, @@ -231,6 +420,11 @@ export function executionWorkspaceRoutes(db: Db) { entityId: existing.id, details: { runtimeServiceCount, + workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, + workspaceCommandKind: workspaceCommand?.kind ?? null, + workspaceCommandName: workspaceCommand?.name ?? null, + runtimeServiceId: selectedRuntimeServiceId, + serviceIndex: selectedServiceIndex, }, }); @@ -238,7 +432,10 @@ export function executionWorkspaceRoutes(db: Db) { workspace, operation, }); - }); + } + + router.post("/execution-workspaces/:id/runtime-services/:action", validate(workspaceRuntimeControlTargetSchema), handleExecutionWorkspaceRuntimeCommand); + router.post("/execution-workspaces/:id/runtime-commands/:action", validate(workspaceRuntimeControlTargetSchema), handleExecutionWorkspaceRuntimeCommand); router.patch("/execution-workspaces/:id", validate(updateExecutionWorkspaceSchema), async (req, res) => { const id = req.params.id as string; @@ -248,6 +445,13 @@ export function executionWorkspaceRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectExecutionWorkspaceCommandPaths({ + config: req.body.config, + metadata: req.body.metadata, + }), + ); const patch: Record = { ...(req.body.name === undefined ? {} : { name: req.body.name }), ...(req.body.cwd === undefined ? {} : { cwd: req.body.cwd }), diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 795eb9a5cb4..388585a5bdf 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -1,12 +1,33 @@ +import { timingSafeEqual } from "node:crypto"; import { Router } from "express"; import type { Db } from "@paperclipai/db"; import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js"; +import { logger } from "../middleware/logger.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { serverVersion } from "../version.js"; +function shouldExposeFullHealthDetails( + actorType: "none" | "board" | "agent" | null | undefined, + deploymentMode: DeploymentMode, +) { + if (deploymentMode !== "authenticated") return true; + return actorType === "board" || actorType === "agent"; +} + +function hasDevServerStatusToken(providedToken: string | undefined) { + const expectedToken = process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN?.trim(); + const token = providedToken?.trim(); + if (!expectedToken || !token) return false; + + const expected = Buffer.from(expectedToken); + const provided = Buffer.from(token); + if (expected.length !== provided.length) return false; + return timingSafeEqual(expected, provided); +} + export function healthRoutes( db?: Db, opts: { @@ -23,19 +44,32 @@ export function healthRoutes( ) { const router = Router(); - router.get("/", async (_req, res) => { + router.get("/", async (req, res) => { + const actorType = "actor" in req ? req.actor?.type : null; + const exposeFullDetails = shouldExposeFullHealthDetails( + actorType, + opts.deploymentMode, + ); + const exposeDevServerDetails = + exposeFullDetails || hasDevServerStatusToken(req.get("x-paperclip-dev-server-status-token")); + if (!db) { - res.json({ status: "ok", version: serverVersion }); + res.json( + exposeFullDetails + ? { status: "ok", version: serverVersion } + : { status: "ok", deploymentMode: opts.deploymentMode }, + ); return; } try { await db.execute(sql`SELECT 1`); - } catch { + } catch (error) { + logger.warn({ err: error }, "Health check database probe failed"); res.status(503).json({ status: "unhealthy", version: serverVersion, - error: "database_unreachable", + error: "database_unreachable" }); return; } @@ -70,7 +104,7 @@ export function healthRoutes( const persistedDevServerStatus = readPersistedDevServerStatus(); let devServer: ReturnType | undefined; - if (persistedDevServerStatus) { + if (exposeDevServerDetails && persistedDevServerStatus && typeof (db as { select?: unknown }).select === "function") { const instanceSettings = instanceSettingsService(db); const experimentalSettings = await instanceSettings.getExperimental(); const activeRunCount = await db @@ -85,6 +119,17 @@ export function healthRoutes( }); } + if (!exposeFullDetails) { + res.json({ + status: "ok", + deploymentMode: opts.deploymentMode, + bootstrapStatus, + bootstrapInviteActive, + ...(devServer ? { devServer } : {}), + }); + return; + } + res.json({ status: "ok", version: serverVersion, diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index bca529400d2..8d954a95703 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -12,7 +12,9 @@ export { costRoutes } from "./costs.js"; export { activityRoutes } from "./activity.js"; export { dashboardRoutes } from "./dashboard.js"; export { sidebarBadgeRoutes } from "./sidebar-badges.js"; +export { sidebarPreferenceRoutes } from "./sidebar-preferences.js"; export { inboxDismissalRoutes } from "./inbox-dismissals.js"; export { llmRoutes } from "./llms.js"; export { accessRoutes } from "./access.js"; export { instanceSettingsRoutes } from "./instance-settings.js"; +export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js"; diff --git a/server/src/routes/instance-database-backups.ts b/server/src/routes/instance-database-backups.ts new file mode 100644 index 00000000000..a7fbb5ac834 --- /dev/null +++ b/server/src/routes/instance-database-backups.ts @@ -0,0 +1,30 @@ +import { Router } from "express"; +import type { BackupRetentionPolicy, RunDatabaseBackupResult } from "@paperclipai/db"; +import { assertInstanceAdmin } from "./authz.js"; + +export type InstanceDatabaseBackupTrigger = "manual" | "scheduled"; + +export type InstanceDatabaseBackupRunResult = RunDatabaseBackupResult & { + trigger: InstanceDatabaseBackupTrigger; + backupDir: string; + retention: BackupRetentionPolicy; + startedAt: string; + finishedAt: string; + durationMs: number; +}; + +export type InstanceDatabaseBackupService = { + runManualBackup(): Promise; +}; + +export function instanceDatabaseBackupRoutes(service: InstanceDatabaseBackupService) { + const router = Router(); + + router.post("/instance/database-backups", async (req, res) => { + assertInstanceAdmin(req); + const result = await service.runManualBackup(); + res.status(201).json(result); + }); + + return router; +} diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 7bd24e36a3b..946278ef4e9 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -4,7 +4,7 @@ import { patchInstanceExperimentalSettingsSchema, patchInstanceGeneralSettingsSc import { forbidden } from "../errors.js"; import { validate } from "../middleware/validate.js"; import { instanceSettingsService, logActivity } from "../services/index.js"; -import { getActorInfo } from "./authz.js"; +import { assertBoardOrgAccess, getActorInfo } from "./authz.js"; function assertCanManageInstanceSettings(req: Request) { if (req.actor.type !== "board") { @@ -22,10 +22,8 @@ export function instanceSettingsRoutes(db: Db) { router.get("/instance/settings/general", async (req, res) => { // General settings (e.g. keyboardShortcuts) are readable by any - // authenticated board user. Only PATCH requires instance-admin. - if (req.actor.type !== "board") { - throw forbidden("Board access required"); - } + // authenticated org member or instance admin. Only PATCH requires instance-admin. + assertBoardOrgAccess(req); res.json(await svc.getGeneral()); }); @@ -60,11 +58,9 @@ export function instanceSettingsRoutes(db: Db) { ); router.get("/instance/settings/experimental", async (req, res) => { - // Experimental settings are readable by any authenticated board user. - // Only PATCH requires instance-admin. - if (req.actor.type !== "board") { - throw forbidden("Board access required"); - } + // Experimental settings are readable by any authenticated org member + // or instance admin. Only PATCH requires instance-admin. + assertBoardOrgAccess(req); res.json(await svc.getExperimental()); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 1e54822f165..3c0fb30390c 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -10,6 +10,7 @@ import { createIssueWorkProductSchema, createIssueLabelSchema, checkoutIssueSchema, + createChildIssueSchema, createIssueSchema, feedbackTargetTypeSchema, feedbackTraceStatusSchema, @@ -17,6 +18,7 @@ import { upsertIssueFeedbackVoteSchema, linkIssueApprovalSchema, issueDocumentKeySchema, + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, restoreIssueDocumentRevisionSchema, updateIssueWorkProductSchema, upsertIssueDocumentSchema, @@ -38,8 +40,11 @@ import { heartbeatService, instanceSettingsService, issueApprovalService, + ISSUE_LIST_DEFAULT_LIMIT, + ISSUE_LIST_MAX_LIMIT, issueService, memoryService, + clampIssueListLimit, documentService, logActivity, projectService, @@ -47,8 +52,12 @@ import { workProductService, } from "../services/index.js"; import { logger } from "../middleware/logger.js"; -import { forbidden, HttpError, unauthorized } from "../errors.js"; +import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { + assertNoAgentHostWorkspaceCommandMutation, + collectIssueWorkspaceCommandPaths, +} from "./workspace-command-authz.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; import { isInlineAttachmentContentType, @@ -143,6 +152,22 @@ function summarizeExecutionParticipants( ); } +function isClosedIssueStatus(status: string | null | undefined): status is "done" | "cancelled" { + return status === "done" || status === "cancelled"; +} + +function shouldImplicitlyReopenCommentForAgent(input: { + issueStatus: string | null | undefined; + assigneeAgentId: string | null | undefined; + actorType: "agent" | "user"; + actorId: string; +}) { + if (!isClosedIssueStatus(input.issueStatus)) return false; + if (typeof input.assigneeAgentId !== "string" || input.assigneeAgentId.length === 0) return false; + if (input.actorType === "agent" && input.actorId === input.assigneeAgentId) return false; + return true; +} + function diffExecutionParticipants( previousPolicy: NormalizedExecutionPolicy | null, nextPolicy: NormalizedExecutionPolicy | null, @@ -451,6 +476,53 @@ export function issueRoutes( return runToInterrupt?.status === "running" ? runToInterrupt : null; } + async function normalizeIssueAssigneeAgentReference( + companyId: string, + rawAssigneeAgentId: string | null | undefined, + ) { + if (rawAssigneeAgentId === undefined || rawAssigneeAgentId === null) { + return rawAssigneeAgentId; + } + + const raw = rawAssigneeAgentId.trim(); + if (raw.length === 0) { + return rawAssigneeAgentId; + } + + const resolved = await agentsSvc.resolveByReference(companyId, raw); + if (resolved.ambiguous) { + throw conflict("Agent shortname is ambiguous in this company. Use the agent ID."); + } + if (!resolved.agent) { + throw notFound("Agent not found"); + } + return resolved.agent.id; + } + function toValidTimestamp(value: Date | string | null | undefined) { + if (!value) return null; + const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : null; + } + + function isQueuedIssueCommentForActiveRun(params: { + comment: { + authorAgentId?: string | null; + createdAt?: Date | string | null; + }; + activeRun: { + agentId?: string | null; + startedAt?: Date | string | null; + createdAt?: Date | string | null; + }; + }) { + const activeRunStartedAtMs = + toValidTimestamp(params.activeRun.startedAt) ?? toValidTimestamp(params.activeRun.createdAt); + const commentCreatedAtMs = toValidTimestamp(params.comment.createdAt); + + if (activeRunStartedAtMs === null || commentCreatedAtMs === null) return false; + if (params.comment.authorAgentId && params.comment.authorAgentId === params.activeRun.agentId) return false; + return commentCreatedAtMs >= activeRunStartedAtMs; + } async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) { if (!issue.executionWorkspaceId) return null; const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId); @@ -556,8 +628,10 @@ export function issueRoutes( ? req.actor.userId : unreadForUserFilterRaw; const rawLimit = req.query.limit as string | undefined; - const parsedLimit = rawLimit ? Number.parseInt(rawLimit, 10) : null; - const limit = parsedLimit ?? undefined; + const parsedLimit = rawLimit !== undefined && /^\d+$/.test(rawLimit) + ? Number.parseInt(rawLimit, 10) + : null; + const limit = parsedLimit === null ? ISSUE_LIST_DEFAULT_LIMIT : clampIssueListLimit(parsedLimit); if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) { res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); @@ -576,7 +650,7 @@ export function issueRoutes( return; } if (rawLimit !== undefined && (parsedLimit === null || !Number.isInteger(parsedLimit) || parsedLimit <= 0)) { - res.status(400).json({ error: "limit must be a positive integer" }); + res.status(400).json({ error: `limit must be a positive integer up to ${ISSUE_LIST_MAX_LIMIT}` }); return; } @@ -596,6 +670,8 @@ export function issueRoutes( originId: req.query.originId as string | undefined, includeRoutineExecutions: req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1", + excludeRoutineExecutions: + req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1", q: req.query.q as string | undefined, limit, }); @@ -667,7 +743,7 @@ export function issueRoutes( const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations] = await Promise.all([ resolveIssueProjectAndGoal(issue), svc.getAncestors(issue.id), - svc.findMentionedProjectIds(issue.id), + svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }), documentsSvc.getIssueDocumentPayload(issue), svc.getRelationSummaries(issue.id), ]); @@ -707,7 +783,7 @@ export function issueRoutes( ? req.query.wakeCommentId.trim() : null; - const [{ project, goal }, ancestors, commentCursor, wakeComment, relations, attachments] = + const [{ project, goal }, ancestors, commentCursor, wakeComment, relations, attachments, continuationSummary] = await Promise.all([ resolveIssueProjectAndGoal(issue), svc.getAncestors(issue.id), @@ -715,6 +791,7 @@ export function issueRoutes( wakeCommentId ? svc.getComment(wakeCommentId) : null, svc.getRelationSummaries(issue.id), svc.listAttachments(issue.id), + documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY), ]); res.json({ @@ -771,6 +848,16 @@ export function issueRoutes( contentPath: withContentPath(a).contentPath, createdAt: a.createdAt, })), + continuationSummary: continuationSummary + ? { + key: continuationSummary.key, + title: continuationSummary.title, + body: continuationSummary.body, + latestRevisionId: continuationSummary.latestRevisionId, + latestRevisionNumber: continuationSummary.latestRevisionNumber, + updatedAt: continuationSummary.updatedAt, + } + : null, }); }); @@ -794,7 +881,9 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); - const docs = await documentsSvc.listIssueDocuments(issue.id); + const docs = await documentsSvc.listIssueDocuments(issue.id, { + includeSystem: req.query.includeSystem === "true", + }); res.json(docs); }); @@ -1308,6 +1397,7 @@ export function issueRoutes( router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (req.body.assigneeAgentId || req.body.assigneeUserId) { await assertCanAssignTasks(req, companyId); } @@ -1350,6 +1440,62 @@ export function issueRoutes( res.status(201).json(issue); }); + router.post("/issues/:id/children", validate(createChildIssueSchema), async (req, res) => { + const parentId = req.params.id as string; + const parent = await svc.getById(parentId); + if (!parent) { + res.status(404).json({ error: "Parent issue not found" }); + return; + } + assertCompanyAccess(req, parent.companyId); + assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); + if (req.body.assigneeAgentId || req.body.assigneeUserId) { + await assertCanAssignTasks(req, parent.companyId); + } + + const actor = getActorInfo(req); + const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy); + const { issue, parentBlockerAdded } = await svc.createChild(parent.id, { + ...req.body, + executionPolicy, + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + actorAgentId: actor.agentId, + actorUserId: actor.actorType === "user" ? actor.actorId : null, + }); + + await logActivity(db, { + companyId: parent.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.child_created", + entityType: "issue", + entityId: issue.id, + details: { + parentId: parent.id, + identifier: issue.identifier, + title: issue.title, + inheritedExecutionWorkspaceFromIssueId: parent.id, + ...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}), + ...(parentBlockerAdded ? { parentBlockerAdded: true } : {}), + }, + }); + + void queueIssueAssignmentWakeup({ + heartbeat, + issue, + reason: "issue_assigned", + mutation: "create", + contextSource: "issue.child_create", + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + }); + + res.status(201).json(issue); + }); + router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); @@ -1358,10 +1504,15 @@ export function issueRoutes( return; } assertCompanyAccess(req, existing.companyId); + assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return; const actor = getActorInfo(req); - const isClosed = existing.status === "done" || existing.status === "cancelled"; + const isClosed = isClosedIssueStatus(existing.status); + const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference( + existing.companyId, + req.body.assigneeAgentId as string | null | undefined, + ); const existingRelations = Array.isArray(req.body.blockedByIssueIds) ? await svc.getRelationSummaries(existing.id) @@ -1373,6 +1524,17 @@ export function issueRoutes( hiddenAt: hiddenAtRaw, ...updateFields } = req.body; + const requestedAssigneeAgentId = + normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId; + const effectiveReopenRequested = + reopenRequested || + (!!commentBody && + shouldImplicitlyReopenCommentForAgent({ + issueStatus: existing.status, + assigneeAgentId: requestedAssigneeAgentId, + actorType: actor.actorType, + actorId: actor.actorId, + })); let interruptedRunId: string | null = null; const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing); const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0; @@ -1415,7 +1577,7 @@ export function issueRoutes( if (hiddenAtRaw !== undefined) { updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; } - if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) { + if (commentBody && effectiveReopenRequested && isClosed && updateFields.status === undefined) { updateFields.status = "todo"; } if (req.body.executionPolicy !== undefined) { @@ -1426,14 +1588,16 @@ export function issueRoutes( updateFields.executionPolicy !== undefined ? (updateFields.executionPolicy as NormalizedExecutionPolicy | null) : previousExecutionPolicy; + if (normalizedAssigneeAgentId !== undefined) { + updateFields.assigneeAgentId = normalizedAssigneeAgentId; + } const transition = applyIssueExecutionPolicyTransition({ issue: existing, policy: nextExecutionPolicy, requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined, requestedAssigneePatch: { - assigneeAgentId: - req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null), + assigneeAgentId: normalizedAssigneeAgentId, assigneeUserId: req.body.assigneeUserId === undefined ? undefined : (req.body.assigneeUserId as string | null), }, @@ -1522,8 +1686,7 @@ export function issueRoutes( issueId: id, companyId: existing.companyId, assigneePatch: { - assigneeAgentId: - req.body.assigneeAgentId === undefined ? "__omitted__" : req.body.assigneeAgentId, + assigneeAgentId: normalizedAssigneeAgentId === undefined ? "__omitted__" : normalizedAssigneeAgentId, assigneeUserId: req.body.assigneeUserId === undefined ? "__omitted__" : req.body.assigneeUserId, }, @@ -1574,7 +1737,7 @@ export function issueRoutes( const hasFieldChanges = Object.keys(previous).length > 0; const reopened = commentBody && - reopenRequested === true && + effectiveReopenRequested && isClosed && previous.status !== undefined && issue.status === "todo"; @@ -1677,7 +1840,13 @@ export function issueRoutes( if (tc && actor.agentId) { const actorAgent = await agentsSvc.getById(actor.agentId); if (actorAgent) { - trackAgentTaskCompleted(tc, { agentRole: actorAgent.role }); + const model = typeof actorAgent.adapterConfig?.model === "string" ? actorAgent.adapterConfig.model : undefined; + trackAgentTaskCompleted(tc, { + agentRole: actorAgent.role, + agentId: actorAgent.id, + adapterType: actorAgent.adapterType, + model, + }); } } } @@ -1717,6 +1886,10 @@ export function issueRoutes( existing.status === "backlog" && issue.status !== "backlog" && req.body.status !== undefined; + const statusChangedFromBlockedToTodo = + existing.status === "blocked" && + issue.status === "todo" && + req.body.status !== undefined; const previousExecutionState = parseIssueExecutionState(existing.executionState); const nextExecutionState = parseIssueExecutionState(issue.executionState); const executionStageWakeup = buildExecutionStageWakeup({ @@ -1770,7 +1943,7 @@ export function issueRoutes( }); } - if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) { + if (!assigneeChanged && (statusChangedFromBacklog || statusChangedFromBlockedToTodo) && issue.assigneeAgentId) { addWakeup(issue.assigneeAgentId, { source: "automation", triggerDetail: "system", @@ -1796,7 +1969,7 @@ export function issueRoutes( const selfComment = actorIsAgent && actor.actorId === assigneeId; const skipAssigneeCommentWake = selfComment || isClosed; - if (assigneeId && !assigneeChanged && !skipAssigneeCommentWake) { + if (assigneeId && !assigneeChanged && (reopened || !skipAssigneeCommentWake)) { addWakeup(assigneeId, { source: "automation", triggerDetail: "system", @@ -1891,6 +2064,8 @@ export function issueRoutes( issueId: parent.id, completedChildIssueId: issue.id, childIssueIds: parent.childIssueIds, + childIssueSummaries: parent.childIssueSummaries, + childIssueSummaryTruncated: parent.childIssueSummaryTruncated, }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, @@ -1901,6 +2076,8 @@ export function issueRoutes( source: "issue.children_completed", completedChildIssueId: issue.id, childIssueIds: parent.childIssueIds, + childIssueSummaries: parent.childIssueSummaries, + childIssueSummaryTruncated: parent.childIssueSummaryTruncated, }, }); } @@ -2117,6 +2294,72 @@ export function issueRoutes( res.json(comment); }); + router.delete("/issues/:id/comments/:commentId", async (req, res) => { + const id = req.params.id as string; + const commentId = req.params.commentId as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return; + + const comment = await svc.getComment(commentId); + if (!comment || comment.issueId !== id) { + res.status(404).json({ error: "Comment not found" }); + return; + } + + const actor = getActorInfo(req); + const actorOwnsComment = + actor.actorType === "agent" + ? comment.authorAgentId === actor.agentId + : comment.authorUserId === actor.actorId; + if (!actorOwnsComment) { + res.status(403).json({ error: "Only the comment author can cancel queued comments" }); + return; + } + + const activeRun = await resolveActiveIssueRun(issue); + if (!activeRun) { + res.status(409).json({ error: "Queued comment can no longer be canceled" }); + return; + } + + if (!isQueuedIssueCommentForActiveRun({ comment, activeRun })) { + res.status(409).json({ error: "Only queued comments can be canceled" }); + return; + } + + const removed = await svc.removeComment(commentId); + if (!removed) { + res.status(404).json({ error: "Comment not found" }); + return; + } + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.comment_cancelled", + entityType: "issue", + entityId: issue.id, + details: { + commentId: removed.id, + bodySnippet: removed.body.slice(0, 120), + identifier: issue.identifier, + issueTitle: issue.title, + source: "queue_cancel", + queueTargetRunId: activeRun.id, + }, + }); + + res.json(removed); + }); + router.get("/issues/:id/feedback-votes", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); @@ -2215,13 +2458,21 @@ export function issueRoutes( const actor = getActorInfo(req); const reopenRequested = req.body.reopen === true; const interruptRequested = req.body.interrupt === true; - const isClosed = issue.status === "done" || issue.status === "cancelled"; + const isClosed = isClosedIssueStatus(issue.status); + const effectiveReopenRequested = + reopenRequested || + shouldImplicitlyReopenCommentForAgent({ + issueStatus: issue.status, + assigneeAgentId: issue.assigneeAgentId, + actorType: actor.actorType, + actorId: actor.actorId, + }); let reopened = false; let reopenFromStatus: string | null = null; let interruptedRunId: string | null = null; let currentIssue = issue; - if (reopenRequested && isClosed) { + if (effectiveReopenRequested && isClosed) { const reopenedIssue = await svc.update(id, { status: "todo" }); if (!reopenedIssue) { res.status(404).json({ error: "Issue not found" }); diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 95215d899af..d75912d07cf 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -11,7 +11,8 @@ * - Retrieving UI slot contributions for frontend rendering * - Discovering and executing plugin-contributed agent tools * - * All routes require board-level authentication (assertBoard middleware). + * All routes require board-level authentication, and sensitive instance-wide + * mutations such as install/upgrade require instance-admin privileges. * * @module server/routes/plugins * @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification @@ -47,7 +48,7 @@ import type { PluginStreamBus } from "../services/plugin-stream-bus.js"; import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js"; import type { ToolRunContext } from "@paperclipai/plugin-sdk"; import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk"; -import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoardOrgAccess, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; import { validateInstanceConfig } from "../services/plugin-config-validator.js"; /** UI slot declaration extracted from plugin manifest */ @@ -371,7 +372,7 @@ export function pluginRoutes( * Response: `PluginRecord[]` */ router.get("/plugins", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const rawStatus = req.query.status; if (rawStatus !== undefined) { if (typeof rawStatus !== "string" || !(PLUGIN_STATUSES as readonly string[]).includes(rawStatus)) { @@ -395,7 +396,7 @@ export function pluginRoutes( * These can be installed through the normal local-path install flow. */ router.get("/plugins/examples", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); res.json(listBundledPluginExamples()); }); @@ -440,7 +441,7 @@ export function pluginRoutes( * Response: PluginUiContribution[] */ router.get("/plugins/ui-contributions", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const plugins = await registry.listByStatus("ready"); const contributions: PluginUiContribution[] = plugins @@ -483,7 +484,7 @@ export function pluginRoutes( * Errors: 501 if tool dispatcher is not configured */ router.get("/plugins/tools", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!toolDeps) { res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); @@ -517,7 +518,7 @@ export function pluginRoutes( * - 502 if the plugin worker is unavailable or the RPC call fails */ router.post("/plugins/tools/execute", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!toolDeps) { res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); @@ -583,6 +584,9 @@ export function pluginRoutes( * * Install a plugin from npm or a local filesystem path. * + * Instance-wide plugin installation is restricted to instance admins because + * the install flow fetches and inspects package contents on the host. + * * Request body: * - packageName: npm package name or local path (required) * - version: Target version for npm packages (optional) @@ -601,7 +605,7 @@ export function pluginRoutes( * - `500` — installation succeeded but manifest is missing (indicates a loader bug) */ router.post("/plugins/install", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { packageName, version, isLocalPath } = req.body as PluginInstallRequest; // Input validation @@ -793,7 +797,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/bridge/data", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -876,7 +880,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/bridge/action", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -960,7 +964,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/data/:key", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1039,7 +1043,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/actions/:key", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1120,7 +1124,7 @@ export function pluginRoutes( * - 501 if bridge deps or stream bus are not configured */ router.get("/plugins/:pluginId/bridge/stream/:channel", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps?.streamBus) { res.status(501).json({ error: "Plugin stream bridge is not enabled" }); @@ -1198,7 +1202,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { @@ -1228,7 +1232,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.delete("/plugins/:pluginId", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const purge = req.query.purge === "true"; @@ -1264,7 +1268,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/enable", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1302,7 +1306,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/disable", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const body = req.body as { reason?: string } | undefined; const reason = body?.reason; @@ -1343,7 +1347,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/health", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1411,7 +1415,7 @@ export function pluginRoutes( * Response: Array of log entries, newest first. */ router.get("/plugins/:pluginId/logs", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1450,6 +1454,9 @@ export function pluginRoutes( * * Upgrade a plugin to a newer version. * + * Upgrades are restricted to instance admins because they fetch and inspect + * new package contents on the host before activation. + * * Request body (optional): * - version: Target version (defaults to latest) * @@ -1461,7 +1468,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/upgrade", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const body = req.body as { version?: string } | undefined; const version = body?.version; @@ -1510,7 +1517,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/config", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1540,7 +1547,7 @@ export function pluginRoutes( * - 404 if plugin not found */ router.post("/plugins/:pluginId/config", async (req, res) => { - assertBoard(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1645,7 +1652,7 @@ export function pluginRoutes( * - 502 if the worker is unavailable */ router.post("/plugins/:pluginId/config/test", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1742,7 +1749,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/jobs", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!jobDeps) { res.status(501).json({ error: "Job scheduling is not enabled" }); return; @@ -1788,7 +1795,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/jobs/:jobId/runs", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!jobDeps) { res.status(501).json({ error: "Job scheduling is not enabled" }); return; @@ -1836,7 +1843,7 @@ export function pluginRoutes( * - 400 if job not found, not active, already running, or worker unavailable */ router.post("/plugins/:pluginId/jobs/:jobId/trigger", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!jobDeps) { res.status(501).json({ error: "Job scheduling is not enabled" }); return; @@ -2042,7 +2049,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/dashboard", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index cd523dce772..036b0de60ff 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -1,19 +1,37 @@ -import { Router, type Request } from "express"; +import { Router, type Request, type Response } from "express"; import type { Db } from "@paperclipai/db"; import { createProjectSchema, createProjectWorkspaceSchema, + findWorkspaceCommandDefinition, isUuidLike, + matchWorkspaceRuntimeServiceToCommand, updateProjectSchema, updateProjectWorkspaceSchema, + workspaceRuntimeControlTargetSchema, } from "@paperclipai/shared"; import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js"; import { conflict } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; -import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js"; +import { + buildWorkspaceRuntimeDesiredStatePatch, + listConfiguredRuntimeServiceEntries, + runWorkspaceJobForControl, + startRuntimeServicesForWorkspaceControl, + stopRuntimeServicesForProjectWorkspace, +} from "../services/workspace-runtime.js"; +import { + assertNoAgentHostWorkspaceCommandMutation, + collectProjectExecutionWorkspaceCommandPaths, + collectProjectWorkspaceCommandPaths, +} from "./workspace-command-authz.js"; +import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js"; import { getTelemetryClient } from "../telemetry.js"; +import { appendWithCap } from "../adapters/utils.js"; + +const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024; export function projectRoutes(db: Db) { const router = Router(); @@ -84,6 +102,13 @@ export function projectRoutes(db: Db) { }; const { workspace, ...projectData } = req.body as CreateProjectPayload; + assertNoAgentHostWorkspaceCommandMutation( + req, + [ + ...collectProjectExecutionWorkspaceCommandPaths(projectData.executionWorkspacePolicy), + ...collectProjectWorkspaceCommandPaths(workspace, "workspace"), + ], + ); if (projectData.env !== undefined) { projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence( companyId, @@ -135,6 +160,10 @@ export function projectRoutes(db: Db) { } assertCompanyAccess(req, existing.companyId); const body = { ...req.body }; + assertNoAgentHostWorkspaceCommandMutation( + req, + collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy), + ); if (typeof body.archivedAt === "string") { body.archivedAt = new Date(body.archivedAt); } @@ -191,6 +220,10 @@ export function projectRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectProjectWorkspaceCommandPaths(req.body), + ); const workspace = await svc.createWorkspace(id, req.body); if (!workspace) { res.status(422).json({ error: "Invalid project workspace payload" }); @@ -229,6 +262,10 @@ export function projectRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectProjectWorkspaceCommandPaths(req.body), + ); const workspaceExists = (await svc.listWorkspaces(id)).some((workspace) => workspace.id === workspaceId); if (!workspaceExists) { res.status(404).json({ error: "Project workspace not found" }); @@ -259,12 +296,12 @@ export function projectRoutes(db: Db) { }, ); - router.post("/projects/:id/workspaces/:workspaceId/runtime-services/:action", async (req, res) => { + async function handleProjectWorkspaceRuntimeCommand(req: Request, res: Response) { const id = req.params.id as string; const workspaceId = req.params.workspaceId as string; const action = String(req.params.action ?? "").trim().toLowerCase(); - if (action !== "start" && action !== "stop" && action !== "restart") { - res.status(404).json({ error: "Runtime service action not found" }); + if (action !== "start" && action !== "stop" && action !== "restart" && action !== "run") { + res.status(404).json({ error: "Workspace command action not found" }); return; } @@ -281,43 +318,140 @@ export function projectRoutes(db: Db) { return; } + await assertCanManageProjectWorkspaceRuntimeServices(db, req, { + companyId: project.companyId, + projectWorkspaceId: workspace.id, + }); + const workspaceCwd = workspace.cwd; if (!workspaceCwd) { - res.status(422).json({ error: "Project workspace needs a local path before Paperclip can manage local runtime services" }); + res.status(422).json({ error: "Project workspace needs a local path before Paperclip can run workspace commands" }); return; } const runtimeConfig = workspace.runtimeConfig?.workspaceRuntime ?? null; + const target = req.body as { workspaceCommandId?: string | null; runtimeServiceId?: string | null; serviceIndex?: number | null }; + const configuredServices = runtimeConfig ? listConfiguredRuntimeServiceEntries({ workspaceRuntime: runtimeConfig }) : []; + const workspaceCommand = runtimeConfig + ? findWorkspaceCommandDefinition(runtimeConfig, target.workspaceCommandId ?? null) + : null; + if (target.workspaceCommandId && !workspaceCommand) { + res.status(404).json({ error: "Workspace command not found for this project workspace" }); + return; + } + if (target.runtimeServiceId && !(workspace.runtimeServices ?? []).some((service) => service.id === target.runtimeServiceId)) { + res.status(404).json({ error: "Runtime service not found for this project workspace" }); + return; + } + const matchedRuntimeService = + workspaceCommand?.kind === "service" && !target.runtimeServiceId + ? matchWorkspaceRuntimeServiceToCommand(workspaceCommand, workspace.runtimeServices ?? []) + : null; + const selectedRuntimeServiceId = target.runtimeServiceId ?? matchedRuntimeService?.id ?? null; + const selectedServiceIndex = + workspaceCommand?.kind === "service" + ? workspaceCommand.serviceIndex + : target.serviceIndex ?? null; + if ( + selectedServiceIndex !== undefined + && selectedServiceIndex !== null + && (selectedServiceIndex < 0 || selectedServiceIndex >= configuredServices.length) + ) { + res.status(422).json({ error: "Selected runtime service is not defined in this project workspace runtime config" }); + return; + } + if (workspaceCommand?.kind === "job" && action !== "run") { + res.status(422).json({ error: `Workspace job "${workspaceCommand.name}" can only be run` }); + return; + } + if (workspaceCommand?.kind === "service" && action === "run") { + res.status(422).json({ error: `Workspace service "${workspaceCommand.name}" should be started or restarted, not run` }); + return; + } + if (action === "run" && !workspaceCommand) { + res.status(422).json({ error: "Select a workspace job to run" }); + return; + } if ((action === "start" || action === "restart") && !runtimeConfig) { - res.status(422).json({ error: "Project workspace has no runtime service configuration" }); + res.status(422).json({ error: "Project workspace has no workspace command configuration" }); return; } const actor = getActorInfo(req); const recorder = workspaceOperations.createRecorder({ companyId: project.companyId }); let runtimeServiceCount = workspace.runtimeServices?.length ?? 0; - const stdout: string[] = []; - const stderr: string[] = []; + let stdout = ""; + let stderr = ""; const operation = await recorder.recordOperation({ phase: action === "stop" ? "workspace_teardown" : "workspace_provision", - command: `workspace runtime ${action}`, + command: workspaceCommand?.command ?? `workspace command ${action}`, cwd: workspace.cwd, metadata: { action, projectId: project.id, projectWorkspaceId: workspace.id, + workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, + workspaceCommandKind: workspaceCommand?.kind ?? null, + workspaceCommandName: workspaceCommand?.name ?? null, + runtimeServiceId: selectedRuntimeServiceId, + serviceIndex: selectedServiceIndex, }, run: async () => { + if (action === "run") { + if (!workspaceCommand || workspaceCommand.kind !== "job") { + throw new Error("Workspace job selection is required"); + } + return await runWorkspaceJobForControl({ + actor: { + id: actor.agentId ?? null, + name: actor.actorType === "user" ? "Board" : "Agent", + companyId: project.companyId, + }, + issue: null, + workspace: { + baseCwd: workspaceCwd, + source: "project_primary", + projectId: project.id, + workspaceId: workspace.id, + repoUrl: workspace.repoUrl, + repoRef: workspace.repoRef, + strategy: "project_primary", + cwd: workspaceCwd, + branchName: workspace.defaultRef ?? workspace.repoRef ?? null, + worktreePath: null, + warnings: [], + created: false, + }, + command: workspaceCommand.rawConfig, + adapterEnv: {}, + recorder, + metadata: { + action, + projectId: project.id, + projectWorkspaceId: workspace.id, + workspaceCommandId: workspaceCommand.id, + }, + }).then((nestedOperation) => ({ + status: "succeeded" as const, + exitCode: 0, + metadata: { + nestedOperationId: nestedOperation?.id ?? null, + runtimeServiceCount, + }, + })); + } + const onLog = async (stream: "stdout" | "stderr", chunk: string) => { - if (stream === "stdout") stdout.push(chunk); - else stderr.push(chunk); + if (stream === "stdout") stdout = appendWithCap(stdout, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); + else stderr = appendWithCap(stderr, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); }; if (action === "stop" || action === "restart") { await stopRuntimeServicesForProjectWorkspace({ db, projectWorkspaceId: workspace.id, + runtimeServiceId: selectedRuntimeServiceId, }); } @@ -347,22 +481,44 @@ export function projectRoutes(db: Db) { config: { workspaceRuntime: runtimeConfig }, adapterEnv: {}, onLog, + serviceIndex: selectedServiceIndex, }); runtimeServiceCount = startedServices.length; } else { - runtimeServiceCount = 0; + runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (workspace.runtimeServices?.length ?? 1) - 1) : 0; } + const currentDesiredState: "running" | "stopped" = + workspace.runtimeConfig?.desiredState + ?? ((workspace.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running") + ? "running" + : "stopped"); + const nextRuntimeState: { + desiredState: "running" | "stopped"; + serviceStates: Record | null | undefined; + } = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null) + ? { + desiredState: currentDesiredState, + serviceStates: workspace.runtimeConfig?.serviceStates ?? null, + } + : buildWorkspaceRuntimeDesiredStatePatch({ + config: { workspaceRuntime: runtimeConfig }, + currentDesiredState, + currentServiceStates: workspace.runtimeConfig?.serviceStates ?? null, + action, + serviceIndex: selectedServiceIndex, + }); await svc.updateWorkspace(project.id, workspace.id, { runtimeConfig: { - desiredState: action === "stop" ? "stopped" : "running", + desiredState: nextRuntimeState.desiredState, + serviceStates: nextRuntimeState.serviceStates, }, }); return { status: "succeeded", - stdout: stdout.join(""), - stderr: stderr.join(""), + stdout, + stderr, system: action === "stop" ? "Stopped project workspace runtime services.\n" @@ -371,6 +527,9 @@ export function projectRoutes(db: Db) { : "Started project workspace runtime services.\n", metadata: { runtimeServiceCount, + workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, + runtimeServiceId: selectedRuntimeServiceId, + serviceIndex: selectedServiceIndex, }, }; }, @@ -389,6 +548,11 @@ export function projectRoutes(db: Db) { details: { projectWorkspaceId: workspace.id, runtimeServiceCount, + workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, + workspaceCommandKind: workspaceCommand?.kind ?? null, + workspaceCommandName: workspaceCommand?.name ?? null, + runtimeServiceId: selectedRuntimeServiceId, + serviceIndex: selectedServiceIndex, }, }); @@ -396,7 +560,10 @@ export function projectRoutes(db: Db) { workspace: updatedWorkspace, operation, }); - }); + } + + router.post("/projects/:id/workspaces/:workspaceId/runtime-services/:action", validate(workspaceRuntimeControlTargetSchema), handleProjectWorkspaceRuntimeCommand); + router.post("/projects/:id/workspaces/:workspaceId/runtime-commands/:action", validate(workspaceRuntimeControlTargetSchema), handleProjectWorkspaceRuntimeCommand); router.delete("/projects/:id/workspaces/:workspaceId", async (req, res) => { const id = req.params.id as string; diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts index 505b7704753..8b4d1f22852 100644 --- a/server/src/routes/sidebar-badges.ts +++ b/server/src/routes/sidebar-badges.ts @@ -5,6 +5,7 @@ import { inboxDismissals, joinRequests } from "@paperclipai/db"; import { sidebarBadgeService } from "../services/sidebar-badges.js"; import { accessService } from "../services/access.js"; import { dashboardService } from "../services/dashboard.js"; +import { collapseDuplicatePendingHumanJoinRequests } from "../lib/join-request-dedupe.js"; import { assertCompanyAccess } from "./authz.js"; function buildDismissedAtByKey( @@ -35,14 +36,24 @@ export function sidebarBadgeRoutes(db: Db) { } const visibleJoinRequests = canApproveJoins - ? await db - .select({ - id: joinRequests.id, - updatedAt: joinRequests.updatedAt, - createdAt: joinRequests.createdAt, - }) - .from(joinRequests) - .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval"))) + ? collapseDuplicatePendingHumanJoinRequests( + await db + .select({ + id: joinRequests.id, + requestType: joinRequests.requestType, + status: joinRequests.status, + requestingUserId: joinRequests.requestingUserId, + requestEmailSnapshot: joinRequests.requestEmailSnapshot, + updatedAt: joinRequests.updatedAt, + createdAt: joinRequests.createdAt, + }) + .from(joinRequests) + .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval"))) + ).map(({ id, updatedAt, createdAt }) => ({ + id, + updatedAt, + createdAt, + })) : []; const dismissedAtByKey = diff --git a/server/src/routes/sidebar-preferences.ts b/server/src/routes/sidebar-preferences.ts new file mode 100644 index 00000000000..f7247c448a3 --- /dev/null +++ b/server/src/routes/sidebar-preferences.ts @@ -0,0 +1,71 @@ +import { Router, type Request, type Response } from "express"; +import type { Db } from "@paperclipai/db"; +import { upsertSidebarOrderPreferenceSchema } from "@paperclipai/shared"; +import { validate } from "../middleware/validate.js"; +import { logActivity, sidebarPreferenceService } from "../services/index.js"; +import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; + +function requireBoardUserId(req: Request, res: Response): string | null { + assertBoard(req); + if (!req.actor.userId) { + res.status(403).json({ error: "Board user context required" }); + return null; + } + return req.actor.userId; +} + +export function sidebarPreferenceRoutes(db: Db) { + const router = Router(); + const svc = sidebarPreferenceService(db); + + router.get("/sidebar-preferences/me", async (req, res) => { + const userId = requireBoardUserId(req, res); + if (!userId) return; + res.json(await svc.getCompanyOrder(userId)); + }); + + router.put("/sidebar-preferences/me", validate(upsertSidebarOrderPreferenceSchema), async (req, res) => { + const userId = requireBoardUserId(req, res); + if (!userId) return; + res.json(await svc.upsertCompanyOrder(userId, req.body.orderedIds)); + }); + + router.get("/companies/:companyId/sidebar-preferences/me", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const userId = requireBoardUserId(req, res); + if (!userId) return; + res.json(await svc.getProjectOrder(companyId, userId)); + }); + + router.put( + "/companies/:companyId/sidebar-preferences/me", + validate(upsertSidebarOrderPreferenceSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const userId = requireBoardUserId(req, res); + if (!userId) return; + + const result = await svc.upsertProjectOrder(companyId, userId, req.body.orderedIds); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "sidebar_preferences.project_order_updated", + entityType: "company", + entityId: companyId, + details: { + userId, + orderedIds: result.orderedIds, + }, + }); + res.json(result); + }, + ); + + return router; +} diff --git a/server/src/routes/user-profiles.ts b/server/src/routes/user-profiles.ts new file mode 100644 index 00000000000..7b50e94a36a --- /dev/null +++ b/server/src/routes/user-profiles.ts @@ -0,0 +1,436 @@ +import { Router } from "express"; +import { and, desc, eq, gte, isNull, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + activityLog, + agents, + authUsers, + companyMemberships, + costEvents, + issueComments, + issues, +} from "@paperclipai/db"; +import type { + UserProfileDailyPoint, + UserProfileIdentity, + UserProfileResponse, + UserProfileWindowStats, +} from "@paperclipai/shared"; +import { notFound } from "../errors.js"; +import { assertCompanyAccess } from "./authz.js"; + +type CompanyUserRow = { + id: string; + principalId: string; + status: string; + membershipRole: string | null; + createdAt: Date; + userId: string | null; + name: string | null; + email: string | null; + image: string | null; +}; + +const PROFILE_WINDOWS = [ + { key: "last7", label: "Last 7 days", days: 7 }, + { key: "last30", label: "Last 30 days", days: 30 }, + { key: "all", label: "All time", days: null }, +] as const; + +function slugifyUserPart(value: string | null | undefined) { + const normalized = value + ?.trim() + .toLowerCase() + .replace(/['"]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || null; +} + +function userSlugCandidates(row: CompanyUserRow) { + const candidates = new Set(); + const add = (value: string | null | undefined) => { + const slug = slugifyUserPart(value); + if (slug) candidates.add(slug); + }; + add(row.name); + add(row.email?.split("@")[0]); + add(row.email); + add(row.principalId); + return [...candidates]; +} + +async function resolveCompanyUser(db: Db, companyId: string, rawSlug: string): Promise { + const slug = slugifyUserPart(rawSlug); + if (!slug) return null; + + const rows = await db + .select({ + id: companyMemberships.id, + principalId: companyMemberships.principalId, + status: companyMemberships.status, + membershipRole: companyMemberships.membershipRole, + createdAt: companyMemberships.createdAt, + userId: authUsers.id, + name: authUsers.name, + email: authUsers.email, + image: authUsers.image, + }) + .from(companyMemberships) + .leftJoin(authUsers, eq(authUsers.id, companyMemberships.principalId)) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + ), + ) + .orderBy(desc(companyMemberships.updatedAt)) + .limit(200); + + return rows.find((row) => userSlugCandidates(row).includes(slug)) ?? null; +} + +function userIssueInvolvementSql(companyId: string, userId: string) { + return sql` + ( + ${issues.createdByUserId} = ${userId} + OR ${issues.assigneeUserId} = ${userId} + OR EXISTS ( + SELECT 1 + FROM ${issueComments} + WHERE ${issueComments.companyId} = ${companyId} + AND ${issueComments.issueId} = ${issues.id} + AND ${issueComments.authorUserId} = ${userId} + ) + ) + `; +} + +function windowStart(days: number | null) { + if (!days) return null; + return new Date(Date.now() - days * 24 * 60 * 60 * 1000); +} + +function startOfUtcDay(date: Date) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +function isoDay(date: Date) { + return startOfUtcDay(date).toISOString().slice(0, 10); +} + +function dayKeyExpr(dateSql: ReturnType) { + return sql`to_char(date_trunc('day', ${dateSql}), 'YYYY-MM-DD')`; +} + +function sumNumber(column: typeof costEvents.costCents | typeof costEvents.inputTokens | typeof costEvents.cachedInputTokens | typeof costEvents.outputTokens) { + return sql`coalesce(sum(${column}), 0)::double precision`; +} + +async function loadWindowStats( + db: Db, + companyId: string, + userId: string, + key: UserProfileWindowStats["key"], + label: string, + from: Date | null, +): Promise { + const involvement = userIssueInvolvementSql(companyId, userId); + const openStatuses = ["backlog", "todo", "in_progress", "in_review", "blocked"]; + const fromIso = from?.toISOString(); + + const [issueStats] = await db + .select({ + touchedIssues: sql`count(distinct case when ${involvement} ${fromIso ? sql`and ${issues.updatedAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`, + createdIssues: sql`count(distinct case when ${issues.createdByUserId} = ${userId} ${fromIso ? sql`and ${issues.createdAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`, + completedIssues: sql`count(distinct case when ${involvement} and ${issues.status} = 'done' ${fromIso ? sql`and ${issues.completedAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`, + assignedOpenIssues: sql`count(distinct case when ${issues.assigneeUserId} = ${userId} and ${issues.status} in (${sql.join(openStatuses.map((status) => sql`${status}`), sql`, `)}) then ${issues.id} end)::int`, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), isNull(issues.hiddenAt))); + + const commentConditions = [ + eq(issueComments.companyId, companyId), + eq(issueComments.authorUserId, userId), + ]; + if (from) commentConditions.push(gte(issueComments.createdAt, from)); + const [commentStats] = await db + .select({ count: sql`count(*)::int` }) + .from(issueComments) + .where(and(...commentConditions)); + + const activityConditions = [ + eq(activityLog.companyId, companyId), + eq(activityLog.actorType, "user"), + eq(activityLog.actorId, userId), + ]; + if (from) activityConditions.push(gte(activityLog.createdAt, from)); + const [activityStats] = await db + .select({ count: sql`count(*)::int` }) + .from(activityLog) + .where(and(...activityConditions)); + + const costConditions = [ + eq(costEvents.companyId, companyId), + userIssueInvolvementSql(companyId, userId), + ]; + if (from) costConditions.push(gte(costEvents.occurredAt, from)); + const [costStats] = await db + .select({ + costCents: sumNumber(costEvents.costCents), + inputTokens: sumNumber(costEvents.inputTokens), + cachedInputTokens: sumNumber(costEvents.cachedInputTokens), + outputTokens: sumNumber(costEvents.outputTokens), + costEventCount: sql`count(${costEvents.id})::int`, + }) + .from(costEvents) + .innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId))) + .where(and(...costConditions)); + + return { + key, + label, + touchedIssues: Number(issueStats?.touchedIssues ?? 0), + createdIssues: Number(issueStats?.createdIssues ?? 0), + completedIssues: Number(issueStats?.completedIssues ?? 0), + assignedOpenIssues: Number(issueStats?.assignedOpenIssues ?? 0), + commentCount: Number(commentStats?.count ?? 0), + activityCount: Number(activityStats?.count ?? 0), + costCents: Number(costStats?.costCents ?? 0), + inputTokens: Number(costStats?.inputTokens ?? 0), + cachedInputTokens: Number(costStats?.cachedInputTokens ?? 0), + outputTokens: Number(costStats?.outputTokens ?? 0), + costEventCount: Number(costStats?.costEventCount ?? 0), + }; +} + +async function loadDailyStats(db: Db, companyId: string, userId: string): Promise { + const firstDay = startOfUtcDay(new Date(Date.now() - 13 * 24 * 60 * 60 * 1000)); + const points = new Map(); + for (let index = 0; index < 14; index += 1) { + const date = new Date(firstDay.getTime() + index * 24 * 60 * 60 * 1000); + points.set(isoDay(date), { + date: isoDay(date), + activityCount: 0, + completedIssues: 0, + costCents: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + }); + } + + const activityDay = dayKeyExpr(sql`${activityLog.createdAt}`); + const activityRows = await db + .select({ + date: activityDay, + count: sql`count(*)::int`, + }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, companyId), + eq(activityLog.actorType, "user"), + eq(activityLog.actorId, userId), + gte(activityLog.createdAt, firstDay), + ), + ) + .groupBy(activityDay); + + for (const row of activityRows) { + const point = points.get(row.date); + if (point) point.activityCount = Number(row.count); + } + + const completedDay = dayKeyExpr(sql`${issues.completedAt}`); + const completedRows = await db + .select({ + date: completedDay, + count: sql`count(distinct ${issues.id})::int`, + }) + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + isNull(issues.hiddenAt), + eq(issues.status, "done"), + gte(issues.completedAt, firstDay), + userIssueInvolvementSql(companyId, userId), + ), + ) + .groupBy(completedDay); + + for (const row of completedRows) { + const point = points.get(row.date); + if (point) point.completedIssues = Number(row.count); + } + + const costDay = dayKeyExpr(sql`${costEvents.occurredAt}`); + const costRows = await db + .select({ + date: costDay, + costCents: sumNumber(costEvents.costCents), + inputTokens: sumNumber(costEvents.inputTokens), + cachedInputTokens: sumNumber(costEvents.cachedInputTokens), + outputTokens: sumNumber(costEvents.outputTokens), + }) + .from(costEvents) + .innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId))) + .where( + and( + eq(costEvents.companyId, companyId), + gte(costEvents.occurredAt, firstDay), + userIssueInvolvementSql(companyId, userId), + ), + ) + .groupBy(costDay); + + for (const row of costRows) { + const point = points.get(row.date); + if (!point) continue; + point.costCents = Number(row.costCents); + point.inputTokens = Number(row.inputTokens); + point.cachedInputTokens = Number(row.cachedInputTokens); + point.outputTokens = Number(row.outputTokens); + } + + return [...points.values()]; +} + +export function userProfileRoutes(db: Db) { + const router = Router(); + + router.get("/companies/:companyId/users/:userSlug/profile", async (req, res) => { + const companyId = req.params.companyId as string; + const userSlug = req.params.userSlug as string; + assertCompanyAccess(req, companyId); + + const row = await resolveCompanyUser(db, companyId, userSlug); + if (!row) throw notFound("User not found"); + const canonicalSlug = userSlugCandidates(row)[0] ?? row.principalId; + const userId = row.userId ?? row.principalId; + + const [stats, daily, recentIssues, recentActivity, topAgents, topProviders] = await Promise.all([ + Promise.all( + PROFILE_WINDOWS.map((entry) => + loadWindowStats(db, companyId, userId, entry.key, entry.label, windowStart(entry.days)), + ), + ), + loadDailyStats(db, companyId, userId), + db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + updatedAt: issues.updatedAt, + completedAt: issues.completedAt, + }) + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + isNull(issues.hiddenAt), + userIssueInvolvementSql(companyId, userId), + ), + ) + .orderBy(desc(issues.updatedAt)) + .limit(8), + db + .select({ + id: activityLog.id, + action: activityLog.action, + entityType: activityLog.entityType, + entityId: activityLog.entityId, + details: activityLog.details, + createdAt: activityLog.createdAt, + }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, companyId), + eq(activityLog.actorType, "user"), + eq(activityLog.actorId, userId), + ), + ) + .orderBy(desc(activityLog.createdAt)) + .limit(12), + db + .select({ + agentId: costEvents.agentId, + agentName: agents.name, + costCents: sumNumber(costEvents.costCents), + inputTokens: sumNumber(costEvents.inputTokens), + cachedInputTokens: sumNumber(costEvents.cachedInputTokens), + outputTokens: sumNumber(costEvents.outputTokens), + }) + .from(costEvents) + .innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId))) + .leftJoin(agents, eq(agents.id, costEvents.agentId)) + .where(and(eq(costEvents.companyId, companyId), userIssueInvolvementSql(companyId, userId))) + .groupBy(costEvents.agentId, agents.name) + .orderBy(desc(sumNumber(costEvents.costCents))) + .limit(5), + db + .select({ + provider: costEvents.provider, + biller: costEvents.biller, + model: costEvents.model, + costCents: sumNumber(costEvents.costCents), + inputTokens: sumNumber(costEvents.inputTokens), + cachedInputTokens: sumNumber(costEvents.cachedInputTokens), + outputTokens: sumNumber(costEvents.outputTokens), + }) + .from(costEvents) + .innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId))) + .where(and(eq(costEvents.companyId, companyId), userIssueInvolvementSql(companyId, userId))) + .groupBy(costEvents.provider, costEvents.biller, costEvents.model) + .orderBy(desc(sumNumber(costEvents.costCents))) + .limit(5), + ]); + + const user: UserProfileIdentity = { + id: userId, + slug: canonicalSlug, + name: row.name, + email: row.email, + image: row.image, + membershipRole: row.membershipRole, + membershipStatus: row.status, + joinedAt: row.createdAt, + }; + + const payload: UserProfileResponse = { + user, + stats, + daily, + recentIssues: recentIssues.map((issue) => ({ + ...issue, + status: issue.status as UserProfileResponse["recentIssues"][number]["status"], + priority: issue.priority as UserProfileResponse["recentIssues"][number]["priority"], + })), + recentActivity, + topAgents: topAgents.map((entry) => ({ + ...entry, + costCents: Number(entry.costCents), + inputTokens: Number(entry.inputTokens), + cachedInputTokens: Number(entry.cachedInputTokens), + outputTokens: Number(entry.outputTokens), + })), + topProviders: topProviders.map((entry) => ({ + ...entry, + costCents: Number(entry.costCents), + inputTokens: Number(entry.inputTokens), + cachedInputTokens: Number(entry.cachedInputTokens), + outputTokens: Number(entry.outputTokens), + })), + }; + + res.json(payload); + }); + + return router; +} diff --git a/server/src/routes/workspace-command-authz.ts b/server/src/routes/workspace-command-authz.ts new file mode 100644 index 00000000000..e7999d11d5a --- /dev/null +++ b/server/src/routes/workspace-command-authz.ts @@ -0,0 +1,115 @@ +import type { Request } from "express"; +import { forbidden } from "../errors.js"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOwn(value: Record, key: string) { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function prefixPath(prefix: string, key: string) { + return prefix.length > 0 ? `${prefix}.${key}` : key; +} + +function collectWorkspaceStrategyCommandPaths(raw: unknown, prefix: string): string[] { + if (!isRecord(raw)) return []; + const paths: string[] = []; + if (hasOwn(raw, "provisionCommand")) { + paths.push(prefixPath(prefix, "provisionCommand")); + } + if (hasOwn(raw, "teardownCommand")) { + paths.push(prefixPath(prefix, "teardownCommand")); + } + return paths; +} + +function collectExecutionWorkspaceConfigCommandPaths(raw: unknown, prefix: string): string[] { + if (!isRecord(raw)) return []; + const paths: string[] = []; + if (hasOwn(raw, "provisionCommand")) { + paths.push(prefixPath(prefix, "provisionCommand")); + } + if (hasOwn(raw, "teardownCommand")) { + paths.push(prefixPath(prefix, "teardownCommand")); + } + if (hasOwn(raw, "cleanupCommand")) { + paths.push(prefixPath(prefix, "cleanupCommand")); + } + return paths; +} + +export function assertNoAgentHostWorkspaceCommandMutation(req: Request, paths: string[]) { + if (req.actor.type !== "agent" || paths.length === 0) return; + throw forbidden( + `Agent keys cannot modify host-executed workspace commands (${paths.join(", ")}).`, + ); +} + +export function collectAgentAdapterWorkspaceCommandPaths(adapterConfig: unknown): string[] { + if (!isRecord(adapterConfig)) return []; + return collectWorkspaceStrategyCommandPaths( + adapterConfig.workspaceStrategy, + "adapterConfig.workspaceStrategy", + ); +} + +export function collectProjectExecutionWorkspaceCommandPaths(policy: unknown): string[] { + if (!isRecord(policy)) return []; + return collectWorkspaceStrategyCommandPaths( + policy.workspaceStrategy, + "executionWorkspacePolicy.workspaceStrategy", + ); +} + +export function collectProjectWorkspaceCommandPaths( + workspacePatch: unknown, + prefix = "", +): string[] { + if (!isRecord(workspacePatch)) return []; + return hasOwn(workspacePatch, "cleanupCommand") + ? [prefixPath(prefix, "cleanupCommand")] + : []; +} + +export function collectIssueWorkspaceCommandPaths(input: { + executionWorkspaceSettings?: unknown; + assigneeAdapterOverrides?: unknown; +}): string[] { + const paths: string[] = []; + if (isRecord(input.executionWorkspaceSettings)) { + paths.push( + ...collectWorkspaceStrategyCommandPaths( + input.executionWorkspaceSettings.workspaceStrategy, + "executionWorkspaceSettings.workspaceStrategy", + ), + ); + } + if (isRecord(input.assigneeAdapterOverrides)) { + const adapterConfig = input.assigneeAdapterOverrides.adapterConfig; + if (isRecord(adapterConfig)) { + paths.push( + ...collectWorkspaceStrategyCommandPaths( + adapterConfig.workspaceStrategy, + "assigneeAdapterOverrides.adapterConfig.workspaceStrategy", + ), + ); + } + } + return paths; +} + +export function collectExecutionWorkspaceCommandPaths(input: { + config?: unknown; + metadata?: unknown; +}): string[] { + const paths: string[] = []; + if (input.config !== undefined) { + paths.push(...collectExecutionWorkspaceConfigCommandPaths(input.config, "config")); + } + if (isRecord(input.metadata) && hasOwn(input.metadata, "config")) { + paths.push(...collectExecutionWorkspaceConfigCommandPaths(input.metadata.config, "metadata.config")); + } + return paths; +} diff --git a/server/src/routes/workspace-runtime-service-authz.ts b/server/src/routes/workspace-runtime-service-authz.ts new file mode 100644 index 00000000000..16cff277be5 --- /dev/null +++ b/server/src/routes/workspace-runtime-service-authz.ts @@ -0,0 +1,138 @@ +import { and, eq, inArray, isNull, ne, or } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents, issues } from "@paperclipai/db"; +import type { Request } from "express"; +import { forbidden } from "../errors.js"; +import { assertCompanyAccess } from "./authz.js"; + +const WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES: string[] = [ + "backlog", + "todo", + "in_progress", + "in_review", + "blocked", +]; + +async function listReportingSubtreeAgentIds(db: Db, companyId: string, actorAgentId: string) { + const companyAgents = await db + .select({ + id: agents.id, + reportsTo: agents.reportsTo, + }) + .from(agents) + .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); + + const reportsByManager = new Map(); + for (const agent of companyAgents) { + if (!agent.reportsTo) continue; + const reports = reportsByManager.get(agent.reportsTo) ?? []; + reports.push(agent.id); + reportsByManager.set(agent.reportsTo, reports); + } + + const visited = new Set([actorAgentId]); + const queue = [actorAgentId]; + while (queue.length > 0) { + const current = queue.shift(); + if (!current) continue; + const reports = reportsByManager.get(current) ?? []; + for (const reportId of reports) { + if (visited.has(reportId)) continue; + visited.add(reportId); + queue.push(reportId); + } + } + + return [...visited]; +} + +async function assertAgentCanManageRuntimeServicesForWorkspace( + db: Db, + req: Request, + input: { + companyId: string; + projectWorkspaceId?: string | null; + executionWorkspaceId?: string | null; + sourceIssueId?: string | null; + }, +) { + if (req.actor.type !== "agent" || !req.actor.agentId) { + throw forbidden("Agent authentication required"); + } + + const actorAgent = await db + .select({ + id: agents.id, + companyId: agents.companyId, + role: agents.role, + }) + .from(agents) + .where(eq(agents.id, req.actor.agentId)) + .then((rows) => rows[0] ?? null); + + if (!actorAgent || actorAgent.companyId !== input.companyId) { + throw forbidden("Agent key cannot access another company"); + } + + if (actorAgent.role === "ceo") { + return; + } + + const eligibleAgentIds = await listReportingSubtreeAgentIds(db, input.companyId, actorAgent.id); + const workspaceScopeConditions = [ + input.projectWorkspaceId ? eq(issues.projectWorkspaceId, input.projectWorkspaceId) : null, + input.executionWorkspaceId ? eq(issues.executionWorkspaceId, input.executionWorkspaceId) : null, + input.sourceIssueId ? eq(issues.id, input.sourceIssueId) : null, + ].filter((condition): condition is NonNullable => condition !== null); + + if (workspaceScopeConditions.length === 0) { + throw forbidden("Missing permission to manage workspace runtime services"); + } + + const linkedIssue = await db + .select({ id: issues.id }) + .from(issues) + .where(and( + eq(issues.companyId, input.companyId), + isNull(issues.hiddenAt), + inArray(issues.status, WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES), + inArray(issues.assigneeAgentId, eligibleAgentIds), + workspaceScopeConditions.length === 1 + ? workspaceScopeConditions[0]! + : or(...workspaceScopeConditions), + )) + .then((rows) => rows[0] ?? null); + + if (linkedIssue) { + return; + } + + throw forbidden("Missing permission to manage workspace runtime services"); +} + +export async function assertCanManageProjectWorkspaceRuntimeServices( + db: Db, + req: Request, + input: { + companyId: string; + projectWorkspaceId: string; + }, +) { + assertCompanyAccess(req, input.companyId); + if (req.actor.type === "board") return; + await assertAgentCanManageRuntimeServicesForWorkspace(db, req, input); +} + +export async function assertCanManageExecutionWorkspaceRuntimeServices( + db: Db, + req: Request, + input: { + companyId: string; + executionWorkspaceId: string; + sourceIssueId?: string | null; + }, +) { + assertCompanyAccess(req, input.companyId); + if (req.actor.type === "board") return; + await assertAgentCanManageRuntimeServicesForWorkspace(db, req, input); +} diff --git a/server/src/services/access.ts b/server/src/services/access.ts index 3e30e1ab3b9..faac70da407 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -1,11 +1,14 @@ -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { + agents, companyMemberships, instanceUserRoles, + issues, principalPermissionGrants, } from "@paperclipai/db"; import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; +import { conflict } from "../errors.js"; type MembershipRow = typeof companyMemberships.$inferSelect; type GrantInput = { @@ -13,6 +16,13 @@ type GrantInput = { scope?: Record | null; }; +type MemberArchiveInput = { + reassignment?: { + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + } | null; +}; + export function accessService(db: Db) { async function isInstanceAdmin(userId: string | null | undefined): Promise { if (!userId) return false; @@ -83,6 +93,14 @@ export function accessService(db: Db) { .orderBy(sql`${companyMemberships.createdAt} desc`); } + async function getMemberById(companyId: string, memberId: string) { + return db + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) + .then((rows) => rows[0] ?? null); + } + async function listActiveUserMemberships(companyId: string) { return db .select() @@ -103,11 +121,7 @@ export function accessService(db: Db) { grants: GrantInput[], grantedByUserId: string | null, ) { - const member = await db - .select() - .from(companyMemberships) - .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) - .then((rows) => rows[0] ?? null); + const member = await getMemberById(companyId, memberId); if (!member) return null; await db.transaction(async (tx) => { @@ -139,6 +153,271 @@ export function accessService(db: Db) { return member; } + async function updateMemberAndPermissions( + companyId: string, + memberId: string, + data: { + membershipRole?: string | null; + status?: "pending" | "active" | "suspended"; + grants: GrantInput[]; + }, + grantedByUserId: string | null, + ) { + return db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + + const nextMembershipRole = + data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole; + const nextStatus = data.status ?? existing.status; + + if ( + existing.principalType === "user" && + existing.status === "active" && + existing.membershipRole === "owner" && + (nextStatus !== "active" || nextMembershipRole !== "owner") + ) { + const activeOwnerCount = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + const now = new Date(); + const updated = await tx + .update(companyMemberships) + .set({ + membershipRole: nextMembershipRole, + status: nextStatus, + updatedAt: now, + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, existing.principalType), + eq(principalPermissionGrants.principalId, existing.principalId), + ), + ); + if (data.grants.length > 0) { + await tx.insert(principalPermissionGrants).values( + data.grants.map((grant) => ({ + companyId, + principalType: existing.principalType, + principalId: existing.principalId, + permissionKey: grant.permissionKey, + scope: grant.scope ?? null, + grantedByUserId, + createdAt: now, + updatedAt: now, + })), + ); + } + + return updated; + }); + } + + async function assertCanRemoveActiveOwner( + companyId: string, + principalType: PrincipalType, + status: string, + membershipRole: string | null, + tx: Pick, + ) { + if ( + principalType !== "user" || + status !== "active" || + membershipRole !== "owner" + ) { + return; + } + + const activeOwnerCount = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + async function assertAssignableArchiveTarget( + companyId: string, + input: MemberArchiveInput["reassignment"], + tx: Pick, + ) { + if (!input?.assigneeAgentId && !input?.assigneeUserId) return; + if (input.assigneeAgentId && input.assigneeUserId) { + throw conflict("Choose either an agent or user reassignment target"); + } + if (input.assigneeUserId) { + const membership = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, input.assigneeUserId), + eq(companyMemberships.status, "active"), + ), + ) + .then((rows) => rows[0] ?? null); + if (!membership) { + throw conflict("Replacement user must be an active company member"); + } + return; + } + + const agent = await tx + .select({ + id: agents.id, + companyId: agents.companyId, + status: agents.status, + }) + .from(agents) + .where(eq(agents.id, input.assigneeAgentId!)) + .then((rows) => rows[0] ?? null); + if (!agent || agent.companyId !== companyId) { + throw conflict("Replacement agent must belong to the same company"); + } + if (agent.status === "pending_approval" || agent.status === "terminated") { + throw conflict("Replacement agent must be assignable"); + } + } + + async function archiveMember(companyId: string, memberId: string, input: MemberArchiveInput = {}) { + return db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + if (existing.principalType !== "user") { + throw conflict("Only human company members can be archived"); + } + if (existing.status === "archived") { + return { member: existing, reassignedIssueCount: 0 }; + } + if (input.reassignment?.assigneeUserId === existing.principalId) { + throw conflict("Replacement user cannot be the archived member"); + } + + await assertCanRemoveActiveOwner( + companyId, + existing.principalType, + existing.status, + existing.membershipRole, + tx, + ); + await assertAssignableArchiveTarget(companyId, input.reassignment, tx); + + const now = new Date(); + const assignmentPatch = { + assigneeAgentId: input.reassignment?.assigneeAgentId ?? null, + assigneeUserId: input.reassignment?.assigneeUserId ?? null, + updatedAt: now, + }; + const assignedOpenIssueWhere = and( + eq(issues.companyId, companyId), + eq(issues.assigneeUserId, existing.principalId), + sql`${issues.status} not in ('done', 'cancelled')`, + ); + const resetInProgress = await tx + .update(issues) + .set({ + ...assignmentPatch, + status: "todo", + startedAt: null, + checkoutRunId: null, + executionRunId: null, + executionLockedAt: null, + }) + .where(and(assignedOpenIssueWhere, eq(issues.status, "in_progress"))) + .returning({ id: issues.id }); + const reassigned = await tx + .update(issues) + .set(assignmentPatch) + .where(and(assignedOpenIssueWhere, ne(issues.status, "in_progress"))) + .returning({ id: issues.id }); + + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, existing.principalType), + eq(principalPermissionGrants.principalId, existing.principalId), + ), + ); + + const archived = await tx + .update(companyMemberships) + .set({ + status: "archived", + updatedAt: now, + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + + return { + member: archived, + reassignedIssueCount: resetInProgress.length + reassigned.length, + }; + }); + } + async function promoteInstanceAdmin(userId: string) { const existing = await db .select() @@ -172,25 +451,87 @@ export function accessService(db: Db) { .orderBy(sql`${companyMemberships.createdAt} desc`); } - async function setUserCompanyAccess(userId: string, companyIds: string[]) { + async function setUserCompanyAccess( + userId: string, + companyIds: string[], + options: { actorUserId?: string | null } = {}, + ) { const existing = await listUserCompanyAccess(userId); const existingByCompany = new Map(existing.map((row) => [row.companyId, row])); const target = new Set(companyIds); await db.transaction(async (tx) => { - const toDelete = existing.filter((row) => !target.has(row.companyId)).map((row) => row.id); - if (toDelete.length > 0) { - await tx.delete(companyMemberships).where(inArray(companyMemberships.id, toDelete)); + const toArchive = existing.filter((row) => !target.has(row.companyId) && row.status !== "archived"); + if (toArchive.length > 0 && options.actorUserId && options.actorUserId === userId) { + throw conflict("You cannot remove yourself"); + } + if (toArchive.length > 0 && (await isInstanceAdmin(userId))) { + throw conflict("Instance admins cannot be removed from company access"); + } + const protectedArchives = toArchive.filter((row) => row.membershipRole === "owner" || row.membershipRole === "admin"); + if (protectedArchives.length > 0) { + throw conflict("Owners and admins cannot be removed from company access"); + } + const activeOwnerArchives = toArchive.filter( + (row) => row.status === "active" && row.membershipRole === "owner", + ); + if (activeOwnerArchives.length > 0) { + const activeOwnerRows = await tx + .select({ companyId: companyMemberships.companyId, id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + inArray(companyMemberships.companyId, activeOwnerArchives.map((row) => row.companyId)), + ), + ); + for (const row of activeOwnerArchives) { + const remainingOwners = + activeOwnerRows.filter((owner) => owner.companyId === row.companyId).length - 1; + if (remainingOwners <= 0) { + throw conflict("Cannot remove the last active owner"); + } + } + } + if (toArchive.length > 0) { + await tx + .update(companyMemberships) + .set({ status: "archived", updatedAt: new Date() }) + .where(inArray(companyMemberships.id, toArchive.map((row) => row.id))); + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, userId), + inArray(principalPermissionGrants.companyId, toArchive.map((row) => row.companyId)), + ), + ); } for (const companyId of target) { - if (existingByCompany.has(companyId)) continue; + const existingMembership = existingByCompany.get(companyId); + if (existingMembership) { + if (existingMembership.status !== "active") { + await tx + .update(companyMemberships) + .set({ + status: "active", + membershipRole: existingMembership.membershipRole ?? "operator", + updatedAt: new Date(), + }) + .where(eq(companyMemberships.id, existingMembership.id)); + } + continue; + } await tx.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", - membershipRole: "member", + membershipRole: "operator", }); } }); @@ -359,16 +700,85 @@ export function accessService(db: Db) { }); } + async function updateMember( + companyId: string, + memberId: string, + data: { + membershipRole?: string | null; + status?: "pending" | "active" | "suspended"; + }, + ) { + return db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + + const nextMembershipRole = + data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole; + const nextStatus = data.status ?? existing.status; + + if ( + existing.principalType === "user" && + existing.status === "active" && + existing.membershipRole === "owner" && + (nextStatus !== "active" || nextMembershipRole !== "owner") + ) { + const activeOwnerCount = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + return tx + .update(companyMemberships) + .set({ + membershipRole: nextMembershipRole, + status: nextStatus, + updatedAt: new Date(), + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + }); + } + return { isInstanceAdmin, canUser, hasPermission, getMembership, + getMemberById, ensureMembership, listMembers, listActiveUserMemberships, copyActiveUserMemberships, + archiveMember, setMemberPermissions, + updateMemberAndPermissions, promoteInstanceAdmin, demoteInstanceAdmin, listUserCompanyAccess, @@ -376,5 +786,6 @@ export function accessService(db: Db) { setPrincipalGrants, listPrincipalGrants, setPrincipalPermission, + updateMember, }; } diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index 62e995307d3..2198f19be77 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -1,6 +1,20 @@ import { and, desc, eq, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { activityLog, agents, heartbeatRuns, issues } from "@paperclipai/db"; +import { + activityLog, + agents, + documentRevisions, + heartbeatRunEvents, + heartbeatRuns, + issueComments, + issueDocuments, + issues, + issueWorkProducts, + workspaceOperations, +} from "@paperclipai/db"; +import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; +import { logger } from "../middleware/logger.js"; +import { classifyRunLiveness } from "./run-liveness.js"; export interface ActivityFilters { companyId: string; @@ -10,7 +24,295 @@ export interface ActivityFilters { } export function activityService(db: Db) { + const scheduledLivenessBackfills = new Set(); const issueIdAsText = sql`${issues.id}::text`; + const summarizedUsageJson = sql | null>` + case + when ${heartbeatRuns.usageJson} is null then null + else jsonb_strip_nulls(jsonb_build_object( + 'inputTokens', coalesce(${heartbeatRuns.usageJson} -> 'inputTokens', ${heartbeatRuns.usageJson} -> 'input_tokens'), + 'input_tokens', coalesce(${heartbeatRuns.usageJson} -> 'input_tokens', ${heartbeatRuns.usageJson} -> 'inputTokens'), + 'outputTokens', coalesce(${heartbeatRuns.usageJson} -> 'outputTokens', ${heartbeatRuns.usageJson} -> 'output_tokens'), + 'output_tokens', coalesce(${heartbeatRuns.usageJson} -> 'output_tokens', ${heartbeatRuns.usageJson} -> 'outputTokens'), + 'cachedInputTokens', coalesce( + ${heartbeatRuns.usageJson} -> 'cachedInputTokens', + ${heartbeatRuns.usageJson} -> 'cached_input_tokens', + ${heartbeatRuns.usageJson} -> 'cache_read_input_tokens' + ), + 'cached_input_tokens', coalesce( + ${heartbeatRuns.usageJson} -> 'cached_input_tokens', + ${heartbeatRuns.usageJson} -> 'cachedInputTokens', + ${heartbeatRuns.usageJson} -> 'cache_read_input_tokens' + ), + 'cache_read_input_tokens', coalesce( + ${heartbeatRuns.usageJson} -> 'cache_read_input_tokens', + ${heartbeatRuns.usageJson} -> 'cached_input_tokens', + ${heartbeatRuns.usageJson} -> 'cachedInputTokens' + ), + 'billingType', coalesce(${heartbeatRuns.usageJson} -> 'billingType', ${heartbeatRuns.usageJson} -> 'billing_type'), + 'billing_type', coalesce(${heartbeatRuns.usageJson} -> 'billing_type', ${heartbeatRuns.usageJson} -> 'billingType'), + 'costUsd', coalesce( + ${heartbeatRuns.usageJson} -> 'costUsd', + ${heartbeatRuns.usageJson} -> 'cost_usd', + ${heartbeatRuns.usageJson} -> 'total_cost_usd' + ), + 'cost_usd', coalesce( + ${heartbeatRuns.usageJson} -> 'cost_usd', + ${heartbeatRuns.usageJson} -> 'costUsd', + ${heartbeatRuns.usageJson} -> 'total_cost_usd' + ), + 'total_cost_usd', coalesce( + ${heartbeatRuns.usageJson} -> 'total_cost_usd', + ${heartbeatRuns.usageJson} -> 'cost_usd', + ${heartbeatRuns.usageJson} -> 'costUsd' + ) + )) + end + `.as("usageJson"); + const summarizedResultJson = sql | null>` + case + when ${heartbeatRuns.resultJson} is null then null + else jsonb_strip_nulls(jsonb_build_object( + 'billingType', coalesce(${heartbeatRuns.resultJson} -> 'billingType', ${heartbeatRuns.resultJson} -> 'billing_type'), + 'billing_type', coalesce(${heartbeatRuns.resultJson} -> 'billing_type', ${heartbeatRuns.resultJson} -> 'billingType'), + 'costUsd', coalesce( + ${heartbeatRuns.resultJson} -> 'costUsd', + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'total_cost_usd' + ), + 'cost_usd', coalesce( + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'costUsd', + ${heartbeatRuns.resultJson} -> 'total_cost_usd' + ), + 'total_cost_usd', coalesce( + ${heartbeatRuns.resultJson} -> 'total_cost_usd', + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'costUsd' + ), + 'stopReason', ${heartbeatRuns.resultJson} -> 'stopReason', + 'effectiveTimeoutSec', ${heartbeatRuns.resultJson} -> 'effectiveTimeoutSec', + 'effectiveTimeoutMs', ${heartbeatRuns.resultJson} -> 'effectiveTimeoutMs', + 'timeoutConfigured', ${heartbeatRuns.resultJson} -> 'timeoutConfigured', + 'timeoutSource', ${heartbeatRuns.resultJson} -> 'timeoutSource', + 'timeoutFired', ${heartbeatRuns.resultJson} -> 'timeoutFired' + )) + end + `.as("resultJson"); + + function countValue(value: unknown) { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : 0; + } + + function dateValue(value: unknown) { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; + if (typeof value === "string" || typeof value === "number") { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + return null; + } + + function latestDate(...values: unknown[]) { + let latest: Date | null = null; + for (const value of values) { + const parsed = dateValue(value); + if (!parsed) continue; + if (!latest || parsed.getTime() > latest.getTime()) latest = parsed; + } + return latest; + } + + function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; + } + + function readNumber(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : null; + } + + async function backfillMissingRunLivenessForIssue(companyId: string, issueId: string) { + const runs = await db + .select({ + id: heartbeatRuns.id, + companyId: heartbeatRuns.companyId, + status: heartbeatRuns.status, + contextSnapshot: heartbeatRuns.contextSnapshot, + resultJson: heartbeatRuns.resultJson, + stdoutExcerpt: heartbeatRuns.stdoutExcerpt, + stderrExcerpt: heartbeatRuns.stderrExcerpt, + error: heartbeatRuns.error, + errorCode: heartbeatRuns.errorCode, + continuationAttempt: heartbeatRuns.continuationAttempt, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, companyId), + isNull(heartbeatRuns.livenessState), + sql`${heartbeatRuns.status} not in ('queued', 'running')`, + or( + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, + sql`exists ( + select 1 + from ${activityLog} + where ${activityLog.companyId} = ${companyId} + and ${activityLog.entityType} = 'issue' + and ${activityLog.entityId} = ${issueId} + and ${activityLog.runId} = ${heartbeatRuns.id} + )`, + ), + ), + ) + .limit(20); + + if (runs.length === 0) return; + + const issue = await db + .select({ + status: issues.status, + title: issues.title, + description: issues.description, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.id, issueId))) + .then((rows) => rows[0] ?? null); + + for (const run of runs) { + const context = asRecord(run.contextSnapshot); + const continuationAttempt = + readNumber(context?.continuationAttempt) ?? + readNumber(context?.livenessContinuationAttempt) ?? + run.continuationAttempt ?? + 0; + + const [commentStats] = await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${issueComments.createdAt})`, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, companyId), + eq(issueComments.issueId, issueId), + eq(issueComments.createdByRunId, run.id), + ), + ); + + const [documentStats] = await db + .select({ + count: sql`count(*)::int`, + planCount: sql`count(*) filter (where ${issueDocuments.key} = 'plan')::int`, + latestAt: sql`max(${documentRevisions.createdAt})`, + }) + .from(documentRevisions) + .innerJoin(issueDocuments, eq(documentRevisions.documentId, issueDocuments.documentId)) + .where( + and( + eq(documentRevisions.companyId, companyId), + eq(documentRevisions.createdByRunId, run.id), + eq(issueDocuments.companyId, companyId), + eq(issueDocuments.issueId, issueId), + sql`${issueDocuments.key} != ${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`, + ), + ); + + const [workProductStats] = await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${issueWorkProducts.createdAt})`, + }) + .from(issueWorkProducts) + .where( + and( + eq(issueWorkProducts.companyId, companyId), + eq(issueWorkProducts.issueId, issueId), + eq(issueWorkProducts.createdByRunId, run.id), + ), + ); + + const [workspaceOperationStats] = await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${workspaceOperations.startedAt})`, + }) + .from(workspaceOperations) + .where(and(eq(workspaceOperations.companyId, companyId), eq(workspaceOperations.heartbeatRunId, run.id))); + + const [activityStats] = await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${activityLog.createdAt})`, + }) + .from(activityLog) + .where(and(eq(activityLog.companyId, companyId), eq(activityLog.runId, run.id))); + + const [eventStats] = await db + .select({ + count: sql`count(*) filter (where ${heartbeatRunEvents.eventType} not in ('lifecycle', 'adapter.invoke', 'error'))::int`, + latestAt: sql`max(${heartbeatRunEvents.createdAt}) filter (where ${heartbeatRunEvents.eventType} not in ('lifecycle', 'adapter.invoke', 'error'))`, + }) + .from(heartbeatRunEvents) + .where(and(eq(heartbeatRunEvents.companyId, companyId), eq(heartbeatRunEvents.runId, run.id))); + + const classification = classifyRunLiveness({ + runStatus: run.status, + issue, + resultJson: asRecord(run.resultJson), + stdoutExcerpt: run.stdoutExcerpt, + stderrExcerpt: run.stderrExcerpt, + error: run.error, + errorCode: run.errorCode, + continuationAttempt, + evidence: { + issueCommentsCreated: countValue(commentStats?.count), + documentRevisionsCreated: countValue(documentStats?.count), + planDocumentRevisionsCreated: countValue(documentStats?.planCount), + workProductsCreated: countValue(workProductStats?.count), + workspaceOperationsCreated: countValue(workspaceOperationStats?.count), + activityEventsCreated: countValue(activityStats?.count), + toolOrActionEventsCreated: countValue(eventStats?.count), + latestEvidenceAt: latestDate( + commentStats?.latestAt, + documentStats?.latestAt, + workProductStats?.latestAt, + workspaceOperationStats?.latestAt, + activityStats?.latestAt, + eventStats?.latestAt, + ), + }, + }); + + await db + .update(heartbeatRuns) + .set({ + livenessState: classification.livenessState, + livenessReason: classification.livenessReason, + continuationAttempt: classification.continuationAttempt, + lastUsefulActionAt: classification.lastUsefulActionAt, + nextAction: classification.nextAction, + updatedAt: new Date(), + }) + .where(and(eq(heartbeatRuns.id, run.id), isNull(heartbeatRuns.livenessState))); + } + } + + function scheduleRunLivenessBackfill(companyId: string, issueId: string) { + const key = `${companyId}:${issueId}`; + if (scheduledLivenessBackfills.has(key)) return; + scheduledLivenessBackfills.add(key); + void backfillMissingRunLivenessForIssue(companyId, issueId) + .catch((err: unknown) => { + logger.warn({ err, companyId, issueId }, "run liveness backfill failed"); + }) + .finally(() => { + scheduledLivenessBackfills.delete(key); + }); + } + return { list: (filters: ActivityFilters) => { const conditions = [eq(activityLog.companyId, filters.companyId)]; @@ -60,8 +362,9 @@ export function activityService(db: Db) { ) .orderBy(desc(activityLog.createdAt)), - runsForIssue: (companyId: string, issueId: string) => - db + runsForIssue: async (companyId: string, issueId: string) => { + scheduleRunLivenessBackfill(companyId, issueId); + return db .select({ runId: heartbeatRuns.id, status: heartbeatRuns.status, @@ -71,9 +374,14 @@ export function activityService(db: Db) { finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, invocationSource: heartbeatRuns.invocationSource, - usageJson: heartbeatRuns.usageJson, - resultJson: heartbeatRuns.resultJson, + usageJson: summarizedUsageJson, + resultJson: summarizedResultJson, logBytes: heartbeatRuns.logBytes, + livenessState: heartbeatRuns.livenessState, + livenessReason: heartbeatRuns.livenessReason, + continuationAttempt: heartbeatRuns.continuationAttempt, + lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, + nextAction: heartbeatRuns.nextAction, }) .from(heartbeatRuns) .innerJoin( @@ -99,7 +407,8 @@ export function activityService(db: Db) { ), ), ) - .orderBy(desc(heartbeatRuns.createdAt)), + .orderBy(desc(heartbeatRuns.createdAt)); + }, issuesForRun: async (runId: string) => { const run = await db diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index abba741491c..512d0e89cca 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -16,7 +16,7 @@ import { issues, issueComments, } from "@paperclipai/db"; -import { isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared"; +import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { normalizeAgentPermissions } from "./agent-permissions.js"; import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js"; @@ -114,6 +114,25 @@ function hasConfigPatchFields(data: Partial) { return CONFIG_REVISION_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(data, field)); } +function parseFiniteNumberLike(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string") return null; + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; +} + +function normalizeRuntimeConfigForNewAgent(runtimeConfig: unknown): Record { + const normalizedRuntimeConfig = isPlainRecord(runtimeConfig) ? { ...runtimeConfig } : {}; + const heartbeat = isPlainRecord(normalizedRuntimeConfig.heartbeat) + ? { ...normalizedRuntimeConfig.heartbeat } + : {}; + if (parseFiniteNumberLike(heartbeat.maxConcurrentRuns) == null) { + heartbeat.maxConcurrentRuns = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; + } + normalizedRuntimeConfig.heartbeat = heartbeat; + return normalizedRuntimeConfig; +} + function diffConfigSnapshot( before: AgentConfigSnapshot, after: AgentConfigSnapshot, @@ -216,7 +235,7 @@ export function agentService(db: Db) { const rows = await db .select({ agentId: costEvents.agentId, - spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where( @@ -398,9 +417,10 @@ export function agentService(db: Db) { const role = data.role ?? "general"; const normalizedPermissions = normalizeAgentPermissions(data.permissions, role); + const runtimeConfig = normalizeRuntimeConfigForNewAgent(data.runtimeConfig); const created = await db .insert(agents) - .values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions }) + .values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions, runtimeConfig }) .returning() .then((rows) => rows[0]); @@ -506,18 +526,19 @@ export function agentService(db: Db) { }, activatePendingApproval: async (id: string) => { - const existing = await getById(id); - if (!existing) return null; - if (existing.status !== "pending_approval") return existing; - const updated = await db .update(agents) .set({ status: "idle", updatedAt: new Date() }) - .where(eq(agents.id, id)) + .where(and(eq(agents.id, id), eq(agents.status, "pending_approval"))) .returning() .then((rows) => rows[0] ?? null); - return updated ? normalizeAgentRow(updated) : null; + if (updated) { + return { agent: normalizeAgentRow(updated), activated: true }; + } + + const existing = await getById(id); + return existing ? { agent: existing, activated: false } : null; }, updatePermissions: async (id: string, permissions: { canCreateAgents: boolean }) => { @@ -619,11 +640,25 @@ export function agentService(db: Db) { .from(agentApiKeys) .where(eq(agentApiKeys.agentId, id)), - revokeKey: async (keyId: string) => { + getKeyById: async (keyId: string) => + db + .select({ + id: agentApiKeys.id, + agentId: agentApiKeys.agentId, + companyId: agentApiKeys.companyId, + name: agentApiKeys.name, + createdAt: agentApiKeys.createdAt, + revokedAt: agentApiKeys.revokedAt, + }) + .from(agentApiKeys) + .where(eq(agentApiKeys.id, keyId)) + .then((rows) => rows[0] ?? null), + + revokeKey: async (agentId: string, keyId: string) => { const rows = await db .update(agentApiKeys) .set({ revokedAt: new Date() }) - .where(eq(agentApiKeys.id, keyId)) + .where(and(eq(agentApiKeys.id, keyId), eq(agentApiKeys.agentId, agentId))) .returning(); return rows[0] ?? null; }, diff --git a/server/src/services/board-auth.ts b/server/src/services/board-auth.ts index 19e533c1163..0637457c54b 100644 --- a/server/src/services/board-auth.ts +++ b/server/src/services/board-auth.ts @@ -62,7 +62,11 @@ export function boardAuthService(db: Db) { .where(eq(authUsers.id, userId)) .then((rows) => rows[0] ?? null), db - .select({ companyId: companyMemberships.companyId }) + .select({ + companyId: companyMemberships.companyId, + membershipRole: companyMemberships.membershipRole, + status: companyMemberships.status, + }) .from(companyMemberships) .where( and( @@ -71,7 +75,7 @@ export function boardAuthService(db: Db) { eq(companyMemberships.status, "active"), ), ) - .then((rows) => rows.map((row) => row.companyId)), + .then((rows) => rows), db .select({ id: instanceUserRoles.id }) .from(instanceUserRoles) @@ -81,7 +85,8 @@ export function boardAuthService(db: Db) { return { user, - companyIds: memberships, + companyIds: memberships.map((row) => row.companyId), + memberships, isInstanceAdmin: Boolean(adminRole), }; } diff --git a/server/src/services/budgets.ts b/server/src/services/budgets.ts index 3e7d8b4e6cb..48575438386 100644 --- a/server/src/services/budgets.ts +++ b/server/src/services/budgets.ts @@ -156,7 +156,7 @@ async function computeObservedAmount( const [row] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where(and(...conditions)); diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index dcdeb27dd3c..89678572ca8 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -76,10 +76,10 @@ export function companyService(db: Db) { if (companyIds.length === 0) return new Map(); const { start, end } = currentUtcMonthWindow(); const rows = await database - .select({ - companyId: costEvents.companyId, - spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - }) + .select({ + companyId: costEvents.companyId, + spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, + }) .from(costEvents) .where( and( diff --git a/server/src/services/company-member-roles.ts b/server/src/services/company-member-roles.ts new file mode 100644 index 00000000000..6e8513ee451 --- /dev/null +++ b/server/src/services/company-member-roles.ts @@ -0,0 +1,59 @@ +import { PERMISSION_KEYS } from "@paperclipai/shared"; +import type { HumanCompanyMembershipRole } from "@paperclipai/shared"; + +const HUMAN_COMPANY_MEMBERSHIP_ROLES: HumanCompanyMembershipRole[] = [ + "owner", + "admin", + "operator", + "viewer", +]; + +export function normalizeHumanRole( + value: unknown, + fallback: HumanCompanyMembershipRole = "operator" +): HumanCompanyMembershipRole { + if (value === "member") return "operator"; + return HUMAN_COMPANY_MEMBERSHIP_ROLES.includes(value as HumanCompanyMembershipRole) + ? (value as HumanCompanyMembershipRole) + : fallback; +} + +export function grantsForHumanRole( + role: HumanCompanyMembershipRole +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + switch (role) { + case "owner": + return [ + { permissionKey: "agents:create", scope: null }, + { permissionKey: "users:invite", scope: null }, + { permissionKey: "users:manage_permissions", scope: null }, + { permissionKey: "tasks:assign", scope: null }, + { permissionKey: "joins:approve", scope: null }, + ]; + case "admin": + return [ + { permissionKey: "agents:create", scope: null }, + { permissionKey: "users:invite", scope: null }, + { permissionKey: "tasks:assign", scope: null }, + { permissionKey: "joins:approve", scope: null }, + ]; + case "operator": + return [{ permissionKey: "tasks:assign", scope: null }]; + case "viewer": + return []; + } +} + +export function resolveHumanInviteRole( + defaultsPayload: Record | null | undefined +): HumanCompanyMembershipRole { + if (!defaultsPayload || typeof defaultsPayload !== "object") return "operator"; + const scoped = defaultsPayload.human; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + return "operator"; + } + return normalizeHumanRole((scoped as Record).role, "operator"); +} diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 9dc3262fb4e..bbc7d4b691b 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -31,6 +31,7 @@ import type { RoutineVariable, } from "@paperclipai/shared"; import { + AGENT_DEFAULT_MAX_CONCURRENT_RUNS, ISSUE_PRIORITIES, ISSUE_STATUSES, PROJECT_STATUSES, @@ -47,7 +48,9 @@ import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils"; -import { notFound, unprocessable } from "../errors.js"; +import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server"; +import { findServerAdapter } from "../adapters/index.js"; +import { forbidden, notFound, unprocessable } from "../errors.js"; import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; import type { StorageService } from "../storage/types.js"; import { accessService } from "./access.js"; @@ -62,6 +65,7 @@ import { validateCron } from "./cron.js"; import { issueService } from "./issues.js"; import { projectService } from "./projects.js"; import { routineService } from "./routines.js"; +import { secretService } from "./secrets.js"; /** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */ function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] { @@ -117,6 +121,7 @@ const DEFAULT_INCLUDE: CompanyPortabilityInclude = { }; const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename"; +const IMPORT_FORBIDDEN_ADAPTER_TYPES = new Set(["process", "http"]); const execFileAsync = promisify(execFile); let bundledSkillsCommitPromise: Promise | null = null; @@ -586,7 +591,7 @@ const RUNTIME_DEFAULT_RULES: Array<{ path: string[]; value: unknown }> = [ { path: ["heartbeat", "wakeOnAssignment"], value: true }, { path: ["heartbeat", "wakeOnAutomation"], value: true }, { path: ["heartbeat", "wakeOnDemand"], value: true }, - { path: ["heartbeat", "maxConcurrentRuns"], value: 3 }, + { path: ["heartbeat", "maxConcurrentRuns"], value: AGENT_DEFAULT_MAX_CONCURRENT_RUNS }, ]; const ADAPTER_DEFAULT_RULES_BY_TYPE: Record> = { @@ -737,10 +742,20 @@ function clonePortableRecord(value: unknown) { return structuredClone(value) as Record; } +function parseFiniteNumberLike(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string") return null; + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; +} + function disableImportedTimerHeartbeat(runtimeConfig: unknown) { const next = clonePortableRecord(runtimeConfig) ?? {}; const heartbeat = isPlainRecord(next.heartbeat) ? { ...next.heartbeat } : {}; heartbeat.enabled = false; + if (parseFiniteNumberLike(heartbeat.maxConcurrentRuns) == null) { + heartbeat.maxConcurrentRuns = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; + } next.heartbeat = heartbeat; return next; } @@ -2747,6 +2762,94 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const projects = projectService(db); const issues = issueService(db); const companySkills = companySkillService(db); + const secrets = secretService(db); + const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; + + function assertKnownImportAdapterType(type: string | null | undefined): string { + const adapterType = typeof type === "string" ? type.trim() : ""; + if (!adapterType) { + throw unprocessable("Adapter type is required"); + } + if (!findServerAdapter(adapterType)) { + throw unprocessable(`Unknown adapter type: ${adapterType}`); + } + return adapterType; + } + + async function assertImportAdapterConfigConstraints( + companyId: string, + adapterType: string, + adapterConfig: Record, + ) { + if (adapterType !== "opencode_local") return; + const { config: runtimeConfig } = await secrets.resolveAdapterConfigForRuntime(companyId, adapterConfig); + const runtimeEnv = isPlainRecord(runtimeConfig.env) ? runtimeConfig.env : {}; + try { + await ensureOpenCodeModelConfiguredAndAvailable({ + model: runtimeConfig.model, + command: runtimeConfig.command, + cwd: runtimeConfig.cwd, + env: runtimeEnv, + }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`); + } + } + + async function prepareImportedAgentAdapter( + companyId: string, + adapterType: string | null | undefined, + adapterConfig: Record, + desiredSkills: string[], + mode: ImportMode, + ) { + const effectiveAdapterType = assertKnownImportAdapterType(adapterType); + if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) { + throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`); + } + const nextAdapterConfig = writePaperclipSkillSyncPreference({ ...adapterConfig }, desiredSkills); + delete nextAdapterConfig.promptTemplate; + delete nextAdapterConfig.bootstrapPromptTemplate; + delete nextAdapterConfig.instructionsFilePath; + delete nextAdapterConfig.instructionsBundleMode; + delete nextAdapterConfig.instructionsRootPath; + delete nextAdapterConfig.instructionsEntryFile; + const normalizedAdapterConfig = await secrets.normalizeAdapterConfigForPersistence( + companyId, + nextAdapterConfig, + { strictMode: strictSecretsMode }, + ); + await assertImportAdapterConfigConstraints(companyId, effectiveAdapterType, normalizedAdapterConfig); + return { + adapterType: effectiveAdapterType, + adapterConfig: normalizedAdapterConfig, + }; + } + + function resolveImportedAssigneeAgentId( + assigneeSlug: string | null | undefined, + importedSlugToAgentId: Map, + existingSlugToAgentId: Map, + agentStatusById: Map, + warnings: string[], + subjectLabel: string, + ) { + if (!assigneeSlug) return null; + const assigneeAgentId = + importedSlugToAgentId.get(assigneeSlug) + ?? existingSlugToAgentId.get(assigneeSlug) + ?? null; + if (!assigneeAgentId) return null; + const assigneeStatus = agentStatusById.get(assigneeAgentId) ?? null; + if (assigneeStatus === "pending_approval" || assigneeStatus === "terminated") { + warnings.push( + `${subjectLabel} assignee ${assigneeSlug} is ${assigneeStatus}; imported work was left unassigned.`, + ); + return null; + } + return assigneeAgentId; + } async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise { if (source.type === "inline") { @@ -3856,7 +3959,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const warnings = [...plan.preview.warnings]; const include = plan.include; - let targetCompany: { id: string; name: string } | null = null; + let targetCompany: { + id: string; + name: string; + requireBoardApprovalForNewAgents?: boolean | null; + } | null = null; let companyAction: "created" | "updated" | "unchanged" = "unchanged"; if (input.target.mode === "new_company") { @@ -3977,9 +4084,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const resultProjects: CompanyPortabilityImportResult["projects"] = []; const importedSlugToAgentId = new Map(); const existingSlugToAgentId = new Map(); + const agentStatusById = new Map(); const existingAgents = await agents.list(targetCompany.id); for (const existing of existingAgents) { existingSlugToAgentId.set(normalizeAgentUrlKey(existing.name) ?? existing.id, existing.id); + agentStatusById.set(existing.id, existing.status); } const importedSlugToProjectId = new Map(); const importedProjectWorkspaceIdByProjectSlug = new Map>(); @@ -4049,22 +4158,18 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { // Apply adapter overrides from request if present const adapterOverride = input.adapterOverrides?.[planAgent.slug]; - const effectiveAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType; const baseAdapterConfig = adapterOverride?.adapterConfig ? { ...adapterOverride.adapterConfig } : { ...manifestAgent.adapterConfig } as Record; const desiredSkills = (manifestAgent.skills ?? []).map((skillRef) => desiredSkillRefMap.get(skillRef) ?? skillRef); - const adapterConfigWithSkills = writePaperclipSkillSyncPreference( + const normalizedAdapter = await prepareImportedAgentAdapter( + targetCompany.id, + adapterOverride?.adapterType ?? manifestAgent.adapterType, baseAdapterConfig, desiredSkills, + mode, ); - delete adapterConfigWithSkills.promptTemplate; - delete adapterConfigWithSkills.bootstrapPromptTemplate; // deprecated - delete adapterConfigWithSkills.instructionsFilePath; - delete adapterConfigWithSkills.instructionsBundleMode; - delete adapterConfigWithSkills.instructionsRootPath; - delete adapterConfigWithSkills.instructionsEntryFile; const patch = { name: planAgent.plannedName, role: manifestAgent.role, @@ -4072,8 +4177,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { icon: manifestAgent.icon, capabilities: manifestAgent.capabilities, reportsTo: null, - adapterType: effectiveAdapterType, - adapterConfig: adapterConfigWithSkills, + adapterType: normalizedAdapter.adapterType, + adapterConfig: normalizedAdapter.adapterConfig, runtimeConfig: disableImportedTimerHeartbeat(manifestAgent.runtimeConfig), budgetMonthlyCents: manifestAgent.budgetMonthlyCents, permissions: manifestAgent.permissions, @@ -4102,6 +4207,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } catch (err) { warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`); } + agentStatusById.set(updated.id, updated.status ?? agentStatusById.get(updated.id) ?? null); importedSlugToAgentId.set(planAgent.slug, updated.id); existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id); resultAgents.push({ @@ -4114,7 +4220,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { continue; } - let created = await agents.create(targetCompany.id, patch); + const createdStatus = "idle"; + let created = await agents.create(targetCompany.id, { + ...patch, + status: createdStatus, + }); await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active"); await access.setPrincipalPermission( targetCompany.id, @@ -4133,6 +4243,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } catch (err) { warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`); } + agentStatusById.set(created.id, created.status ?? createdStatus); importedSlugToAgentId.set(planAgent.slug, created.id); existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id); resultAgents.push({ @@ -4275,11 +4386,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const markdownRaw = readPortableTextFile(plan.source.files, manifestIssue.path); const parsed = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : null; const description = parsed?.body || manifestIssue.description || null; - const assigneeAgentId = manifestIssue.assigneeAgentSlug - ? importedSlugToAgentId.get(manifestIssue.assigneeAgentSlug) - ?? existingSlugToAgentId.get(manifestIssue.assigneeAgentSlug) - ?? null - : null; + const assigneeAgentId = resolveImportedAssigneeAgentId( + manifestIssue.assigneeAgentSlug, + importedSlugToAgentId, + existingSlugToAgentId, + agentStatusById, + warnings, + `Task ${manifestIssue.slug}`, + ); const projectId = manifestIssue.projectSlug ? importedSlugToProjectId.get(manifestIssue.projectSlug) ?? existingProjectSlugToId.get(manifestIssue.projectSlug) @@ -4292,8 +4406,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { warnings.push(`Task ${manifestIssue.slug} references workspace key ${manifestIssue.projectWorkspaceKey}, but that workspace was not imported.`); } if (manifestIssue.recurring) { - if (!projectId || !assigneeAgentId) { - throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project or assignee required to create a routine.`); + if (!projectId) { + throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project required to create a routine.`); } const resolvedRoutine = resolvePortableRoutineDefinition(manifestIssue, parsed?.frontmatter.schedule); if (resolvedRoutine.errors.length > 0) { @@ -4373,22 +4487,27 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } continue; } + let issueStatus = manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any) + ? manifestIssue.status as typeof ISSUE_STATUSES[number] + : "backlog"; + if (!assigneeAgentId && issueStatus === "in_progress") { + warnings.push(`Task ${manifestIssue.slug} was downgraded to todo because its assignee could not be imported as assignable work.`); + issueStatus = "todo"; + } await issues.create(targetCompany.id, { projectId, projectWorkspaceId, title: manifestIssue.title, description, assigneeAgentId, - status: manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any) - ? manifestIssue.status as typeof ISSUE_STATUSES[number] - : "backlog", + status: issueStatus, priority: manifestIssue.priority && ISSUE_PRIORITIES.includes(manifestIssue.priority as any) ? manifestIssue.priority as typeof ISSUE_PRIORITIES[number] : "medium", billingCode: manifestIssue.billingCode, assigneeAdapterOverrides: manifestIssue.assigneeAdapterOverrides, executionWorkspaceSettings: manifestIssue.executionWorkspaceSettings, - labelIds: [], + labelIds: manifestIssue.labelIds ?? [], }); } } diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 60fc06b40f5..0985c2b6226 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -36,6 +36,50 @@ import { projectService } from "./projects.js"; import { secretService } from "./secrets.js"; type CompanySkillRow = typeof companySkills.$inferSelect; +type CompanySkillListDbRow = Pick< + CompanySkillRow, + | "id" + | "companyId" + | "key" + | "slug" + | "name" + | "description" + | "sourceType" + | "sourceLocator" + | "sourceRef" + | "trustLevel" + | "compatibility" + | "fileInventory" + | "metadata" + | "createdAt" + | "updatedAt" +>; +type CompanySkillListRow = Pick< + CompanySkill, + | "id" + | "companyId" + | "key" + | "slug" + | "name" + | "description" + | "sourceType" + | "sourceLocator" + | "sourceRef" + | "trustLevel" + | "compatibility" + | "fileInventory" + | "metadata" + | "createdAt" + | "updatedAt" +>; +type SkillReferenceTarget = Pick; +type SkillSourceInfoTarget = Pick< + CompanySkill, + | "companyId" + | "sourceType" + | "sourceLocator" + | "metadata" +>; type ImportedSkill = { key: string; @@ -1150,6 +1194,28 @@ function toCompanySkill(row: CompanySkillRow): CompanySkill { }; } +function toCompanySkillListRow(row: CompanySkillListDbRow): CompanySkillListRow { + return { + ...row, + description: row.description ?? null, + sourceType: row.sourceType as CompanySkillSourceType, + sourceLocator: row.sourceLocator ?? null, + sourceRef: row.sourceRef ?? null, + trustLevel: row.trustLevel as CompanySkillTrustLevel, + compatibility: row.compatibility as CompanySkillCompatibility, + fileInventory: Array.isArray(row.fileInventory) + ? row.fileInventory.flatMap((entry) => { + if (!isPlainRecord(entry)) return []; + return [{ + path: String(entry.path ?? ""), + kind: (String(entry.kind ?? "other") as CompanySkillFileInventoryEntry["kind"]), + }]; + }) + : [], + metadata: isPlainRecord(row.metadata) ? row.metadata : null, + }; +} + function serializeFileInventory( fileInventory: CompanySkillFileInventoryEntry[], ): Array> { @@ -1159,14 +1225,14 @@ function serializeFileInventory( })); } -function getSkillMeta(skill: CompanySkill): SkillSourceMeta { +function getSkillMeta(skill: Pick): SkillSourceMeta { return isPlainRecord(skill.metadata) ? skill.metadata as SkillSourceMeta : {}; } function resolveSkillReference( - skills: CompanySkill[], + skills: SkillReferenceTarget[], reference: string, -): { skill: CompanySkill | null; ambiguous: boolean } { +): { skill: SkillReferenceTarget | null; ambiguous: boolean } { const trimmed = reference.trim(); if (!trimmed) { return { skill: null, ambiguous: false }; @@ -1242,7 +1308,7 @@ function resolveRequestedSkillKeysOrThrow( } function resolveDesiredSkillKeys( - skills: CompanySkill[], + skills: SkillReferenceTarget[], config: Record, ) { const preference = readPaperclipSkillSyncPreference(config); @@ -1253,7 +1319,7 @@ function resolveDesiredSkillKeys( )); } -function normalizeSkillDirectory(skill: CompanySkill) { +function normalizeSkillDirectory(skill: SkillSourceInfoTarget) { if ((skill.sourceType !== "local_path" && skill.sourceType !== "catalog") || !skill.sourceLocator) return null; const resolved = path.resolve(skill.sourceLocator); if (path.basename(resolved).toLowerCase() === "skill.md") { @@ -1329,7 +1395,7 @@ function isMarkdownPath(filePath: string) { return fileName === "skill.md" || fileName.endsWith(".md"); } -function deriveSkillSourceInfo(skill: CompanySkill): { +function deriveSkillSourceInfo(skill: SkillSourceInfoTarget): { editable: boolean; editableReason: string | null; sourceLabel: string | null; @@ -1428,7 +1494,7 @@ function enrichSkill(skill: CompanySkill, attachedAgentCount: number, usedByAgen }; } -function toCompanySkillListItem(skill: CompanySkill, attachedAgentCount: number): CompanySkillListItem { +function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount: number): CompanySkillListItem { const source = deriveSkillSourceInfo(skill); return { id: skill.id, @@ -1526,7 +1592,29 @@ export function companySkillService(db: Db) { } async function list(companyId: string): Promise { - const rows = await listFull(companyId); + await ensureSkillInventoryCurrent(companyId); + const rows = await db + .select({ + id: companySkills.id, + companyId: companySkills.companyId, + key: companySkills.key, + slug: companySkills.slug, + name: companySkills.name, + description: companySkills.description, + sourceType: companySkills.sourceType, + sourceLocator: companySkills.sourceLocator, + sourceRef: companySkills.sourceRef, + trustLevel: companySkills.trustLevel, + compatibility: companySkills.compatibility, + fileInventory: companySkills.fileInventory, + metadata: companySkills.metadata, + createdAt: companySkills.createdAt, + updatedAt: companySkills.updatedAt, + }) + .from(companySkills) + .where(eq(companySkills.companyId, companyId)) + .orderBy(asc(companySkills.name), asc(companySkills.key)) + .then((entries) => entries.map((entry) => toCompanySkillListRow(entry as CompanySkillListDbRow))); const agentRows = await agents.list(companyId); return rows.map((skill) => { const attachedAgentCount = agentRows.filter((agent) => { diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts index 76a90f2dad6..88825b498bd 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -12,6 +12,10 @@ export interface CostDateRange { const METERED_BILLING_TYPE = "metered_api"; const SUBSCRIPTION_BILLING_TYPES = ["subscription_included", "subscription_overage"] as const; +function sumAsNumber(column: typeof costEvents.costCents | typeof costEvents.inputTokens | typeof costEvents.cachedInputTokens | typeof costEvents.outputTokens) { + return sql`coalesce(sum(${column}), 0)::double precision`; +} + function currentUtcMonthWindow(now = new Date()) { const year = now.getUTCFullYear(); const month = now.getUTCMonth(); @@ -36,7 +40,7 @@ async function getMonthlySpendTotal( } const [row] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sumAsNumber(costEvents.costCents), }) .from(costEvents) .where(and(...conditions)); @@ -111,7 +115,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { const [{ total }] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sumAsNumber(costEvents.costCents), }) .from(costEvents) .where(and(...conditions)); @@ -140,26 +144,26 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { agentId: costEvents.agentId, agentName: agents.name, agentStatus: agents.status, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, }) .from(costEvents) .leftJoin(agents, eq(costEvents.agentId, agents.id)) .where(and(...conditions)) .groupBy(costEvents.agentId, agents.name, agents.status) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, byProvider: async (companyId: string, range?: CostDateRange) => { @@ -173,25 +177,25 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { biller: costEvents.biller, billingType: costEvents.billingType, model: costEvents.model, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, }) .from(costEvents) .where(and(...conditions)) .groupBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, byBiller: async (companyId: string, range?: CostDateRange) => { @@ -202,27 +206,27 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { return db .select({ biller: costEvents.biller, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, providerCount: sql`count(distinct ${costEvents.provider})::int`, modelCount: sql`count(distinct ${costEvents.model})::int`, }) .from(costEvents) .where(and(...conditions)) .groupBy(costEvents.biller) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, /** @@ -244,10 +248,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { .select({ provider: costEvents.provider, biller: sql`case when count(distinct ${costEvents.biller}) = 1 then min(${costEvents.biller}) else 'mixed' end`, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .where( @@ -257,7 +261,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { ), ) .groupBy(costEvents.provider) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); return rows.map((row) => ({ provider: row.provider, @@ -292,10 +296,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { biller: costEvents.biller, billingType: costEvents.billingType, model: costEvents.model, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .leftJoin(agents, eq(costEvents.agentId, agents.id)) @@ -342,16 +346,16 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from)); if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to)); - const costCentsExpr = sql`coalesce(sum(${costEvents.costCents}), 0)::int`; + const costCentsExpr = sumAsNumber(costEvents.costCents); return db .select({ projectId: effectiveProjectId, projectName: projects.name, costCents: costCentsExpr, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .leftJoin(runProjectLinks, eq(costEvents.heartbeatRunId, runProjectLinks.runId)) diff --git a/server/src/services/dashboard.ts b/server/src/services/dashboard.ts index e495208d9cd..c1169aa98ca 100644 --- a/server/src/services/dashboard.ts +++ b/server/src/services/dashboard.ts @@ -65,7 +65,7 @@ export function dashboardService(db: Db) { const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const [{ monthSpend }] = await db .select({ - monthSpend: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + monthSpend: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where( diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts index 1a56af27d1c..5c5ee74761a 100644 --- a/server/src/services/documents.ts +++ b/server/src/services/documents.ts @@ -1,7 +1,7 @@ import { and, asc, desc, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { documentRevisions, documents, issueDocuments, issues } from "@paperclipai/db"; -import { issueDocumentKeySchema } from "@paperclipai/shared"; +import { isSystemIssueDocumentKey, issueDocumentKeySchema } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; function normalizeDocumentKey(key: string) { @@ -83,8 +83,14 @@ const issueDocumentSelect = { }; export function documentService(db: Db) { + const filterSystemDocuments = (rows: T[], includeSystem: boolean) => + includeSystem ? rows : rows.filter((row) => !isSystemIssueDocumentKey(row.key)); + return { - getIssueDocumentPayload: async (issue: { id: string; description: string | null }) => { + getIssueDocumentPayload: async ( + issue: { id: string; description: string | null }, + options: { includeSystem?: boolean } = {}, + ) => { const [planDocument, documentSummaries] = await Promise.all([ db .select(issueDocumentSelect) @@ -104,7 +110,8 @@ export function documentService(db: Db) { return { planDocument: planDocument ? mapIssueDocumentRow(planDocument, true) : null, - documentSummaries: documentSummaries.map((row) => mapIssueDocumentRow(row, false)), + documentSummaries: filterSystemDocuments(documentSummaries, options.includeSystem ?? false) + .map((row) => mapIssueDocumentRow(row, false)), legacyPlanDocument: legacyPlanBody ? { key: "plan" as const, @@ -115,14 +122,14 @@ export function documentService(db: Db) { }; }, - listIssueDocuments: async (issueId: string) => { + listIssueDocuments: async (issueId: string, options: { includeSystem?: boolean } = {}) => { const rows = await db .select(issueDocumentSelect) .from(issueDocuments) .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) .where(eq(issueDocuments.issueId, issueId)) .orderBy(asc(issueDocuments.key), desc(documents.updatedAt)); - return rows.map((row) => mapIssueDocumentRow(row, true)); + return filterSystemDocuments(rows, options.includeSystem ?? false).map((row) => mapIssueDocumentRow(row, true)); }, getIssueDocumentByKey: async (issueId: string, rawKey: string) => { diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 58a43210f2a..1832a1a1b5e 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -7,6 +7,7 @@ import type { Db } from "@paperclipai/db"; import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; import type { ExecutionWorkspace, + ExecutionWorkspaceSummary, ExecutionWorkspaceCloseAction, ExecutionWorkspaceCloseGitReadiness, ExecutionWorkspaceCloseReadiness, @@ -192,6 +193,11 @@ export function readExecutionWorkspaceConfig(metadata: Record | cleanupCommand: readNullableString(raw.cleanupCommand), workspaceRuntime: cloneRecord(raw.workspaceRuntime), desiredState: raw.desiredState === "running" || raw.desiredState === "stopped" ? raw.desiredState : null, + serviceStates: isRecord(raw.serviceStates) + ? Object.fromEntries( + Object.entries(raw.serviceStates).filter(([, state]) => state === "running" || state === "stopped"), + ) as ExecutionWorkspaceConfig["serviceStates"] + : null, }; const hasConfig = Object.values(config).some((value) => { @@ -214,6 +220,7 @@ export function mergeExecutionWorkspaceConfig( cleanupCommand: null, workspaceRuntime: null, desiredState: null, + serviceStates: null, }; if (patch === null) { @@ -232,6 +239,14 @@ export function mergeExecutionWorkspaceConfig( ? patch.desiredState : null : current.desiredState, + serviceStates: + patch.serviceStates !== undefined && isRecord(patch.serviceStates) + ? Object.fromEntries( + Object.entries(patch.serviceStates).filter(([, state]) => state === "running" || state === "stopped"), + ) as ExecutionWorkspaceConfig["serviceStates"] + : patch.serviceStates !== undefined + ? null + : current.serviceStates, }; const hasConfig = Object.values(nextConfig).some((value) => { @@ -247,6 +262,7 @@ export function mergeExecutionWorkspaceConfig( cleanupCommand: nextConfig.cleanupCommand, workspaceRuntime: nextConfig.workspaceRuntime, desiredState: nextConfig.desiredState, + serviceStates: nextConfig.serviceStates ?? null, }; } else { delete nextMetadata.config; @@ -321,6 +337,15 @@ function toExecutionWorkspace( }; } +function toExecutionWorkspaceSummary(row: Pick): ExecutionWorkspaceSummary { + return { + id: row.id, + name: row.name, + mode: row.mode as ExecutionWorkspaceSummary["mode"], + projectWorkspaceId: row.projectWorkspaceId ?? null, + }; +} + function usesInheritedProjectRuntimeServices(row: ExecutionWorkspaceRow) { if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false; return !readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null)?.workspaceRuntime; @@ -357,6 +382,33 @@ async function loadEffectiveRuntimeServicesByExecutionWorkspace( } export function executionWorkspaceService(db: Db) { + function buildListConditions( + companyId: string, + filters?: { + projectId?: string; + projectWorkspaceId?: string; + issueId?: string; + status?: string; + reuseEligible?: boolean; + }, + ) { + const conditions = [eq(executionWorkspaces.companyId, companyId)]; + if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId)); + if (filters?.projectWorkspaceId) { + conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId)); + } + if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId)); + if (filters?.status) { + const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean); + if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!)); + else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses)); + } + if (filters?.reuseEligible) { + conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"])); + } + return conditions; + } + return { list: async (companyId: string, filters?: { projectId?: string; @@ -365,21 +417,7 @@ export function executionWorkspaceService(db: Db) { status?: string; reuseEligible?: boolean; }) => { - const conditions = [eq(executionWorkspaces.companyId, companyId)]; - if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId)); - if (filters?.projectWorkspaceId) { - conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId)); - } - if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId)); - if (filters?.status) { - const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean); - if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!)); - else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses)); - } - if (filters?.reuseEligible) { - conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"])); - } - + const conditions = buildListConditions(companyId, filters); const rows = await db .select() .from(executionWorkspaces) @@ -394,6 +432,27 @@ export function executionWorkspaceService(db: Db) { ); }, + listSummaries: async (companyId: string, filters?: { + projectId?: string; + projectWorkspaceId?: string; + issueId?: string; + status?: string; + reuseEligible?: boolean; + }) => { + const conditions = buildListConditions(companyId, filters); + const rows = await db + .select({ + id: executionWorkspaces.id, + name: executionWorkspaces.name, + mode: executionWorkspaces.mode, + projectWorkspaceId: executionWorkspaces.projectWorkspaceId, + }) + .from(executionWorkspaces) + .where(and(...conditions)) + .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); + return rows.map((row) => toExecutionWorkspaceSummary(row)); + }, + getById: async (id: string) => { const row = await db .select() diff --git a/server/src/services/finance.ts b/server/src/services/finance.ts index 29dcec494ab..766bea7b1f3 100644 --- a/server/src/services/finance.ts +++ b/server/src/services/finance.ts @@ -35,9 +35,9 @@ function rangeConditions(companyId: string, range?: FinanceDateRange) { } export function financeService(db: Db) { - const debitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::int`; - const creditExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::int`; - const estimatedDebitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::int`; + const debitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`; + const creditExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`; + const estimatedDebitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::double precision`; return { createEvent: async (companyId: string, data: Omit) => { @@ -95,12 +95,12 @@ export function financeService(db: Db) { estimatedDebitCents: estimatedDebitExpr, eventCount: sql`count(*)::int`, kindCount: sql`count(distinct ${financeEvents.eventKind})::int`, - netCents: sql`(${debitExpr} - ${creditExpr})::int`, + netCents: sql`(${debitExpr} - ${creditExpr})::double precision`, }) .from(financeEvents) .where(and(...conditions)) .groupBy(financeEvents.biller) - .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.biller); + .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.biller); }, byKind: async (companyId: string, range?: FinanceDateRange) => { @@ -113,12 +113,12 @@ export function financeService(db: Db) { estimatedDebitCents: estimatedDebitExpr, eventCount: sql`count(*)::int`, billerCount: sql`count(distinct ${financeEvents.biller})::int`, - netCents: sql`(${debitExpr} - ${creditExpr})::int`, + netCents: sql`(${debitExpr} - ${creditExpr})::double precision`, }) .from(financeEvents) .where(and(...conditions)) .groupBy(financeEvents.eventKind) - .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.eventKind); + .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.eventKind); }, list: async (companyId: string, range?: FinanceDateRange, limit: number = 100) => { diff --git a/server/src/services/heartbeat-run-summary.ts b/server/src/services/heartbeat-run-summary.ts index 4d6335ef653..479957dce2a 100644 --- a/server/src/services/heartbeat-run-summary.ts +++ b/server/src/services/heartbeat-run-summary.ts @@ -1,4 +1,8 @@ -function truncateSummaryText(value: unknown, maxLength = 500) { +export const HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS = 500; +export const HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS = 4_096; +export const HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES = 64 * 1024; + +function truncateSummaryText(value: unknown, maxLength = HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS) { if (typeof value !== "string") return null; return value.length > maxLength ? value.slice(0, maxLength) : value; } @@ -65,6 +69,26 @@ export function summarizeHeartbeatRunResultJson( } } + for (const key of ["stopReason", "timeoutSource"] as const) { + const value = readCommentText(resultJson[key]); + if (value !== null) { + summary[key] = value; + } + } + + for (const key of ["effectiveTimeoutSec", "effectiveTimeoutMs"] as const) { + const value = readNumericField(resultJson, key); + if (value !== undefined && value !== null) { + summary[key] = value; + } + } + + for (const key of ["timeoutConfigured", "timeoutFired"] as const) { + if (typeof resultJson[key] === "boolean") { + summary[key] = resultJson[key]; + } + } + return Object.keys(summary).length > 0 ? summary : null; } diff --git a/server/src/services/heartbeat-stop-metadata.test.ts b/server/src/services/heartbeat-stop-metadata.test.ts new file mode 100644 index 00000000000..fa0b636613f --- /dev/null +++ b/server/src/services/heartbeat-stop-metadata.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + buildHeartbeatRunStopMetadata, + mergeHeartbeatRunStopMetadata, + resolveHeartbeatRunTimeoutPolicy, +} from "./heartbeat-stop-metadata.js"; + +describe("heartbeat stop metadata", () => { + it("keeps local coding adapters at no timeout by default", () => { + for (const adapterType of [ + "codex_local", + "claude_local", + "cursor", + "gemini_local", + "opencode_local", + "pi_local", + "process", + ]) { + expect(resolveHeartbeatRunTimeoutPolicy(adapterType, {})).toEqual({ + effectiveTimeoutSec: 0, + timeoutConfigured: false, + timeoutSource: "default", + }); + } + }); + + it("records configured timeout policy and timeout stop reason", () => { + const metadata = buildHeartbeatRunStopMetadata({ + adapterType: "codex_local", + adapterConfig: { timeoutSec: 45 }, + outcome: "timed_out", + errorCode: "timeout", + errorMessage: "Timed out after 45s", + }); + + expect(metadata).toEqual({ + effectiveTimeoutSec: 45, + timeoutConfigured: true, + timeoutSource: "config", + stopReason: "timeout", + timeoutFired: true, + }); + }); + + it("distinguishes budget cancellation from manual cancellation", () => { + expect( + buildHeartbeatRunStopMetadata({ + adapterType: "codex_local", + adapterConfig: {}, + outcome: "cancelled", + errorCode: "cancelled", + errorMessage: "Cancelled due to budget pause", + }).stopReason, + ).toBe("budget_paused"); + + expect( + buildHeartbeatRunStopMetadata({ + adapterType: "codex_local", + adapterConfig: {}, + outcome: "cancelled", + errorCode: "cancelled", + errorMessage: "Cancelled by control plane", + }).stopReason, + ).toBe("cancelled"); + }); + + it("preserves existing result fields when merging stop metadata", () => { + const result = mergeHeartbeatRunStopMetadata( + { summary: "done" }, + buildHeartbeatRunStopMetadata({ + adapterType: "openclaw_gateway", + adapterConfig: {}, + outcome: "succeeded", + }), + ); + + expect(result).toMatchObject({ + summary: "done", + stopReason: "completed", + effectiveTimeoutSec: 120, + timeoutConfigured: true, + timeoutSource: "default", + timeoutFired: false, + }); + }); +}); diff --git a/server/src/services/heartbeat-stop-metadata.ts b/server/src/services/heartbeat-stop-metadata.ts new file mode 100644 index 00000000000..e04c3b9ffa7 --- /dev/null +++ b/server/src/services/heartbeat-stop-metadata.ts @@ -0,0 +1,119 @@ +export type HeartbeatRunOutcome = "succeeded" | "failed" | "cancelled" | "timed_out"; + +export type HeartbeatRunStopReason = + | "completed" + | "timeout" + | "cancelled" + | "budget_paused" + | "paused" + | "process_lost" + | "adapter_failed"; + +export interface HeartbeatRunTimeoutPolicy { + effectiveTimeoutSec: number | null; + effectiveTimeoutMs?: number | null; + timeoutConfigured: boolean; + timeoutSource: "config" | "default" | "unknown"; +} + +export interface HeartbeatRunStopMetadata extends HeartbeatRunTimeoutPolicy { + stopReason: HeartbeatRunStopReason; + timeoutFired: boolean; +} + +function readFiniteNumber(value: unknown): number | null { + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + if (typeof value === "string") { + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function hasOwn(record: Record, key: string) { + return Object.prototype.hasOwnProperty.call(record, key); +} + +function defaultTimeoutSecForAdapter(adapterType: string) { + return adapterType === "openclaw_gateway" ? 120 : 0; +} + +export function resolveHeartbeatRunTimeoutPolicy( + adapterType: string, + adapterConfig: Record | null | undefined, +): HeartbeatRunTimeoutPolicy { + const config = adapterConfig ?? {}; + + if (adapterType === "http") { + const hasTimeoutMs = hasOwn(config, "timeoutMs"); + const rawTimeoutMs = hasTimeoutMs ? readFiniteNumber(config.timeoutMs) : 0; + const timeoutMs = Math.max(0, Math.floor(rawTimeoutMs ?? 0)); + return { + effectiveTimeoutSec: timeoutMs / 1000, + effectiveTimeoutMs: timeoutMs, + timeoutConfigured: timeoutMs > 0, + timeoutSource: hasTimeoutMs ? "config" : "default", + }; + } + + const hasTimeoutSec = hasOwn(config, "timeoutSec"); + const defaultTimeoutSec = defaultTimeoutSecForAdapter(adapterType); + const rawTimeoutSec = hasTimeoutSec ? readFiniteNumber(config.timeoutSec) : defaultTimeoutSec; + const timeoutSec = Math.max(0, Math.floor(rawTimeoutSec ?? defaultTimeoutSec)); + + return { + effectiveTimeoutSec: timeoutSec, + timeoutConfigured: timeoutSec > 0, + timeoutSource: hasTimeoutSec ? "config" : "default", + }; +} + +export function inferHeartbeatRunStopReason(input: { + outcome: HeartbeatRunOutcome; + errorCode?: string | null; + errorMessage?: string | null; +}): HeartbeatRunStopReason { + if (input.outcome === "succeeded") return "completed"; + if (input.outcome === "timed_out") return "timeout"; + if (input.outcome === "failed" && input.errorCode === "process_lost") return "process_lost"; + if (input.outcome === "cancelled") { + const message = (input.errorMessage ?? "").toLowerCase(); + if (message.includes("budget")) return "budget_paused"; + if (message.includes("pause") || message.includes("paused")) return "paused"; + return "cancelled"; + } + return "adapter_failed"; +} + +export function buildHeartbeatRunStopMetadata(input: { + adapterType: string; + adapterConfig: Record | null | undefined; + outcome: HeartbeatRunOutcome; + errorCode?: string | null; + errorMessage?: string | null; +}): HeartbeatRunStopMetadata { + const timeoutPolicy = resolveHeartbeatRunTimeoutPolicy(input.adapterType, input.adapterConfig); + const stopReason = inferHeartbeatRunStopReason(input); + return { + ...timeoutPolicy, + stopReason, + timeoutFired: stopReason === "timeout", + }; +} + +export function mergeHeartbeatRunStopMetadata( + resultJson: Record | null | undefined, + metadata: HeartbeatRunStopMetadata, +): Record { + return { + ...(resultJson ?? {}), + stopReason: metadata.stopReason, + effectiveTimeoutSec: metadata.effectiveTimeoutSec, + timeoutConfigured: metadata.timeoutConfigured, + timeoutSource: metadata.timeoutSource, + timeoutFired: metadata.timeoutFired, + ...(metadata.effectiveTimeoutMs != null ? { effectiveTimeoutMs: metadata.effectiveTimeoutMs } : {}), + }; +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 2de20783162..50c55a045d5 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2,29 +2,42 @@ import fs from "node:fs/promises"; import path from "node:path"; import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; -import { and, asc, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, getTableColumns, gt, inArray, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import type { BillingType, ExecutionWorkspace, ExecutionWorkspaceConfig } from "@paperclipai/shared"; +import { + AGENT_DEFAULT_MAX_CONCURRENT_RUNS, + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + type BillingType, + type ExecutionWorkspace, + type ExecutionWorkspaceConfig, + type RunLivenessState, +} from "@paperclipai/shared"; import { agents, agentRuntimeState, agentTaskSessions, agentWakeupRequests, + activityLog, + companySkills as companySkillsTable, + documentRevisions, + issueDocuments, heartbeatRunEvents, heartbeatRuns, issueComments, issues, + issueWorkProducts, projects, projectWorkspaces, + workspaceOperations, } from "@paperclipai/db"; -import { conflict, notFound } from "../errors.js"; +import { conflict, HttpError, notFound } from "../errors.js"; import { logger } from "../middleware/logger.js"; import { publishLiveEvent } from "./live-events.js"; import { getRunLogStore, type RunLogHandle } from "./run-log-store.js"; import { getServerAdapter, runningProcesses } from "../adapters/index.js"; import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec, UsageSummary } from "../adapters/index.js"; import { createLocalAgentJwt } from "../agent-auth-jwt.js"; -import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; +import { parseObject, asBoolean, asNumber, appendWithByteCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; import { costService } from "./costs.js"; import { trackAgentFirstHeartbeat } from "@paperclipai/shared/telemetry"; import { getTelemetryClient } from "../telemetry.js"; @@ -35,8 +48,10 @@ import { memoryService } from "./memory.js"; import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js"; import { buildHeartbeatRunIssueComment, + HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS, + HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS, + HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES, mergeHeartbeatRunResultJson, - summarizeHeartbeatRunResultJson, } from "./heartbeat-run-summary.js"; import { buildErroredMemoryHookTrace, @@ -44,6 +59,15 @@ import { formatMemoryHookTraceLog, type MemoryHookTrace, } from "./memory-hook-trace.js"; +import { + buildHeartbeatRunStopMetadata, + mergeHeartbeatRunStopMetadata, +} from "./heartbeat-stop-metadata.js"; +import { + classifyRunLiveness, + type RunLivenessClassificationInput, +} from "./run-liveness.js"; +import { logActivity, type LogActivityInput } from "./activity-log.js"; import { buildWorkspaceReadyComment, cleanupExecutionWorkspaceArtifacts, @@ -56,6 +80,10 @@ import { sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; import { issueService } from "./issues.js"; +import { + getIssueContinuationSummaryDocument, + refreshIssueContinuationSummary, +} from "./issue-continuation-summary.js"; import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js"; import { workspaceOperationService } from "./workspace-operations.js"; import { isProcessGroupAlive, terminateLocalService } from "./local-service-supervisor.js"; @@ -68,19 +96,37 @@ import { resolveExecutionWorkspaceMode, } from "./execution-workspace-policy.js"; import { instanceSettingsService } from "./instance-settings.js"; +import { + RUN_LIVENESS_CONTINUATION_REASON, + buildRunLivenessContinuationIdempotencyKey, + decideRunLivenessContinuation, + findExistingRunLivenessContinuationWake, + readContinuationAttempt, +} from "./run-continuations.js"; import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js"; import { hasSessionCompactionThresholds, resolveSessionCompactionPolicy, type SessionCompactionPolicy, } from "@paperclipai/adapter-utils"; +import { + readPaperclipSkillSyncPreference, + writePaperclipSkillSyncPreference, +} from "@paperclipai/adapter-utils/server-utils"; +import { extractSkillMentionIds } from "@paperclipai/shared"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; -const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; +const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024; +const MAX_RUN_EVENT_PAYLOAD_STRING_CHARS = 16 * 1024; +const MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS = 50; +const MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS = 100; +const MAX_RUN_EVENT_PAYLOAD_DEPTH = 6; +const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = AGENT_DEFAULT_MAX_CONCURRENT_RUNS; const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; const WAKE_COMMENT_IDS_KEY = "wakeCommentIds"; const PAPERCLIP_WAKE_PAYLOAD_KEY = "paperclipWake"; +const PAPERCLIP_HARNESS_CHECKOUT_KEY = "paperclipHarnessCheckedOut"; const DETACHED_PROCESS_ERROR_CODE = "process_detached"; const startLocksByAgent = new Map>(); const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; @@ -89,14 +135,19 @@ const MAX_INLINE_WAKE_COMMENTS = 8; const MAX_INLINE_WAKE_COMMENT_BODY_CHARS = 4_000; const MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS = 12_000; const execFile = promisify(execFileCallback); +const ACTIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running"] as const; +const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = ["failed", "cancelled", "timed_out"] as const; +const RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP = new Set(["approval_approved"]); const SESSIONED_LOCAL_ADAPTERS = new Set([ "claude_local", "codex_local", "cursor", "gemini_local", + "hermes_local", "opencode_local", "pi_local", ]); +const INLINE_BASE64_IMAGE_DATA_RE = /("type":"image","source":\{"type":"base64","data":")([A-Za-z0-9+/=]{1024,})(")/g; type RuntimeConfigSecretResolver = Pick< ReturnType, @@ -128,6 +179,90 @@ export async function resolveExecutionRunAdapterConfig(input: { return { resolvedConfig, secretKeys }; } +export function extractMentionedSkillIdsFromSources( + sources: Array, +): string[] { + const mentionedIds = new Set(); + for (const source of sources) { + if (typeof source !== "string" || source.length === 0) continue; + for (const skillId of extractSkillMentionIds(source)) { + mentionedIds.add(skillId); + } + } + return [...mentionedIds]; +} + +export function applyRunScopedMentionedSkillKeys( + config: Record, + skillKeys: string[], +): Record { + const normalizedSkillKeys = Array.from( + new Set( + skillKeys + .map((value) => value.trim()) + .filter(Boolean), + ), + ); + if (normalizedSkillKeys.length === 0) return config; + + const existingPreference = readPaperclipSkillSyncPreference(config); + return writePaperclipSkillSyncPreference(config, [ + ...existingPreference.desiredSkills, + ...normalizedSkillKeys, + ]); +} + +async function resolveRunScopedMentionedSkillKeys(input: { + db: Db; + companyId: string; + issueId: string | null; +}): Promise { + if (!input.issueId) return []; + + const issue = await input.db + .select({ + title: issues.title, + description: issues.description, + }) + .from(issues) + .where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId))) + .then((rows) => rows[0] ?? null); + if (!issue) return []; + + const comments = await input.db + .select({ body: issueComments.body }) + .from(issueComments) + .where( + and( + eq(issueComments.issueId, input.issueId), + eq(issueComments.companyId, input.companyId), + ), + ); + const mentionedSkillIds = extractMentionedSkillIdsFromSources([ + issue.title, + issue.description ?? "", + ...comments.map((comment) => comment.body), + ]); + if (mentionedSkillIds.length === 0) return []; + + const skillRows = await input.db + .select({ + id: companySkillsTable.id, + key: companySkillsTable.key, + }) + .from(companySkillsTable) + .where( + and( + eq(companySkillsTable.companyId, input.companyId), + inArray(companySkillsTable.id, mentionedSkillIds), + ), + ); + const skillKeyById = new Map(skillRows.map((row) => [row.id, row.key])); + return mentionedSkillIds + .map((skillId) => skillKeyById.get(skillId) ?? null) + .filter((skillKey): skillKey is string => Boolean(skillKey)); +} + export function applyPersistedExecutionWorkspaceConfig(input: { config: Record; workspaceConfig: ExecutionWorkspaceConfig | null; @@ -290,7 +425,6 @@ const heartbeatRunListColumns = { exitCode: heartbeatRuns.exitCode, signal: heartbeatRuns.signal, usageJson: heartbeatRuns.usageJson, - resultJson: heartbeatRuns.resultJson, sessionIdBefore: heartbeatRuns.sessionIdBefore, sessionIdAfter: heartbeatRuns.sessionIdAfter, logStore: heartbeatRuns.logStore, @@ -307,11 +441,104 @@ const heartbeatRunListColumns = { processStartedAt: heartbeatRuns.processStartedAt, retryOfRunId: heartbeatRuns.retryOfRunId, processLossRetryCount: heartbeatRuns.processLossRetryCount, - contextSnapshot: heartbeatRuns.contextSnapshot, + livenessState: heartbeatRuns.livenessState, + livenessReason: heartbeatRuns.livenessReason, + continuationAttempt: heartbeatRuns.continuationAttempt, + lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, + nextAction: heartbeatRuns.nextAction, createdAt: heartbeatRuns.createdAt, updatedAt: heartbeatRuns.updatedAt, } as const; +const heartbeatRunListContextColumns = { + contextIssueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("contextIssueId"), + contextTaskId: sql`${heartbeatRuns.contextSnapshot} ->> 'taskId'`.as("contextTaskId"), + contextTaskKey: sql`${heartbeatRuns.contextSnapshot} ->> 'taskKey'`.as("contextTaskKey"), + contextCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"), + contextWakeCommentId: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`.as("contextWakeCommentId"), + contextWakeReason: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeReason'`.as("contextWakeReason"), + contextWakeSource: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeSource'`.as("contextWakeSource"), + contextWakeTriggerDetail: sql`${heartbeatRuns.contextSnapshot} ->> 'wakeTriggerDetail'`.as("contextWakeTriggerDetail"), +} as const; + +const heartbeatRunListResultColumns = { + resultSummary: sql`left(${heartbeatRuns.resultJson} ->> 'summary', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultSummary"), + resultResult: sql`left(${heartbeatRuns.resultJson} ->> 'result', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultResult"), + resultMessage: sql`left(${heartbeatRuns.resultJson} ->> 'message', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultMessage"), + resultError: sql`left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS})`.as("resultError"), + resultTotalCostUsd: sql`${heartbeatRuns.resultJson} ->> 'total_cost_usd'`.as("resultTotalCostUsd"), + resultCostUsd: sql`${heartbeatRuns.resultJson} ->> 'cost_usd'`.as("resultCostUsd"), + resultCostUsdCamel: sql`${heartbeatRuns.resultJson} ->> 'costUsd'`.as("resultCostUsdCamel"), +} as const; + +const heartbeatRunSafeResultJsonColumn = sql | null>` + case + when ${heartbeatRuns.resultJson} is null then null + when pg_column_size(${heartbeatRuns.resultJson}) <= ${HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES} + then ${heartbeatRuns.resultJson} + else jsonb_strip_nulls( + jsonb_build_object( + 'summary', left(${heartbeatRuns.resultJson} ->> 'summary', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'result', left(${heartbeatRuns.resultJson} ->> 'result', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'message', left(${heartbeatRuns.resultJson} ->> 'message', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'error', left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}), + 'stdout', left(${heartbeatRuns.resultJson} ->> 'stdout', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}), + 'stderr', left(${heartbeatRuns.resultJson} ->> 'stderr', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}), + 'stdoutTruncated', case + when length(${heartbeatRuns.resultJson} ->> 'stdout') > ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS} + then to_jsonb(true) + else null + end, + 'stderrTruncated', case + when length(${heartbeatRuns.resultJson} ->> 'stderr') > ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS} + then to_jsonb(true) + else null + end, + 'costUsd', coalesce( + ${heartbeatRuns.resultJson} -> 'costUsd', + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'total_cost_usd' + ), + 'cost_usd', coalesce( + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'costUsd', + ${heartbeatRuns.resultJson} -> 'total_cost_usd' + ), + 'total_cost_usd', coalesce( + ${heartbeatRuns.resultJson} -> 'total_cost_usd', + ${heartbeatRuns.resultJson} -> 'cost_usd', + ${heartbeatRuns.resultJson} -> 'costUsd' + ), + 'truncated', true, + 'truncationReason', 'oversized_result_json', + 'originalSizeBytes', pg_column_size(${heartbeatRuns.resultJson}) + ) + ) + end +`.as("resultJson"); + +const heartbeatRunSafeColumns = { + ...getTableColumns(heartbeatRuns), + processGroupId: heartbeatRunProcessGroupIdColumn, + resultJson: heartbeatRunSafeResultJsonColumn, +} as const; + +const heartbeatRunSqlAsciiSafeColumns = { + ...getTableColumns(heartbeatRuns), + processGroupId: heartbeatRunProcessGroupIdColumn, + error: sql`NULL`.as("error"), + resultJson: sql | null>`NULL`.as("resultJson"), + stdoutExcerpt: sql`NULL`.as("stdoutExcerpt"), + stderrExcerpt: sql`NULL`.as("stderrExcerpt"), +} as const; + +const heartbeatRunLogAccessColumns = { + id: heartbeatRuns.id, + companyId: heartbeatRuns.companyId, + logStore: heartbeatRuns.logStore, + logRef: heartbeatRuns.logRef, +} as const; + const heartbeatRunIssueSummaryColumns = { id: heartbeatRuns.id, status: heartbeatRuns.status, @@ -321,11 +548,107 @@ const heartbeatRunIssueSummaryColumns = { finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, agentId: heartbeatRuns.agentId, + livenessState: heartbeatRuns.livenessState, + livenessReason: heartbeatRuns.livenessReason, + continuationAttempt: heartbeatRuns.continuationAttempt, + lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt, + nextAction: heartbeatRuns.nextAction, issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"), } as const; function appendExcerpt(prev: string, chunk: string) { - return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES); + return appendWithByteCap(prev, chunk, MAX_EXCERPT_BYTES); +} + +function truncateRunEventString(value: string) { + if (value.length <= MAX_RUN_EVENT_PAYLOAD_STRING_CHARS) return value; + const omittedChars = value.length - MAX_RUN_EVENT_PAYLOAD_STRING_CHARS; + return `${value.slice(0, MAX_RUN_EVENT_PAYLOAD_STRING_CHARS)}\n[truncated ${omittedChars} chars]`; +} + +function boundRunEventValue(value: unknown, depth: number, seen: WeakSet): unknown { + if (typeof value === "string") { + return truncateRunEventString(value); + } + if ( + value === null + || typeof value === "number" + || typeof value === "boolean" + ) { + return value; + } + if (value instanceof Date) { + return value.toISOString(); + } + if (Array.isArray(value)) { + if (depth >= MAX_RUN_EVENT_PAYLOAD_DEPTH) { + return { + _truncated: true, + type: "array", + originalLength: value.length, + }; + } + const bounded = value + .slice(0, MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS) + .map((entry) => boundRunEventValue(entry, depth + 1, seen)); + if (value.length > MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS) { + bounded.push({ + _truncated: true, + omittedItems: value.length - MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS, + }); + } + return bounded; + } + if (typeof value !== "object" || value === undefined) { + return null; + } + if (seen.has(value)) { + return "[Circular]"; + } + seen.add(value); + const entries = Object.entries(value as Record); + if (depth >= MAX_RUN_EVENT_PAYLOAD_DEPTH) { + const bounded = { + _truncated: true, + type: "object", + keys: entries.map(([key]) => key).slice(0, 20), + }; + seen.delete(value); + return bounded; + } + + const out: Record = {}; + for (const [key, entryValue] of entries.slice(0, MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS)) { + out[key] = boundRunEventValue(entryValue, depth + 1, seen); + } + if (entries.length > MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS) { + out._truncated = true; + out._omittedKeys = entries.length - MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS; + } + seen.delete(value); + return out; +} + +export function boundHeartbeatRunEventPayloadForStorage(payload: Record): Record { + const bounded = boundRunEventValue(payload, 0, new WeakSet()); + return parseObject(bounded) ?? { _truncated: true }; +} + +function redactInlineBase64ImageData(chunk: string) { + return chunk.replace(INLINE_BASE64_IMAGE_DATA_RE, (_match, prefix: string, data: string, suffix: string) => + `${prefix}[omitted base64 image data: ${data.length} chars]${suffix}`, + ); +} + +export function compactRunLogChunk(chunk: string, maxChars = MAX_PERSISTED_LOG_CHUNK_CHARS) { + const normalized = redactInlineBase64ImageData(chunk); + if (normalized.length <= maxChars) return normalized; + + const headChars = Math.max(0, Math.floor(maxChars * 0.6)); + const tailChars = Math.max(0, Math.floor(maxChars * 0.25)); + const omittedChars = Math.max(0, normalized.length - headChars - tailChars); + const marker = `\n[paperclip truncated run log chunk: omitted ${omittedChars} chars]\n`; + return `${normalized.slice(0, headChars)}${marker}${normalized.slice(normalized.length - tailChars)}`; } function normalizeMaxConcurrentRuns(value: unknown) { @@ -414,6 +737,103 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } +export function summarizeHeartbeatRunContextSnapshot( + contextSnapshot: Record | null | undefined, +): Record | null { + const summary: Record = {}; + const allowedKeys = [ + "issueId", + "taskId", + "taskKey", + "commentId", + "wakeCommentId", + "wakeReason", + "wakeSource", + "wakeTriggerDetail", + ] as const; + + for (const key of allowedKeys) { + const value = readNonEmptyString(contextSnapshot?.[key]); + if (value) summary[key] = value; + } + + return Object.keys(summary).length > 0 ? summary : null; +} + +export function summarizeHeartbeatRunListResultJson(input: { + summary?: string | null; + result?: string | null; + message?: string | null; + error?: string | null; + totalCostUsd?: string | null; + costUsd?: string | null; + costUsdCamel?: string | null; +}): Record | null { + const summary: Record = {}; + for (const [key, value] of [ + ["summary", input.summary], + ["result", input.result], + ["message", input.message], + ["error", input.error], + ] as const) { + const normalized = readNonEmptyString(value); + if (normalized) summary[key] = normalized; + } + + for (const [key, value] of [ + ["total_cost_usd", input.totalCostUsd], + ["cost_usd", input.costUsd], + ["costUsd", input.costUsdCamel], + ] as const) { + const normalized = readNonEmptyString(value); + if (!normalized) continue; + const parsed = Number(normalized); + if (Number.isFinite(parsed)) summary[key] = parsed; + } + + return Object.keys(summary).length > 0 ? summary : null; +} + +function summarizeRunFailureForIssueComment( + run: Pick | null | undefined, +) { + if (!run) return null; + + const errorCode = readNonEmptyString(run.errorCode)?.trim() ?? null; + const rawError = readNonEmptyString(run.error)?.trim() ?? null; + const apiMessageMatch = rawError?.match(/"message"\s*:\s*"([^"]+)"/); + const firstLine = rawError + ?.split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? null; + const summarySource = apiMessageMatch?.[1] ?? firstLine; + const summary = + summarySource && summarySource.length > 240 + ? `${summarySource.slice(0, 237)}...` + : summarySource; + + if (errorCode && summary) return ` Latest retry failure: \`${errorCode}\` - ${summary}.`; + if (errorCode) return ` Latest retry failure: \`${errorCode}\`.`; + if (summary) return ` Latest retry failure: ${summary}.`; + return null; +} + +function didAutomaticRecoveryFail( + latestRun: Pick | null, + expectedRetryReason: "assignment_recovery" | "issue_continuation_needed", +) { + if (!latestRun) return false; + + const latestContext = parseObject(latestRun.contextSnapshot); + const latestRetryReason = readNonEmptyString(latestContext.retryReason); + return ( + latestRetryReason === expectedRetryReason && + UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES.includes( + latestRun.status as (typeof UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES)[number], + ) + ); +} + function normalizeLedgerBillingType(value: unknown): BillingType { const raw = readNonEmptyString(value); switch (raw) { @@ -767,6 +1187,45 @@ function describeSessionResetReason( return null; } +function shouldAutoCheckoutIssueForWake(input: { + contextSnapshot: Record | null | undefined; + issueStatus: string | null; + issueAssigneeAgentId: string | null; + agentId: string; +}) { + if (input.issueAssigneeAgentId !== input.agentId) return false; + + const issueStatus = readNonEmptyString(input.issueStatus); + if ( + issueStatus !== "todo" && + issueStatus !== "backlog" && + issueStatus !== "blocked" && + issueStatus !== "in_progress" + ) { + return false; + } + + const wakeReason = readNonEmptyString(input.contextSnapshot?.wakeReason); + if (!wakeReason) return false; + if (wakeReason === "issue_comment_mentioned") return false; + if (wakeReason.startsWith("execution_")) return false; + + return true; +} + +function shouldQueueFollowupForRunningIssueWake(input: { + contextSnapshot: Record | null | undefined; + wakeCommentId: string | null; +}) { + if (input.wakeCommentId) return true; + const wakeReason = readNonEmptyString(input.contextSnapshot?.wakeReason); + return Boolean(wakeReason && RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP.has(wakeReason)); +} + +function isCheckoutConflictError(error: unknown): boolean { + return error instanceof HttpError && error.status === 409 && error.message === "Issue checkout conflict"; +} + function deriveCommentId( contextSnapshot: Record | null | undefined, payload: Record | null | undefined, @@ -907,6 +1366,14 @@ async function buildPaperclipWakePayload(input: { db: Db; companyId: string; contextSnapshot: Record; + continuationSummary?: + | { + key: string; + title: string | null; + body: string; + updatedAt: Date; + } + | null; issueSummary?: | { id: string; @@ -920,6 +1387,7 @@ async function buildPaperclipWakePayload(input: { const executionStage = parseObject(input.contextSnapshot.executionStage); const commentIds = extractWakeCommentIds(input.contextSnapshot); const issueId = readNonEmptyString(input.contextSnapshot.issueId); + const continuationSummary = input.continuationSummary ?? null; const issueSummary = input.issueSummary ?? (issueId @@ -1012,7 +1480,37 @@ async function buildPaperclipWakePayload(input: { priority: issueSummary.priority, } : null, + childIssueSummaries: Array.isArray(input.contextSnapshot.childIssueSummaries) + ? input.contextSnapshot.childIssueSummaries + : [], + childIssueSummaryTruncated: input.contextSnapshot.childIssueSummaryTruncated === true, + livenessContinuation: readNonEmptyString(input.contextSnapshot.livenessContinuationState) || + readNonEmptyString(input.contextSnapshot.livenessContinuationInstruction) || + readNonEmptyString(input.contextSnapshot.livenessContinuationSourceRunId) || + typeof input.contextSnapshot.livenessContinuationAttempt === "number" + ? { + attempt: input.contextSnapshot.livenessContinuationAttempt, + maxAttempts: input.contextSnapshot.livenessContinuationMaxAttempts, + sourceRunId: readNonEmptyString(input.contextSnapshot.livenessContinuationSourceRunId), + state: readNonEmptyString(input.contextSnapshot.livenessContinuationState), + reason: readNonEmptyString(input.contextSnapshot.livenessContinuationReason), + instruction: readNonEmptyString(input.contextSnapshot.livenessContinuationInstruction), + } + : null, + checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true, executionStage: Object.keys(executionStage).length > 0 ? executionStage : null, + continuationSummary: continuationSummary + ? { + key: continuationSummary.key, + title: continuationSummary.title, + body: + continuationSummary.body.length > 4_000 + ? continuationSummary.body.slice(0, 4_000) + : continuationSummary.body, + bodyTruncated: continuationSummary.body.length > 4_000, + updatedAt: continuationSummary.updatedAt.toISOString(), + } + : null, commentIds, latestCommentId: commentIds[commentIds.length - 1] ?? null, comments, @@ -1214,6 +1712,26 @@ export function heartbeatService( cancelWorkForScope: cancelBudgetScopeWork, }; const budgets = budgetService(db, budgetHooks); + let unsafeTextProjectionPromise: Promise | null = null; + + async function hasUnsafeTextProjectionDatabase() { + if (!unsafeTextProjectionPromise) { + unsafeTextProjectionPromise = db + .execute(sql`select current_setting('server_encoding') as server_encoding`) + .then((rows) => { + const first = Array.isArray(rows) ? rows[0] : null; + const serverEncoding = typeof first === "object" && first !== null + ? (first as Record).server_encoding + : null; + return typeof serverEncoding === "string" && serverEncoding.toUpperCase() === "SQL_ASCII"; + }) + .catch((err) => { + logger.warn({ err }, "failed to inspect database server encoding; using conservative heartbeat result projection"); + return true; + }); + } + return unsafeTextProjectionPromise; + } async function getAgent(agentId: string) { return db @@ -1223,14 +1741,50 @@ export function heartbeatService( .then((rows) => rows[0] ?? null); } - async function getRun(runId: string) { + async function getRun(runId: string, opts?: { unsafeFullResultJson?: boolean }) { + const safeForLegacyEncoding = !opts?.unsafeFullResultJson && await hasUnsafeTextProjectionDatabase(); return db - .select() + .select( + opts?.unsafeFullResultJson + ? getTableColumns(heartbeatRuns) + : safeForLegacyEncoding + ? heartbeatRunSqlAsciiSafeColumns + : heartbeatRunSafeColumns, + ) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, runId)) .then((rows) => rows[0] ?? null); } + async function getRunLogAccess(runId: string) { + return db + .select(heartbeatRunLogAccessColumns) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null); + } + + async function getIssueExecutionContext(companyId: string, issueId: string) { + return db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + projectId: issues.projectId, + projectWorkspaceId: issues.projectWorkspaceId, + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspacePreference: issues.executionWorkspacePreference, + assigneeAgentId: issues.assigneeAgentId, + assigneeAdapterOverrides: issues.assigneeAdapterOverrides, + executionWorkspaceSettings: issues.executionWorkspaceSettings, + }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + } + async function getRuntimeState(agentId: string) { return db .select() @@ -1272,7 +1826,10 @@ export function heartbeatService( conditions.push(sql`${heartbeatRuns.id} <> ${opts.excludeRunId}`); } return db - .select() + .select({ + id: heartbeatRuns.id, + usageJson: heartbeatRuns.usageJson, + }) .from(heartbeatRuns) .where(and(...conditions)) .orderBy(desc(heartbeatRuns.createdAt)) @@ -1321,6 +1878,7 @@ export function heartbeatService( agent: typeof agents.$inferSelect; sessionId: string | null; issueId: string | null; + continuationSummaryBody?: string | null; }): Promise { const { agent, sessionId, issueId } = input; if (!sessionId) { @@ -1348,8 +1906,8 @@ export function heartbeatService( id: heartbeatRuns.id, createdAt: heartbeatRuns.createdAt, usageJson: heartbeatRuns.usageJson, - resultJson: heartbeatRuns.resultJson, error: heartbeatRuns.error, + ...heartbeatRunListResultColumns, }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.agentId, agent.id), eq(heartbeatRuns.sessionIdAfter, sessionId))) @@ -1403,7 +1961,15 @@ export function heartbeatService( }; } - const latestSummary = summarizeHeartbeatRunResultJson(latestRun.resultJson); + const latestSummary = summarizeHeartbeatRunListResultJson({ + summary: latestRun?.resultSummary, + result: latestRun?.resultResult, + message: latestRun?.resultMessage, + error: latestRun?.resultError, + totalCostUsd: latestRun?.resultTotalCostUsd, + costUsd: latestRun?.resultCostUsd, + costUsdCamel: latestRun?.resultCostUsdCamel, + }); const latestTextSummary = readNonEmptyString(latestSummary?.summary) ?? readNonEmptyString(latestSummary?.result) ?? @@ -1416,6 +1982,9 @@ export function heartbeatService( issueId ? `- Issue: ${issueId}` : "", `- Rotation reason: ${reason}`, latestTextSummary ? `- Last run summary: ${latestTextSummary}` : "", + input.continuationSummaryBody + ? `- Issue continuation summary: ${input.continuationSummaryBody.slice(0, 1_500)}` + : "", "Continue from the current task state. Rebuild only the minimum context you need.", ] .filter(Boolean) @@ -1840,6 +2409,136 @@ export function heartbeatService( .where(eq(agentWakeupRequests.id, wakeupRequestId)); } + async function addContinuationExhaustedCommentOnce(input: { + run: typeof heartbeatRuns.$inferSelect; + issueId: string; + comment: string; + }) { + const existing = await db + .select({ id: issueComments.id }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, input.run.companyId), + eq(issueComments.issueId, input.issueId), + eq(issueComments.createdByRunId, input.run.id), + sql`${issueComments.body} like 'Bounded liveness continuation exhausted%'`, + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + if (existing) return; + await issuesSvc.addComment(input.issueId, input.comment, { + agentId: input.run.agentId, + runId: input.run.id, + }); + } + + async function handleRunLivenessContinuation(run: typeof heartbeatRuns.$inferSelect) { + const livenessState = run.livenessState as RunLivenessState | null; + if (livenessState !== "plan_only" && livenessState !== "empty_response") return; + + const context = parseObject(run.contextSnapshot); + const issueId = readNonEmptyString(context.issueId); + if (!issueId) return; + + const [issue, agent] = await Promise.all([ + db + .select({ + id: issues.id, + companyId: issues.companyId, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + executionState: issues.executionState, + projectId: issues.projectId, + }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))) + .then((rows) => rows[0] ?? null), + db + .select({ + id: agents.id, + companyId: agents.companyId, + status: agents.status, + }) + .from(agents) + .where(eq(agents.id, run.agentId)) + .then((rows) => rows[0] ?? null), + ]); + + const budgetBlock = + issue && agent + ? await budgets.getInvocationBlock(issue.companyId, agent.id, { + issueId: issue.id, + projectId: issue.projectId, + }) + : null; + + const nextAttempt = readContinuationAttempt(run.continuationAttempt) + 1; + const idempotencyKey = issue + ? buildRunLivenessContinuationIdempotencyKey({ + issueId: issue.id, + sourceRunId: run.id, + livenessState, + nextAttempt, + }) + : null; + const existingWake = idempotencyKey + ? await findExistingRunLivenessContinuationWake(db, { + companyId: run.companyId, + idempotencyKey, + }) + : null; + + const decision = decideRunLivenessContinuation({ + run, + issue, + agent, + livenessState, + livenessReason: run.livenessReason, + nextAction: run.nextAction, + budgetBlocked: Boolean(budgetBlock), + idempotentWakeExists: Boolean(existingWake), + }); + + if (decision.kind === "exhausted") { + await setRunStatus(run.id, run.status, { + livenessReason: `${run.livenessReason ?? "Run ended without concrete progress"}; continuation attempts exhausted`, + }); + await addContinuationExhaustedCommentOnce({ + run, + issueId, + comment: decision.comment, + }); + return; + } + + if (decision.kind !== "enqueue") return; + + const continuationRun = await enqueueWakeup(run.agentId, { + source: "automation", + triggerDetail: "system", + reason: RUN_LIVENESS_CONTINUATION_REASON, + payload: decision.payload, + contextSnapshot: decision.contextSnapshot, + idempotencyKey: decision.idempotencyKey, + requestedByActorType: "system", + requestedByActorId: "heartbeat", + }); + + if (continuationRun) { + await db + .update(heartbeatRuns) + .set({ + continuationAttempt: decision.nextAttempt, + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, continuationRun.id)); + } + } + async function appendRunEvent( run: typeof heartbeatRuns.$inferSelect, seq: number, @@ -1856,9 +2555,12 @@ export function heartbeatService( const sanitizedMessage = event.message ? redactCurrentUserText(event.message, currentUserRedactionOptions) : event.message; - const sanitizedPayload = event.payload - ? redactCurrentUserValue(event.payload, currentUserRedactionOptions) + const boundedPayload = event.payload + ? boundHeartbeatRunEventPayloadForStorage(event.payload) : event.payload; + const sanitizedPayload = boundedPayload + ? redactCurrentUserValue(boundedPayload, currentUserRedactionOptions) + : boundedPayload; await db.insert(heartbeatRunEvents).values({ companyId: run.companyId, @@ -1968,6 +2670,47 @@ export function heartbeatService( .then((rows) => rows[0] ?? null); } + async function refreshContinuationSummaryForRun( + run: typeof heartbeatRuns.$inferSelect, + agent: typeof agents.$inferSelect, + ) { + const contextSnapshot = parseObject(run.contextSnapshot); + const issueId = readNonEmptyString(contextSnapshot.issueId); + if (!issueId) return null; + try { + return await refreshIssueContinuationSummary({ + db, + issueId, + run: { + id: run.id, + status: run.status, + error: run.error, + errorCode: run.errorCode, + resultJson: run.resultJson as Record | null, + stdoutExcerpt: run.stdoutExcerpt, + stderrExcerpt: run.stderrExcerpt, + finishedAt: run.finishedAt, + }, + agent: { + id: agent.id, + name: agent.name, + adapterType: agent.adapterType, + }, + }); + } catch (err) { + logger.warn( + { + err, + runId: run.id, + issueId, + agentId: agent.id, + }, + "failed to refresh issue continuation summary", + ); + return null; + } + } + async function enqueueMissingIssueCommentRetry( run: typeof heartbeatRuns.$inferSelect, agent: typeof agents.$inferSelect, @@ -2388,7 +3131,7 @@ export function heartbeatService( if (isFirstHeartbeat && updated) { const tc = getTelemetryClient(); - if (tc) trackAgentFirstHeartbeat(tc, { agentRole: updated.role }); + if (tc) trackAgentFirstHeartbeat(tc, { agentRole: updated.role, agentId: updated.id }); } if (updated) { @@ -2407,6 +3150,194 @@ export function heartbeatService( } } + function mergeRunStopMetadataForAgent( + agent: Pick, + outcome: "succeeded" | "failed" | "cancelled" | "timed_out", + options?: { + resultJson?: Record | null; + errorCode?: string | null; + errorMessage?: string | null; + }, + ) { + const stopMetadata = buildHeartbeatRunStopMetadata({ + adapterType: agent.adapterType, + adapterConfig: parseObject(agent.adapterConfig), + outcome, + errorCode: options?.errorCode ?? null, + errorMessage: options?.errorMessage ?? null, + }); + return mergeHeartbeatRunStopMetadata(options?.resultJson ?? null, stopMetadata); + } + + function countValue(value: unknown) { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : 0; + } + + function dateValue(value: unknown) { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; + if (typeof value === "string" || typeof value === "number") { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + return null; + } + + function latestDate(...values: unknown[]) { + let latest: Date | null = null; + for (const value of values) { + const parsed = dateValue(value); + if (!parsed) continue; + if (!latest || parsed.getTime() > latest.getTime()) latest = parsed; + } + return latest; + } + + async function buildRunLivenessInput( + run: typeof heartbeatRuns.$inferSelect, + resultJson: Record | null | undefined, + ): Promise { + const context = parseObject(run.contextSnapshot); + const contextIssueId = readNonEmptyString(context.issueId); + const continuationAttempt = asNumber(context.continuationAttempt, run.continuationAttempt ?? 0); + + const issue = contextIssueId + ? await db + .select({ + status: issues.status, + title: issues.title, + description: issues.description, + }) + .from(issues) + .where(and(eq(issues.companyId, run.companyId), eq(issues.id, contextIssueId))) + .then((rows) => rows[0] ?? null) + : null; + + const [commentStats] = contextIssueId + ? await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${issueComments.createdAt})`, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, run.companyId), + eq(issueComments.issueId, contextIssueId), + eq(issueComments.createdByRunId, run.id), + ), + ) + : [{ count: 0, latestAt: null }]; + + const [documentStats] = contextIssueId + ? await db + .select({ + count: sql`count(*)::int`, + planCount: sql`count(*) filter (where ${issueDocuments.key} = 'plan')::int`, + latestAt: sql`max(${documentRevisions.createdAt})`, + }) + .from(documentRevisions) + .innerJoin(issueDocuments, eq(documentRevisions.documentId, issueDocuments.documentId)) + .where( + and( + eq(documentRevisions.companyId, run.companyId), + eq(documentRevisions.createdByRunId, run.id), + eq(issueDocuments.companyId, run.companyId), + eq(issueDocuments.issueId, contextIssueId), + sql`${issueDocuments.key} != ${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`, + ), + ) + : [{ count: 0, planCount: 0, latestAt: null }]; + + const [workProductStats] = contextIssueId + ? await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${issueWorkProducts.createdAt})`, + }) + .from(issueWorkProducts) + .where( + and( + eq(issueWorkProducts.companyId, run.companyId), + eq(issueWorkProducts.issueId, contextIssueId), + eq(issueWorkProducts.createdByRunId, run.id), + ), + ) + : [{ count: 0, latestAt: null }]; + + const [workspaceOperationStats] = await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${workspaceOperations.startedAt})`, + }) + .from(workspaceOperations) + .where(and(eq(workspaceOperations.companyId, run.companyId), eq(workspaceOperations.heartbeatRunId, run.id))); + + const [activityStats] = await db + .select({ + count: sql`count(*)::int`, + latestAt: sql`max(${activityLog.createdAt})`, + }) + .from(activityLog) + .where(and(eq(activityLog.companyId, run.companyId), eq(activityLog.runId, run.id))); + + const [eventStats] = await db + .select({ + count: sql`count(*) filter (where ${heartbeatRunEvents.eventType} not in ('lifecycle', 'adapter.invoke', 'error'))::int`, + latestAt: sql`max(${heartbeatRunEvents.createdAt}) filter (where ${heartbeatRunEvents.eventType} not in ('lifecycle', 'adapter.invoke', 'error'))`, + }) + .from(heartbeatRunEvents) + .where(and(eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id))); + + return { + runStatus: run.status, + issue, + resultJson: resultJson ?? run.resultJson ?? null, + stdoutExcerpt: run.stdoutExcerpt ?? null, + stderrExcerpt: run.stderrExcerpt ?? null, + error: run.error ?? null, + errorCode: run.errorCode ?? null, + continuationAttempt, + evidence: { + issueCommentsCreated: countValue(commentStats?.count), + documentRevisionsCreated: countValue(documentStats?.count), + planDocumentRevisionsCreated: countValue(documentStats?.planCount), + workProductsCreated: countValue(workProductStats?.count), + workspaceOperationsCreated: countValue(workspaceOperationStats?.count), + activityEventsCreated: countValue(activityStats?.count), + toolOrActionEventsCreated: countValue(eventStats?.count), + latestEvidenceAt: latestDate( + commentStats?.latestAt, + documentStats?.latestAt, + workProductStats?.latestAt, + workspaceOperationStats?.latestAt, + activityStats?.latestAt, + eventStats?.latestAt, + ), + }, + }; + } + + async function classifyAndPersistRunLiveness( + run: typeof heartbeatRuns.$inferSelect, + resultJson?: Record | null, + ) { + const classification = classifyRunLiveness(await buildRunLivenessInput(run, resultJson)); + return db + .update(heartbeatRuns) + .set({ + livenessState: classification.livenessState, + livenessReason: classification.livenessReason, + continuationAttempt: classification.continuationAttempt, + lastUsefulActionAt: classification.lastUsefulActionAt, + nextAction: classification.nextAction, + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, run.id)) + .returning() + .then((rows) => rows[0] ?? null); + } + async function reapOrphanedRuns(opts?: { staleThresholdMs?: number }) { const staleThresholdMs = opts?.staleThresholdMs ?? 0; const now = new Date(); @@ -2416,6 +3347,7 @@ export function heartbeatService( .select({ run: heartbeatRuns, adapterType: agents.adapterType, + adapterConfig: agents.adapterConfig, }) .from(heartbeatRuns) .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id)) @@ -2423,7 +3355,7 @@ export function heartbeatService( const reaped: string[] = []; - for (const { run, adapterType } of activeRuns) { + for (const { run, adapterType, adapterConfig } of activeRuns) { if (runningProcesses.has(run.id) || activeRunExecutions.has(run.id)) continue; // Apply staleness threshold to avoid false positives @@ -2473,6 +3405,15 @@ export function heartbeatService( error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage, errorCode: "process_lost", finishedAt: now, + resultJson: mergeRunStopMetadataForAgent( + { adapterType, adapterConfig }, + "failed", + { + resultJson: parseObject(run.resultJson), + errorCode: "process_lost", + errorMessage: shouldRetry ? `${baseMessage}; retrying once` : baseMessage, + }, + ), }); await setWakeupStatus(run.wakeupRequestId, "failed", { finishedAt: now, @@ -2480,6 +3421,7 @@ export function heartbeatService( }); if (!finalizedRun) finalizedRun = await getRun(run.id); if (!finalizedRun) continue; + finalizedRun = await classifyAndPersistRunLiveness(finalizedRun, parseObject(finalizedRun.resultJson)) ?? finalizedRun; let retriedRun: typeof heartbeatRuns.$inferSelect | null = null; if (shouldRetry) { @@ -2530,6 +3472,270 @@ export function heartbeatService( } } + async function getLatestIssueRun(companyId: string, issueId: string) { + return db + .select({ + id: heartbeatRuns.id, + status: heartbeatRuns.status, + error: heartbeatRuns.error, + errorCode: heartbeatRuns.errorCode, + contextSnapshot: heartbeatRuns.contextSnapshot, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, companyId), + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, + ), + ) + .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + } + + async function hasActiveExecutionPath(companyId: string, issueId: string) { + const [run, deferredWake] = await Promise.all([ + db + .select({ id: heartbeatRuns.id }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, companyId), + inArray(heartbeatRuns.status, [...ACTIVE_HEARTBEAT_RUN_STATUSES]), + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null), + db + .select({ id: agentWakeupRequests.id }) + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`, + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null), + ]); + + return Boolean(run || deferredWake); + } + + async function enqueueStrandedIssueRecovery(input: { + issueId: string; + agentId: string; + reason: "issue_assignment_recovery" | "issue_continuation_needed"; + retryReason: "assignment_recovery" | "issue_continuation_needed"; + source: string; + retryOfRunId?: string | null; + }) { + const queued = await enqueueWakeup(input.agentId, { + source: "automation", + triggerDetail: "system", + reason: input.reason, + payload: { + issueId: input.issueId, + ...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}), + }, + requestedByActorType: "system", + requestedByActorId: null, + contextSnapshot: { + issueId: input.issueId, + taskId: input.issueId, + wakeReason: input.reason, + retryReason: input.retryReason, + source: input.source, + ...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}), + }, + }); + + if (queued && input.retryOfRunId) { + return db + .update(heartbeatRuns) + .set({ + retryOfRunId: input.retryOfRunId, + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, queued.id)) + .returning() + .then((rows) => rows[0] ?? queued); + } + + return queued; + } + + async function escalateStrandedAssignedIssue(input: { + issue: typeof issues.$inferSelect; + previousStatus: "todo" | "in_progress"; + latestRun: Pick< + typeof heartbeatRuns.$inferSelect, + "id" | "status" | "error" | "errorCode" | "contextSnapshot" + > | null; + comment: string; + }) { + const updated = await issuesSvc.update(input.issue.id, { + status: "blocked", + }); + if (!updated) return null; + + await issuesSvc.addComment(input.issue.id, input.comment, {}); + + await logActivity(db, { + companyId: input.issue.companyId, + actorType: "system", + actorId: "system", + agentId: null, + runId: null, + action: "issue.updated", + entityType: "issue", + entityId: input.issue.id, + details: { + identifier: input.issue.identifier, + status: "blocked", + previousStatus: input.previousStatus, + source: "heartbeat.reconcile_stranded_assigned_issue", + latestRunId: input.latestRun?.id ?? null, + latestRunStatus: input.latestRun?.status ?? null, + latestRunErrorCode: input.latestRun?.errorCode ?? null, + }, + }); + + return updated; + } + + async function reconcileStrandedAssignedIssues() { + const candidates = await db + .select() + .from(issues) + .where( + and( + isNull(issues.assigneeUserId), + inArray(issues.status, ["todo", "in_progress"]), + sql`${issues.assigneeAgentId} is not null`, + ), + ); + + const result = { + dispatchRequeued: 0, + continuationRequeued: 0, + escalated: 0, + skipped: 0, + issueIds: [] as string[], + }; + + for (const issue of candidates) { + const agentId = issue.assigneeAgentId; + if (!agentId) { + result.skipped += 1; + continue; + } + + const agent = await getAgent(agentId); + if (!agent || agent.companyId !== issue.companyId) { + result.skipped += 1; + continue; + } + if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") { + result.skipped += 1; + continue; + } + + if (await hasActiveExecutionPath(issue.companyId, issue.id)) { + result.skipped += 1; + continue; + } + + const latestRun = await getLatestIssueRun(issue.companyId, issue.id); + if (issue.status === "todo") { + if (!latestRun || latestRun.status === "succeeded") { + result.skipped += 1; + continue; + } + + if (didAutomaticRecoveryFail(latestRun, "assignment_recovery")) { + const failureSummary = summarizeRunFailureForIssueComment(latestRun); + const updated = await escalateStrandedAssignedIssue({ + issue, + previousStatus: "todo", + latestRun, + comment: + "Paperclip automatically retried dispatch for this assigned `todo` issue after a lost wake/run, " + + `but it still has no live execution path.${failureSummary ?? ""} ` + + "Moving it to `blocked` so it is visible for intervention.", + }); + if (updated) { + result.escalated += 1; + result.issueIds.push(issue.id); + } else { + result.skipped += 1; + } + continue; + } + + const queued = await enqueueStrandedIssueRecovery({ + issueId: issue.id, + agentId, + reason: "issue_assignment_recovery", + retryReason: "assignment_recovery", + source: "issue.assignment_recovery", + retryOfRunId: latestRun.id, + }); + if (queued) { + result.dispatchRequeued += 1; + result.issueIds.push(issue.id); + } else { + result.skipped += 1; + } + continue; + } + + if (!latestRun && !issue.checkoutRunId && !issue.executionRunId) { + result.skipped += 1; + continue; + } + + if (didAutomaticRecoveryFail(latestRun, "issue_continuation_needed")) { + const failureSummary = summarizeRunFailureForIssueComment(latestRun); + const updated = await escalateStrandedAssignedIssue({ + issue, + previousStatus: "in_progress", + latestRun, + comment: + "Paperclip automatically retried continuation for this assigned `in_progress` issue after its live " + + `execution disappeared, but it still has no live execution path.${failureSummary ?? ""} ` + + "Moving it to `blocked` so it is visible for intervention.", + }); + if (updated) { + result.escalated += 1; + result.issueIds.push(issue.id); + } else { + result.skipped += 1; + } + continue; + } + + const queued = await enqueueStrandedIssueRecovery({ + issueId: issue.id, + agentId, + reason: "issue_continuation_needed", + retryReason: "issue_continuation_needed", + source: "issue.continuation_recovery", + retryOfRunId: latestRun?.id ?? issue.checkoutRunId ?? null, + }); + if (queued) { + result.continuationRequeued += 1; + result.issueIds.push(issue.id); + } else { + result.skipped += 1; + } + } + + return result; + } + async function updateRuntimeState( agent: typeof agents.$inferSelect, run: typeof heartbeatRuns.$inferSelect, @@ -2659,26 +3865,26 @@ export function heartbeatService( const taskKey = deriveTaskKeyWithHeartbeatFallback(context, null); const sessionCodec = getAdapterSessionCodec(agent.adapterType); const issueId = readNonEmptyString(context.issueId); - const issueContext = issueId - ? await db - .select({ - id: issues.id, - identifier: issues.identifier, - title: issues.title, - status: issues.status, - priority: issues.priority, - projectId: issues.projectId, - projectWorkspaceId: issues.projectWorkspaceId, - executionWorkspaceId: issues.executionWorkspaceId, - executionWorkspacePreference: issues.executionWorkspacePreference, - assigneeAgentId: issues.assigneeAgentId, - assigneeAdapterOverrides: issues.assigneeAdapterOverrides, - executionWorkspaceSettings: issues.executionWorkspaceSettings, - }) - .from(issues) - .where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) - .then((rows) => rows[0] ?? null) - : null; + let issueContext = issueId ? await getIssueExecutionContext(agent.companyId, issueId) : null; + if ( + issueId && + issueContext && + shouldAutoCheckoutIssueForWake({ + contextSnapshot: context, + issueStatus: issueContext.status, + issueAssigneeAgentId: issueContext.assigneeAgentId, + agentId: agent.id, + }) + ) { + try { + await issuesSvc.checkout(issueId, agent.id, ["todo", "backlog", "blocked"], run.id); + context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = true; + } catch (error) { + if (!isCheckoutConflictError(error)) throw error; + context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = false; + } + issueContext = await getIssueExecutionContext(agent.companyId, issueId); + } const issueAssigneeOverrides = issueContext && issueContext.assigneeAgentId === agent.id ? parseIssueAssigneeAdapterOverrides( @@ -2748,10 +3954,24 @@ export function heartbeatService( executionWorkspacePreference: issueContext.executionWorkspacePreference, } : null; + const continuationSummary = issueRef + ? await getIssueContinuationSummaryDocument(db, issueRef.id) + : null; + if (continuationSummary) { + context.paperclipContinuationSummary = { + key: continuationSummary.key, + title: continuationSummary.title, + body: continuationSummary.body, + updatedAt: continuationSummary.updatedAt.toISOString(), + }; + } else { + delete context.paperclipContinuationSummary; + } const paperclipWakePayload = await buildPaperclipWakePayload({ db, companyId: agent.companyId, contextSnapshot: context, + continuationSummary, issueSummary: issueRef ? { id: issueRef.id, @@ -2807,9 +4027,18 @@ export function heartbeatService( projectEnv: projectContext?.env ?? null, secretsSvc, }); + const runScopedMentionedSkillKeys = await resolveRunScopedMentionedSkillKeys({ + db, + companyId: agent.companyId, + issueId, + }); + const effectiveResolvedConfig = applyRunScopedMentionedSkillKeys( + resolvedConfig, + runScopedMentionedSkillKeys, + ); const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); const runtimeConfig = { - ...resolvedConfig, + ...effectiveResolvedConfig, paperclipRuntimeSkills: runtimeSkillEntries, }; const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({ @@ -3055,6 +4284,7 @@ export function heartbeatService( agent, sessionId: previousSessionDisplayId ?? runtimeSessionIdForAdapter, issueId, + continuationSummaryBody: continuationSummary?.body ?? null, }); if (sessionCompaction.rotate) { context.paperclipSessionHandoffMarkdown = sessionCompaction.handoffMarkdown; @@ -3144,7 +4374,9 @@ export function heartbeatService( const currentUserRedactionOptions = await getCurrentUserRedactionOptions(); const appendLog = async (stream: "stdout" | "stderr" | "system", chunk: string) => { - const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions); + const sanitizedChunk = compactRunLogChunk( + redactCurrentUserText(chunk, currentUserRedactionOptions), + ); if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk); if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk); const ts = new Date().toISOString(); @@ -3186,6 +4418,12 @@ export function heartbeatService( payload: trace as unknown as Record, }); }; + if (runScopedMentionedSkillKeys.length > 0) { + await onLog( + "stdout", + `[paperclip] Enabled run-scoped skills from issue mentions: ${runScopedMentionedSkillKeys.join(", ")}\n`, + ); + } for (const warning of runtimeWorkspaceWarnings) { const logEntry = formatRuntimeWorkspaceWarningLog(warning); await onLog(logEntry.stream, logEntry.chunk); @@ -3206,7 +4444,7 @@ export function heartbeatService( issue: issueRef, workspace: executionWorkspace, executionWorkspaceId: persistedExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null, - config: resolvedConfig, + config: effectiveResolvedConfig, adapterEnv, onLog, }); @@ -3396,6 +4634,23 @@ export function heartbeatService( } else { outcome = "failed"; } + const runErrorMessage = + outcome === "cancelled" + ? (latestRun?.error ?? adapterResult.errorMessage ?? "Cancelled") + : outcome === "succeeded" + ? null + : redactCurrentUserText( + adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"), + currentUserRedactionOptions, + ); + const runErrorCode = + outcome === "timed_out" + ? "timeout" + : outcome === "cancelled" + ? (latestRun?.errorCode ?? "cancelled") + : outcome === "failed" + ? (adapterResult.errorCode ?? "adapter_failed") + : null; let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null; @@ -3435,7 +4690,11 @@ export function heartbeatService( : null; const persistedResultJson = mergeHeartbeatRunResultJson( - adapterResult.resultJson ?? null, + mergeRunStopMetadataForAgent(agent, outcome, { + resultJson: adapterResult.resultJson ?? null, + errorCode: runErrorCode, + errorMessage: runErrorMessage, + }), adapterResult.summary ?? null, ); const memorySummary = @@ -3467,23 +4726,10 @@ export function heartbeatService( logSummary = await runLogStore.finalize(handle); } - await setRunStatus(run.id, status, { + let persistedRun = await setRunStatus(run.id, status, { finishedAt: new Date(), - error: - outcome === "succeeded" - ? null - : redactCurrentUserText( - adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"), - currentUserRedactionOptions, - ), - errorCode: - outcome === "timed_out" - ? "timeout" - : outcome === "cancelled" - ? "cancelled" - : outcome === "failed" - ? (adapterResult.errorCode ?? "adapter_failed") - : null, + error: runErrorMessage, + errorCode: runErrorCode, exitCode: adapterResult.exitCode, signal: adapterResult.signal, usageJson, @@ -3495,13 +4741,16 @@ export function heartbeatService( logSha256: logSummary?.sha256, logCompressed: logSummary?.compressed ?? false, }); + if (persistedRun) { + persistedRun = await classifyAndPersistRunLiveness(persistedRun, persistedResultJson) ?? persistedRun; + } await setWakeupStatus(run.wakeupRequestId, outcome === "succeeded" ? "completed" : status, { finishedAt: new Date(), - error: adapterResult.errorMessage ?? null, + error: runErrorMessage, }); - const finalizedRun = await getRun(run.id); + const finalizedRun = persistedRun ?? (await getRun(run.id)); if (finalizedRun) { await appendRunEvent(finalizedRun, seq++, { eventType: "lifecycle", @@ -3513,11 +4762,16 @@ export function heartbeatService( exitCode: adapterResult.exitCode, }, }); + const livenessRun = finalizedRun; + await refreshContinuationSummaryForRun(livenessRun, agent); if (issueId && outcome === "succeeded") { try { - const issueComment = buildHeartbeatRunIssueComment(persistedResultJson); - if (issueComment) { - await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id }); + const existingRunComment = await findRunIssueComment(livenessRun.id, livenessRun.companyId, issueId); + if (!existingRunComment) { + const issueComment = buildHeartbeatRunIssueComment(persistedResultJson); + if (issueComment) { + await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: livenessRun.id }); + } } } catch (err) { await onLog( @@ -3526,8 +4780,9 @@ export function heartbeatService( ); } } - await finalizeIssueCommentPolicy(finalizedRun, agent); - await releaseIssueExecutionAndPromote(finalizedRun); + await finalizeIssueCommentPolicy(livenessRun, agent); + await releaseIssueExecutionAndPromote(livenessRun); + await handleRunLivenessContinuation(livenessRun); } if (finalizedRun) { @@ -3575,6 +4830,10 @@ export function heartbeatService( error: message, errorCode: "adapter_failed", finishedAt: new Date(), + resultJson: mergeRunStopMetadataForAgent(agent, "failed", { + errorCode: "adapter_failed", + errorMessage: message, + }), stdoutExcerpt, stderrExcerpt, logBytes: logSummary?.bytes, @@ -3593,10 +4852,12 @@ export function heartbeatService( level: "error", message, }); - await finalizeIssueCommentPolicy(failedRun, agent); - await releaseIssueExecutionAndPromote(failedRun); + const livenessRun = await classifyAndPersistRunLiveness(failedRun) ?? failedRun; + await refreshContinuationSummaryForRun(livenessRun, agent); + await finalizeIssueCommentPolicy(livenessRun, agent); + await releaseIssueExecutionAndPromote(livenessRun); - await updateRuntimeState(agent, failedRun, { + await updateRuntimeState(agent, livenessRun, { exitCode: null, signal: null, timedOut: false, @@ -3626,10 +4887,17 @@ export function heartbeatService( // The inner catch did not fire, so we must record the failure here. const message = outerErr instanceof Error ? outerErr.message : "Unknown setup failure"; logger.error({ err: outerErr, runId }, "heartbeat execution setup failed"); + const setupFailureAgent = await getAgent(run.agentId).catch(() => null); await setRunStatus(runId, "failed", { error: message, errorCode: "adapter_failed", finishedAt: new Date(), + ...(setupFailureAgent ? { + resultJson: mergeRunStopMetadataForAgent(setupFailureAgent, "failed", { + errorCode: "adapter_failed", + errorMessage: message, + }), + } : {}), }).catch(() => undefined); await setWakeupStatus(run.wakeupRequestId, "failed", { finishedAt: new Date(), @@ -3645,11 +4913,13 @@ export function heartbeatService( level: "error", message, }).catch(() => undefined); - const failedAgent = await getAgent(run.agentId).catch(() => null); + const livenessRun = await classifyAndPersistRunLiveness(failedRun).catch(() => failedRun); + const failedAgent = setupFailureAgent ?? await getAgent(run.agentId).catch(() => null); if (failedAgent) { - await finalizeIssueCommentPolicy(failedRun, failedAgent).catch(() => undefined); + await refreshContinuationSummaryForRun(livenessRun, failedAgent).catch(() => undefined); + await finalizeIssueCommentPolicy(livenessRun, failedAgent).catch(() => undefined); } - await releaseIssueExecutionAndPromote(failedRun).catch(() => undefined); + await releaseIssueExecutionAndPromote(livenessRun).catch(() => undefined); } // Ensure the agent is not left stuck in "running" if the inner catch handler's // DB calls threw (e.g. a transient DB error in finalizeAgentStatus). @@ -3662,31 +4932,50 @@ export function heartbeatService( } async function releaseIssueExecutionAndPromote(run: typeof heartbeatRuns.$inferSelect) { - const promotedRun = await db.transaction(async (tx) => { - await tx.execute( - sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`, - ); + const runContext = parseObject(run.contextSnapshot); + const contextIssueId = readNonEmptyString(runContext.issueId); + const promotionResult = await db.transaction(async (tx) => { + if (contextIssueId) { + await tx.execute( + sql`select id from issues where company_id = ${run.companyId} and id = ${contextIssueId} for update`, + ); + } else { + await tx.execute( + sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`, + ); + } - const issue = await tx + let issue = await tx .select({ id: issues.id, companyId: issues.companyId, + identifier: issues.identifier, + status: issues.status, + executionRunId: issues.executionRunId, }) .from(issues) - .where(and(eq(issues.companyId, run.companyId), eq(issues.executionRunId, run.id))) + .where( + and( + eq(issues.companyId, run.companyId), + contextIssueId ? eq(issues.id, contextIssueId) : eq(issues.executionRunId, run.id), + ), + ) .then((rows) => rows[0] ?? null); - if (!issue) return; + if (!issue) return null; + if (issue.executionRunId && issue.executionRunId !== run.id) return null; - await tx - .update(issues) - .set({ - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - updatedAt: new Date(), - }) - .where(eq(issues.id, issue.id)); + if (issue.executionRunId === run.id) { + await tx + .update(issues) + .set({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: new Date(), + }) + .where(eq(issues.id, issue.id)); + } while (true) { const deferred = await tx @@ -3733,6 +5022,51 @@ export function heartbeatService( const deferredPayload = parseObject(deferred.payload); const deferredContextSeed = parseObject(deferredPayload[DEFERRED_WAKE_CONTEXT_KEY]); const promotedContextSeed: Record = { ...deferredContextSeed }; + const deferredCommentIds = extractWakeCommentIds(deferredContextSeed); + const shouldReopenDeferredCommentWake = + deferredCommentIds.length > 0 && (issue.status === "done" || issue.status === "cancelled"); + let reopenedActivity: LogActivityInput | null = null; + + if (shouldReopenDeferredCommentWake) { + const reopenedFromStatus = issue.status; + const reopenedIssue = await issuesSvc.update( + issue.id, + { + status: "todo", + executionState: null, + }, + tx, + ); + if (reopenedIssue) { + issue = { + ...issue, + identifier: reopenedIssue.identifier, + status: reopenedIssue.status, + executionRunId: reopenedIssue.executionRunId, + }; + if (!readNonEmptyString(promotedContextSeed.reopenedFrom)) { + promotedContextSeed.reopenedFrom = reopenedFromStatus; + } + reopenedActivity = { + companyId: issue.companyId, + actorType: "system", + actorId: "heartbeat", + agentId: deferred.agentId, + runId: run.id, + action: "issue.updated", + entityType: "issue", + entityId: issue.id, + details: { + status: "todo", + reopened: true, + reopenedFrom: reopenedFromStatus, + source: "deferred_comment_wake", + identifier: issue.identifier, + }, + }; + } + } + const promotedReason = readNonEmptyString(deferred.reason) ?? "issue_execution_promoted"; const promotedSource = (readNonEmptyString(deferred.source) as WakeupOptions["source"]) ?? "automation"; @@ -3755,6 +5089,9 @@ export function heartbeatService( const sessionBefore = readNonEmptyString(promotedContextSnapshot.resumeSessionDisplayId) ?? await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey); + const promotedContinuationAttempt = readContinuationAttempt( + promotedContextSnapshot.livenessContinuationAttempt, + ); const now = new Date(); const newRun = await tx .insert(heartbeatRuns) @@ -3767,6 +5104,7 @@ export function heartbeatService( wakeupRequestId: deferred.id, contextSnapshot: promotedContextSnapshot, sessionIdBefore: sessionBefore, + continuationAttempt: promotedContinuationAttempt, }) .returning() .then((rows) => rows[0]); @@ -3794,12 +5132,20 @@ export function heartbeatService( }) .where(eq(issues.id, issue.id)); - return newRun; + return { + run: newRun, + reopenedActivity, + }; } }); + const promotedRun = promotionResult?.run ?? null; if (!promotedRun) return; + if (promotionResult?.reopenedActivity) { + await logActivity(db, promotionResult.reopenedActivity); + } + publishLiveEvent({ companyId: promotedRun.companyId, type: "heartbeat.run.queued", @@ -3857,6 +5203,7 @@ export function heartbeatService( const sessionBefore = explicitResumeSession?.sessionDisplayId ?? await resolveSessionBeforeForWakeup(agent, effectiveTaskKey); + const continuationAttempt = readContinuationAttempt(enrichedContextSnapshot.livenessContinuationAttempt); const writeSkippedRequest = async (skipReason: string) => { await db.insert(agentWakeupRequests).values({ @@ -3914,11 +5261,10 @@ export function heartbeatService( return null; } - const bypassIssueExecutionLock = - reason === "issue_comment_mentioned" || - readNonEmptyString(enrichedContextSnapshot.wakeReason) === "issue_comment_mentioned"; - - if (issueId && !bypassIssueExecutionLock) { + if (issueId) { + // Mention-triggered wakes can request input from another agent, but they must + // still respect the issue execution lock so a second agent cannot start on the + // same issue workspace while the assignee already has a live run. const agentNameKey = normalizeAgentNameKey(agent.name); const outcome = await db.transaction(async (tx) => { @@ -4026,12 +5372,12 @@ export function heartbeatService( normalizeAgentNameKey(executionAgent?.name); const isSameExecutionAgent = Boolean(executionAgentNameKey) && executionAgentNameKey === agentNameKey; - const shouldQueueFollowupForCommentWake = - Boolean(wakeCommentId) && + const shouldQueueFollowupForRunningWake = + shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId }) && activeExecutionRun.status === "running" && isSameExecutionAgent; - if (isSameExecutionAgent && !shouldQueueFollowupForCommentWake) { + if (isSameExecutionAgent && !shouldQueueFollowupForRunningWake) { const mergedContextSnapshot = mergeCoalescedContextSnapshot( activeExecutionRun.contextSnapshot, enrichedContextSnapshot, @@ -4156,6 +5502,7 @@ export function heartbeatService( wakeupRequestId: wakeupRequest.id, contextSnapshot: enrichedContextSnapshot, sessionIdBefore: sessionBefore, + continuationAttempt, }) .returning() .then((rows) => rows[0]); @@ -4207,12 +5554,14 @@ export function heartbeatService( const sameScopeRunningRun = activeRuns.find( (candidate) => candidate.status === "running" && isSameTaskScope(runTaskKey(candidate), taskKey), ); - const shouldQueueFollowupForCommentWake = - Boolean(wakeCommentId) && Boolean(sameScopeRunningRun) && !sameScopeQueuedRun; + const shouldQueueFollowupForRunningWake = + Boolean(sameScopeRunningRun) && + !sameScopeQueuedRun && + shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId }); const coalescedTargetRun = sameScopeQueuedRun ?? - (shouldQueueFollowupForCommentWake ? null : sameScopeRunningRun ?? null); + (shouldQueueFollowupForRunningWake ? null : sameScopeRunningRun ?? null); if (coalescedTargetRun) { const mergedContextSnapshot = mergeCoalescedContextSnapshot( @@ -4275,6 +5624,7 @@ export function heartbeatService( wakeupRequestId: wakeupRequest.id, contextSnapshot: enrichedContextSnapshot, sessionIdBefore: sessionBefore, + continuationAttempt, }) .returning() .then((rows) => rows[0]); @@ -4407,6 +5757,7 @@ export function heartbeatService( const run = await getRun(runId); if (!run) throw notFound("Heartbeat run not found"); if (run.status !== "running" && run.status !== "queued") return run; + const agent = await getAgent(run.agentId); const running = runningProcesses.get(run.id); if (running) { @@ -4426,6 +5777,13 @@ export function heartbeatService( finishedAt: new Date(), error: reason, errorCode: "cancelled", + ...(agent ? { + resultJson: mergeRunStopMetadataForAgent(agent, "cancelled", { + resultJson: parseObject(run.resultJson), + errorCode: "cancelled", + errorMessage: reason, + }), + } : {}), }); await setWakeupStatus(run.wakeupRequestId, "cancelled", { @@ -4450,6 +5808,7 @@ export function heartbeatService( } async function cancelActiveForAgentInternal(agentId: string, reason = "Cancelled due to agent pause") { + const agent = await getAgent(agentId); const runs = await db .select() .from(heartbeatRuns) @@ -4460,6 +5819,13 @@ export function heartbeatService( finishedAt: new Date(), error: reason, errorCode: "cancelled", + ...(agent ? { + resultJson: mergeRunStopMetadataForAgent(agent, "cancelled", { + resultJson: parseObject(run.resultJson), + errorCode: "cancelled", + errorMessage: reason, + }), + } : {}), }); await setWakeupStatus(run.wakeupRequestId, "cancelled", { @@ -4517,8 +5883,21 @@ export function heartbeatService( return { list: async (companyId: string, agentId?: string, limit?: number) => { + const safeForLegacyEncoding = await hasUnsafeTextProjectionDatabase(); const query = db - .select(heartbeatRunListColumns) + .select( + safeForLegacyEncoding + ? { + ...heartbeatRunListColumns, + error: sql`NULL`.as("error"), + ...heartbeatRunListContextColumns, + } + : { + ...heartbeatRunListColumns, + ...heartbeatRunListContextColumns, + ...heartbeatRunListResultColumns, + }, + ) .from(heartbeatRuns) .where( agentId @@ -4528,14 +5907,65 @@ export function heartbeatService( .orderBy(desc(heartbeatRuns.createdAt)); const rows = limit ? await query.limit(limit) : await query; - return rows.map((row) => ({ - ...row, - resultJson: summarizeHeartbeatRunResultJson(row.resultJson), - })); + return rows.map((row) => { + const { + contextIssueId, + contextTaskId, + contextTaskKey, + contextCommentId, + contextWakeCommentId, + contextWakeReason, + contextWakeSource, + contextWakeTriggerDetail, + resultSummary, + resultResult, + resultMessage, + resultError, + resultTotalCostUsd, + resultCostUsd, + resultCostUsdCamel, + ...rest + } = row as typeof row & { + resultSummary?: string | null; + resultResult?: string | null; + resultMessage?: string | null; + resultError?: string | null; + resultTotalCostUsd?: string | null; + resultCostUsd?: string | null; + resultCostUsdCamel?: string | null; + }; + + return { + ...rest, + contextSnapshot: summarizeHeartbeatRunContextSnapshot({ + issueId: contextIssueId, + taskId: contextTaskId, + taskKey: contextTaskKey, + commentId: contextCommentId, + wakeCommentId: contextWakeCommentId, + wakeReason: contextWakeReason, + wakeSource: contextWakeSource, + wakeTriggerDetail: contextWakeTriggerDetail, + }), + resultJson: safeForLegacyEncoding + ? null + : summarizeHeartbeatRunListResultJson({ + summary: resultSummary, + result: resultResult, + message: resultMessage, + error: resultError, + totalCostUsd: resultTotalCostUsd, + costUsd: resultCostUsd, + costUsdCamel: resultCostUsdCamel, + }), + }; + }); }, getRun, + getRunLogAccess, + getRuntimeState: async (agentId: string) => { const state = await getRuntimeState(agentId); const agent = await getAgent(agentId); @@ -4609,8 +6039,17 @@ export function heartbeatService( .orderBy(asc(heartbeatRunEvents.seq)) .limit(Math.max(1, Math.min(limit, 1000))), - readLog: async (runId: string, opts?: { offset?: number; limitBytes?: number }) => { - const run = await getRun(runId); + readLog: async ( + runOrLookup: string | { + id: string; + companyId: string; + logStore: string | null; + logRef: string | null; + }, + opts?: { offset?: number; limitBytes?: number }, + ) => { + const run = typeof runOrLookup === "string" ? await getRunLogAccess(runOrLookup) : runOrLookup; + const runId = typeof runOrLookup === "string" ? runOrLookup : runOrLookup.id; if (!run) throw notFound("Heartbeat run not found"); if (!run.logStore || !run.logRef) throw notFound("Run log not found"); @@ -4627,7 +6066,9 @@ export function heartbeatService( store: run.logStore, logRef: run.logRef, ...result, - content: redactCurrentUserText(result.content, await getCurrentUserRedactionOptions()), + // Run-log chunks are already redacted before they are appended to the store. + // Rewriting the full chunk again on every poll creates avoidable string copies. + content: result.content, }; }, @@ -4654,6 +6095,8 @@ export function heartbeatService( resumeQueuedRuns, + reconcileStrandedAssignedIssues, + tickTimers: async (now = new Date()) => { const allAgents = await db.select().from(agents); let checked = 0; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 06534a0b58a..c286122f988 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -5,8 +5,20 @@ export { agentService, deduplicateAgentName } from "./agents.js"; export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js"; export { assetService } from "./assets.js"; export { documentService, extractLegacyPlanBody } from "./documents.js"; +export { + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + buildContinuationSummaryMarkdown, + getIssueContinuationSummaryDocument, + refreshIssueContinuationSummary, +} from "./issue-continuation-summary.js"; export { projectService } from "./projects.js"; -export { issueService, type IssueFilters } from "./issues.js"; +export { + clampIssueListLimit, + ISSUE_LIST_DEFAULT_LIMIT, + ISSUE_LIST_MAX_LIMIT, + issueService, + type IssueFilters, +} from "./issues.js"; export { issueApprovalService } from "./issue-approvals.js"; export { goalService } from "./goals.js"; export { activityService, type ActivityFilters } from "./activity.js"; @@ -19,6 +31,7 @@ export { financeService } from "./finance.js"; export { heartbeatService } from "./heartbeat.js"; export { dashboardService } from "./dashboard.js"; export { sidebarBadgeService } from "./sidebar-badges.js"; +export { sidebarPreferenceService } from "./sidebar-preferences.js"; export { inboxDismissalService } from "./inbox-dismissals.js"; export { memoryService } from "./memory.js"; export { backgroundJobService } from "./background-jobs.js"; diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 65a1263226d..06e6f9df577 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -85,7 +85,16 @@ export function instanceSettingsService(db: Db) { }) .returning(); - return created; + if (created) return created; + + const raced = await db + .select() + .from(instanceSettings) + .where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY)) + .then((rows) => rows[0] ?? null); + if (raced) return raced; + + throw new Error("Failed to initialize instance settings row"); } return { diff --git a/server/src/services/invite-grants.ts b/server/src/services/invite-grants.ts new file mode 100644 index 00000000000..32c9aa42fe1 --- /dev/null +++ b/server/src/services/invite-grants.ts @@ -0,0 +1,68 @@ +import { PERMISSION_KEYS } from "@paperclipai/shared"; +import type { HumanCompanyMembershipRole } from "@paperclipai/shared"; +import { grantsForHumanRole } from "./company-member-roles.js"; + +export function grantsFromDefaults( + defaultsPayload: Record | null | undefined, + key: "human" | "agent" +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + if (!defaultsPayload || typeof defaultsPayload !== "object") return []; + const scoped = defaultsPayload[key]; + if (!scoped || typeof scoped !== "object") return []; + const grants = (scoped as Record).grants; + if (!Array.isArray(grants)) return []; + const validPermissionKeys = new Set(PERMISSION_KEYS); + const result: Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; + }> = []; + for (const item of grants) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + if (typeof record.permissionKey !== "string") continue; + if (!validPermissionKeys.has(record.permissionKey)) continue; + result.push({ + permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number], + scope: + record.scope && + typeof record.scope === "object" && + !Array.isArray(record.scope) + ? (record.scope as Record) + : null, + }); + } + return result; +} + +export function agentJoinGrantsFromDefaults( + defaultsPayload: Record | null | undefined +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + const grants = grantsFromDefaults(defaultsPayload, "agent"); + if (grants.some((grant) => grant.permissionKey === "tasks:assign")) { + return grants; + } + return [ + ...grants, + { + permissionKey: "tasks:assign", + scope: null, + }, + ]; +} + +export function humanJoinGrantsFromDefaults( + defaultsPayload: Record | null | undefined, + membershipRole: HumanCompanyMembershipRole +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { + const grants = grantsFromDefaults(defaultsPayload, "human"); + return grants.length > 0 ? grants : grantsForHumanRole(membershipRole); +} diff --git a/server/src/services/issue-continuation-summary.ts b/server/src/services/issue-continuation-summary.ts new file mode 100644 index 00000000000..422e3a5b15e --- /dev/null +++ b/server/src/services/issue-continuation-summary.ts @@ -0,0 +1,269 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { documents, issueDocuments, issues } from "@paperclipai/db"; +import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; +import { documentService } from "./documents.js"; + +export { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY }; +export const ISSUE_CONTINUATION_SUMMARY_TITLE = "Continuation Summary"; +export const ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS = 8_000; +const SUMMARY_SECTION_MAX_CHARS = 1_200; +const PATH_CANDIDATE_RE = /(?:^|[\s`"'(])((?:server|ui|packages|doc|scripts|\.github)\/[A-Za-z0-9._/-]+)/g; + +type IssueSummaryInput = { + id: string; + identifier: string | null; + title: string; + description: string | null; + status: string; + priority: string; +}; + +type RunSummaryInput = { + id: string; + status: string; + error: string | null; + errorCode?: string | null; + resultJson?: Record | null; + stdoutExcerpt?: string | null; + stderrExcerpt?: string | null; + finishedAt?: Date | null; +}; + +type AgentSummaryInput = { + id: string; + name: string; + adapterType: string | null; +}; + +export type IssueContinuationSummaryDocument = { + key: typeof ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY; + title: string | null; + body: string; + latestRevisionId: string | null; + latestRevisionNumber: number; + updatedAt: Date; +}; + +function truncateText(value: string, maxChars: number) { + const trimmed = value.trim(); + if (trimmed.length <= maxChars) return trimmed; + return `${trimmed.slice(0, Math.max(0, maxChars - 20)).trimEnd()}\n[truncated]`; +} + +function asNonEmptyString(value: unknown) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readResultSummary(resultJson: Record | null | undefined) { + if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) return null; + return ( + asNonEmptyString(resultJson.summary) ?? + asNonEmptyString(resultJson.result) ?? + asNonEmptyString(resultJson.message) ?? + asNonEmptyString(resultJson.error) ?? + null + ); +} + +function extractMarkdownSection(markdown: string | null | undefined, heading: string) { + if (!markdown) return null; + const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`^##\\s+${escaped}\\s*$([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, "im"); + const match = re.exec(markdown); + const section = match?.[1]?.trim(); + return section ? truncateText(section, SUMMARY_SECTION_MAX_CHARS) : null; +} + +function extractPathCandidates(...texts: Array) { + const seen = new Set(); + for (const text of texts) { + if (!text) continue; + for (const match of text.matchAll(PATH_CANDIDATE_RE)) { + const path = match[1]?.replace(/[),.;:]+$/, ""); + if (path) seen.add(path); + if (seen.size >= 12) break; + } + if (seen.size >= 12) break; + } + return [...seen]; +} + +function inferMode(issue: IssueSummaryInput, run: RunSummaryInput) { + if (issue.status === "done" || issue.status === "in_review") return "review"; + if (run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") return "implementation"; + if (issue.status === "backlog" || issue.status === "todo") return "plan"; + return "implementation"; +} + +function inferNextAction(issue: IssueSummaryInput, run: RunSummaryInput, previousNextAction: string | null) { + if (issue.status === "done") return "Review the completed issue output and close any remaining follow-up comments."; + if (issue.status === "in_review") return "Wait for reviewer feedback or approval before continuing executor work."; + if (run.status === "failed" || run.status === "timed_out") { + return "Inspect the failed run, fix the cause, and resume from the most recent concrete action above."; + } + if (run.status === "cancelled") return "Confirm the cancellation reason before starting another run."; + return previousNextAction ?? "Resume implementation from the acceptance criteria, latest comments, and this summary."; +} + +function bulletList(items: string[], empty: string) { + if (items.length === 0) return `- ${empty}`; + return items.map((item) => `- ${item}`).join("\n"); +} + +function extractPreviousNextAction(previousBody: string | null | undefined) { + const section = extractMarkdownSection(previousBody, "Next Action"); + if (!section) return null; + return section + .split(/\r?\n/) + .map((line) => line.replace(/^[-*]\s+/, "").trim()) + .find(Boolean) ?? null; +} + +export function buildContinuationSummaryMarkdown(input: { + issue: IssueSummaryInput; + run: RunSummaryInput; + agent: AgentSummaryInput; + previousSummaryBody?: string | null; +}) { + const { issue, run, agent } = input; + const resultSummary = readResultSummary(run.resultJson); + const recentActions = [ + `Run \`${run.id}\` finished with status \`${run.status}\`${run.finishedAt ? ` at ${run.finishedAt.toISOString()}` : ""}.`, + resultSummary ? truncateText(resultSummary, SUMMARY_SECTION_MAX_CHARS) : "No adapter-provided result summary was captured for this run.", + ]; + if (run.error) { + recentActions.push(`Latest run error${run.errorCode ? ` (${run.errorCode})` : ""}: ${truncateText(run.error, 500)}`); + } + + const paths = extractPathCandidates(resultSummary, run.stdoutExcerpt, run.stderrExcerpt, input.previousSummaryBody); + const objective = extractMarkdownSection(issue.description, "Objective") ?? issue.description?.trim() ?? "No objective captured."; + const acceptanceCriteria = extractMarkdownSection(issue.description, "Acceptance Criteria") ?? "No explicit acceptance criteria captured."; + const mode = inferMode(issue, run); + const nextAction = inferNextAction(issue, run, extractPreviousNextAction(input.previousSummaryBody)); + + const body = [ + "# Continuation Summary", + "", + `- Issue: ${issue.identifier ?? issue.id} — ${issue.title}`, + `- Status: ${issue.status}`, + `- Priority: ${issue.priority}`, + `- Current mode: ${mode}`, + `- Last updated by run: ${run.id}`, + `- Agent: ${agent.name} (${agent.adapterType ?? "unknown"})`, + "", + "## Objective", + "", + truncateText(objective, SUMMARY_SECTION_MAX_CHARS), + "", + "## Acceptance Criteria", + "", + acceptanceCriteria, + "", + "## Recent Concrete Actions", + "", + bulletList(recentActions, "No recent actions captured."), + "", + "## Files / Routes Touched", + "", + bulletList(paths.map((path) => `\`${path}\``), "No file or route paths were detected in the captured run summary."), + "", + "## Commands Run", + "", + bulletList( + [ + `Heartbeat run \`${run.id}\` invoked adapter \`${agent.adapterType ?? "unknown"}\`.`, + "Detailed shell/tool commands remain in the run log and transcript.", + ], + "No command metadata captured.", + ), + "", + "## Blockers / Decisions", + "", + bulletList( + run.error + ? [`Latest run ended with \`${run.status}\`; inspect the error before continuing.`] + : ["No new blocker was recorded by the latest run."], + "No blockers or decisions captured.", + ), + "", + "## Next Action", + "", + `- ${nextAction}`, + ].join("\n"); + + return truncateText(body, ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS); +} + +export async function getIssueContinuationSummaryDocument( + db: Db, + issueId: string, +): Promise { + const row = await db + .select({ + key: issueDocuments.key, + title: documents.title, + body: documents.latestBody, + latestRevisionId: documents.latestRevisionId, + latestRevisionNumber: documents.latestRevisionNumber, + updatedAt: documents.updatedAt, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY))) + .then((rows) => rows[0] ?? null); + + if (!row) return null; + return { + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + title: row.title, + body: row.body, + latestRevisionId: row.latestRevisionId, + latestRevisionNumber: row.latestRevisionNumber, + updatedAt: row.updatedAt, + }; +} + +export async function refreshIssueContinuationSummary(input: { + db: Db; + issueId: string; + run: RunSummaryInput; + agent: AgentSummaryInput; +}) { + const { db, issueId, run, agent } = input; + const [issue, existing] = await Promise.all([ + db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + description: issues.description, + status: issues.status, + priority: issues.priority, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null), + getIssueContinuationSummaryDocument(db, issueId), + ]); + + if (!issue) return null; + const body = buildContinuationSummaryMarkdown({ + issue, + run, + agent, + previousSummaryBody: existing?.body ?? null, + }); + const result = await documentService(db).upsertIssueDocument({ + issueId, + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + title: ISSUE_CONTINUATION_SUMMARY_TITLE, + format: "markdown", + body, + baseRevisionId: existing?.latestRevisionId ?? null, + changeSummary: `Refresh continuation summary after run ${run.id}`, + createdByAgentId: agent.id, + createdByRunId: run.id, + }); + return result.document; +} diff --git a/server/src/services/issue-execution-policy.ts b/server/src/services/issue-execution-policy.ts index 6a3045a1e05..428a11013dc 100644 --- a/server/src/services/issue-execution-policy.ts +++ b/server/src/services/issue-execution-policy.ts @@ -174,6 +174,42 @@ function buildCompletedState(previous: IssueExecutionState | null, currentStage: }; } +function buildStateWithCompletedStages(input: { + previous: IssueExecutionState | null; + completedStageIds: string[]; + returnAssignee: IssueExecutionStagePrincipal | null; +}): IssueExecutionState { + return { + status: input.previous?.status ?? PENDING_STATUS, + currentStageId: input.previous?.currentStageId ?? null, + currentStageIndex: input.previous?.currentStageIndex ?? null, + currentStageType: input.previous?.currentStageType ?? null, + currentParticipant: input.previous?.currentParticipant ?? null, + returnAssignee: input.previous?.returnAssignee ?? input.returnAssignee, + completedStageIds: input.completedStageIds, + lastDecisionId: input.previous?.lastDecisionId ?? null, + lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null, + }; +} + +function buildSkippedStageCompletedState(input: { + previous: IssueExecutionState | null; + completedStageIds: string[]; + returnAssignee: IssueExecutionStagePrincipal | null; +}): IssueExecutionState { + return { + status: COMPLETED_STATUS, + currentStageId: null, + currentStageIndex: null, + currentStageType: null, + currentParticipant: null, + returnAssignee: input.previous?.returnAssignee ?? input.returnAssignee, + completedStageIds: input.completedStageIds, + lastDecisionId: input.previous?.lastDecisionId ?? null, + lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null, + }; +} + function buildPendingState(input: { previous: IssueExecutionState | null; stage: IssueExecutionStage; @@ -236,6 +272,18 @@ function clearExecutionStatePatch(input: { } } +function canAutoSkipPendingStage(input: { + stage: IssueExecutionStage; + returnAssignee: IssueExecutionStagePrincipal | null; + requestedStatus?: string; +}) { + if (input.requestedStatus !== "done" || input.stage.type !== "review" || !input.returnAssignee) { + return false; + } + return input.stage.participants.length > 0 && + input.stage.participants.every((participant) => principalsEqual(participant, input.returnAssignee)); +} + export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult { const patch: Record = {}; const existingState = parseIssueExecutionState(input.issue.executionState); @@ -431,27 +479,61 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra return { patch }; } - const pendingStage = + let pendingStage = existingState?.status === CHANGES_REQUESTED_STATUS && currentStage ? currentStage : nextPendingStage(input.policy, existingState); if (!pendingStage) return { patch }; const returnAssignee = existingState?.returnAssignee ?? currentAssignee; - const participant = selectStageParticipant(pendingStage, { + const skippedStageIds = [...(existingState?.completedStageIds ?? [])]; + let participant = selectStageParticipant(pendingStage, { preferred: existingState?.status === CHANGES_REQUESTED_STATUS ? explicitAssignee ?? existingState.currentParticipant ?? null : explicitAssignee, exclude: returnAssignee, }); + while (!participant && canAutoSkipPendingStage({ stage: pendingStage, returnAssignee, requestedStatus })) { + skippedStageIds.push(pendingStage.id); + pendingStage = nextPendingStage( + input.policy, + buildStateWithCompletedStages({ + previous: existingState, + completedStageIds: skippedStageIds, + returnAssignee, + }), + ); + if (!pendingStage) { + patch.executionState = buildSkippedStageCompletedState({ + previous: existingState, + completedStageIds: skippedStageIds, + returnAssignee, + }); + return { patch }; + } + participant = selectStageParticipant(pendingStage, { + preferred: + existingState?.status === CHANGES_REQUESTED_STATUS + ? explicitAssignee ?? existingState.currentParticipant ?? null + : explicitAssignee, + exclude: returnAssignee, + }); + } if (!participant) { throw unprocessable(`No eligible ${pendingStage.type} participant is configured for this issue`); } buildPendingStagePatch({ patch, - previous: existingState, + previous: + skippedStageIds.length === (existingState?.completedStageIds ?? []).length + ? existingState + : buildStateWithCompletedStages({ + previous: existingState, + completedStageIds: skippedStageIds, + returnAssignee, + }), policy: input.policy, stage: pendingStage, participant, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index f7ac19da0df..11344d7b5e8 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -38,6 +38,12 @@ import { getDefaultCompanyGoal } from "./goals.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500; +export const ISSUE_LIST_DEFAULT_LIMIT = 500; +export const ISSUE_LIST_MAX_LIMIT = 1000; +const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500; +export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25; +const MAX_CHILD_COMPLETION_SUMMARIES = 20; +const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500; function assertTransition(from: string, to: string) { if (from === to) return; @@ -79,6 +85,7 @@ export interface IssueFilters { originKind?: string; originId?: string; includeRoutineExecutions?: boolean; + excludeRoutineExecutions?: boolean; q?: string; limit?: number; } @@ -102,6 +109,10 @@ type IssueUserCommentStats = { myLastCommentAt: Date | null; lastExternalCommentAt: Date | null; }; +type IssueReadStat = { + issueId: string; + myLastReadAt: Date | null; +}; type IssueLastActivityStat = { issueId: string; latestCommentAt: Date | null; @@ -120,10 +131,27 @@ type IssueCreateInput = Omit & { blockedByIssueIds?: string[]; inheritExecutionWorkspaceFromIssueId?: string | null; }; +type IssueChildCreateInput = IssueCreateInput & { + acceptanceCriteria?: string[]; + blockParentUntilDone?: boolean; + actorAgentId?: string | null; + actorUserId?: string | null; +}; type IssueRelationSummaryMap = { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[]; }; +export type ChildIssueCompletionSummary = { + id: string; + identifier: string | null; + title: string; + status: string; + priority: string; + assigneeAgentId: string | null; + assigneeUserId: string | null; + updatedAt: Date; + summary: string | null; +}; function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { if (actorRunId) return checkoutRunId === actorRunId; @@ -131,11 +159,38 @@ function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { } const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]); +const ISSUE_LIST_DESCRIPTION_MAX_CHARS = 1200; function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, "\\$&"); } +export function clampIssueListLimit(limit: number): number { + return Math.min(ISSUE_LIST_MAX_LIMIT, Math.max(1, Math.floor(limit))); +} + +function chunkList(values: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let index = 0; index < values.length; index += size) { + chunks.push(values.slice(index, index + size)); + } + return chunks; +} + +function truncateInlineSummary(value: string | null | undefined, maxChars = CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS) { + const normalized = value?.trim(); + if (!normalized) return null; + return normalized.length > maxChars ? `${normalized.slice(0, Math.max(0, maxChars - 15)).trimEnd()} [truncated]` : normalized; +} + +function appendAcceptanceCriteriaToDescription(description: string | null | undefined, acceptanceCriteria: string[] | undefined) { + const criteria = (acceptanceCriteria ?? []).map((item) => item.trim()).filter(Boolean); + if (criteria.length === 0) return description ?? null; + const base = description?.trim() ?? ""; + const criteriaMarkdown = ["## Acceptance Criteria", "", ...criteria.map((item) => `- ${item}`)].join("\n"); + return base ? `${base}\n\n${criteriaMarkdown}` : criteriaMarkdown; +} + async function getProjectDefaultGoalId( db: ProjectGoalReader, companyId: string, @@ -458,20 +513,22 @@ function latestIssueActivityAt(...values: Array> { const map = new Map(); if (issueIds.length === 0) return map; - const rows = await dbOrTx - .select({ - issueId: issueLabels.issueId, - label: labels, - }) - .from(issueLabels) - .innerJoin(labels, eq(issueLabels.labelId, labels.id)) - .where(inArray(issueLabels.issueId, issueIds)) - .orderBy(asc(labels.name), asc(labels.id)); + for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + issueId: issueLabels.issueId, + label: labels, + }) + .from(issueLabels) + .innerJoin(labels, eq(issueLabels.labelId, labels.id)) + .where(inArray(issueLabels.issueId, issueIdChunk)) + .orderBy(asc(labels.name), asc(labels.id)); - for (const row of rows) { - const existing = map.get(row.issueId); - if (existing) existing.push(row.label); - else map.set(row.issueId, [row.label]); + for (const row of rows) { + const existing = map.get(row.issueId); + if (existing) existing.push(row.label); + else map.set(row.issueId, [row.label]); + } } return map; } @@ -501,31 +558,78 @@ async function activeRunMapForIssues( .filter((id): id is string => id != null); if (runIds.length === 0) return map; - const rows = await dbOrTx - .select({ - id: heartbeatRuns.id, - status: heartbeatRuns.status, - agentId: heartbeatRuns.agentId, - invocationSource: heartbeatRuns.invocationSource, - triggerDetail: heartbeatRuns.triggerDetail, - startedAt: heartbeatRuns.startedAt, - finishedAt: heartbeatRuns.finishedAt, - createdAt: heartbeatRuns.createdAt, - }) - .from(heartbeatRuns) - .where( - and( - inArray(heartbeatRuns.id, runIds), - inArray(heartbeatRuns.status, ACTIVE_RUN_STATUSES), - ), - ); + for (const runIdChunk of chunkList([...new Set(runIds)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + id: heartbeatRuns.id, + status: heartbeatRuns.status, + agentId: heartbeatRuns.agentId, + invocationSource: heartbeatRuns.invocationSource, + triggerDetail: heartbeatRuns.triggerDetail, + startedAt: heartbeatRuns.startedAt, + finishedAt: heartbeatRuns.finishedAt, + createdAt: heartbeatRuns.createdAt, + }) + .from(heartbeatRuns) + .where( + and( + inArray(heartbeatRuns.id, runIdChunk), + inArray(heartbeatRuns.status, ACTIVE_RUN_STATUSES), + ), + ); - for (const row of rows) { - map.set(row.id, row); + for (const row of rows) { + map.set(row.id, row); + } } return map; } +const issueListSelect = { + id: issues.id, + companyId: issues.companyId, + projectId: issues.projectId, + projectWorkspaceId: issues.projectWorkspaceId, + goalId: issues.goalId, + parentId: issues.parentId, + title: issues.title, + description: sql` + CASE + WHEN ${issues.description} IS NULL THEN NULL + ELSE substring(${issues.description} FROM 1 FOR ${ISSUE_LIST_DESCRIPTION_MAX_CHARS}) + END + `, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + checkoutRunId: issues.checkoutRunId, + executionRunId: issues.executionRunId, + executionAgentNameKey: issues.executionAgentNameKey, + executionLockedAt: issues.executionLockedAt, + createdByAgentId: issues.createdByAgentId, + createdByUserId: issues.createdByUserId, + issueNumber: issues.issueNumber, + identifier: issues.identifier, + originKind: issues.originKind, + originId: issues.originId, + originRunId: issues.originRunId, + requestDepth: issues.requestDepth, + billingCode: issues.billingCode, + assigneeAdapterOverrides: issues.assigneeAdapterOverrides, + executionPolicy: sql`null`, + executionState: sql`null`, + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspacePreference: issues.executionWorkspacePreference, + executionWorkspaceSettings: sql`null`, + startedAt: issues.startedAt, + completedAt: issues.completedAt, + cancelledAt: issues.cancelledAt, + hiddenAt: issues.hiddenAt, + createdAt: issues.createdAt, + updatedAt: issues.updatedAt, +}; + function withActiveRuns( issueRows: IssueWithLabels[], runMap: Map, @@ -536,6 +640,131 @@ function withActiveRuns( })); } +async function userCommentStatsForIssues( + dbOrTx: any, + companyId: string, + userId: string, + issueIds: string[], +): Promise { + const stats: IssueUserCommentStats[] = []; + for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + issueId: issueComments.issueId, + myLastCommentAt: sql` + MAX(CASE WHEN ${issueComments.authorUserId} = ${userId} THEN ${issueComments.createdAt} END) + `, + lastExternalCommentAt: sql` + MAX( + CASE + WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${userId} + THEN ${issueComments.createdAt} + END + ) + `, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, companyId), + inArray(issueComments.issueId, issueIdChunk), + ), + ) + .groupBy(issueComments.issueId); + stats.push(...rows); + } + return stats; +} + +async function userReadStatsForIssues( + dbOrTx: any, + companyId: string, + userId: string, + issueIds: string[], +): Promise { + const stats: IssueReadStat[] = []; + for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + issueId: issueReadStates.issueId, + myLastReadAt: issueReadStates.lastReadAt, + }) + .from(issueReadStates) + .where( + and( + eq(issueReadStates.companyId, companyId), + eq(issueReadStates.userId, userId), + inArray(issueReadStates.issueId, issueIdChunk), + ), + ); + stats.push(...rows); + } + return stats; +} + +async function lastActivityStatsForIssues( + dbOrTx: any, + companyId: string, + issueIds: string[], +): Promise { + const byIssueId = new Map(); + for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const [commentRows, logRows] = await Promise.all([ + dbOrTx + .select({ + issueId: issueComments.issueId, + latestCommentAt: sql`MAX(${issueComments.createdAt})`, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, companyId), + inArray(issueComments.issueId, issueIdChunk), + ), + ) + .groupBy(issueComments.issueId), + dbOrTx + .select({ + issueId: activityLog.entityId, + latestLogAt: sql`MAX(${activityLog.createdAt})`, + }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, companyId), + eq(activityLog.entityType, "issue"), + inArray(activityLog.entityId, issueIdChunk), + sql`${activityLog.action} NOT IN (${sql.join( + ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`), + sql`, `, + )})`, + ), + ) + .groupBy(activityLog.entityId), + ]); + + for (const row of commentRows) { + byIssueId.set(row.issueId, { + issueId: row.issueId, + latestCommentAt: row.latestCommentAt, + latestLogAt: null, + }); + } + for (const row of logRows) { + const existing = byIssueId.get(row.issueId); + if (existing) existing.latestLogAt = row.latestLogAt; + else { + byIssueId.set(row.issueId, { + issueId: row.issueId, + latestCommentAt: null, + latestLogAt: row.latestLogAt, + }); + } + } + } + return [...byIssueId.values()]; +} + export function issueService(db: Db) { const instanceSettings = instanceSettingsService(db); @@ -985,7 +1214,7 @@ export function issueService(db: Db) { )!, ); } - if (!filters?.includeRoutineExecutions && !filters?.originKind && !filters?.originId) { + if (filters?.excludeRoutineExecutions && !filters?.originKind && !filters?.originId) { conditions.push(ne(issues.originKind, "routine_execution")); } conditions.push(isNull(issues.hiddenAt)); @@ -1004,7 +1233,7 @@ export function issueService(db: Db) { `; const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId); const baseQuery = db - .select() + .select(issueListSelect) .from(issues) .where(and(...conditions)) .orderBy( @@ -1024,99 +1253,12 @@ export function issueService(db: Db) { const issueIds = withRuns.map((row) => row.id); const [statsRows, readRows, lastActivityRows] = await Promise.all([ contextUserId - ? db - .select({ - issueId: issueComments.issueId, - myLastCommentAt: sql` - MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END) - `, - lastExternalCommentAt: sql` - MAX( - CASE - WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId} - THEN ${issueComments.createdAt} - END - ) - `, - }) - .from(issueComments) - .where( - and( - eq(issueComments.companyId, companyId), - inArray(issueComments.issueId, issueIds), - ), - ) - .groupBy(issueComments.issueId) + ? userCommentStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), contextUserId - ? db - .select({ - issueId: issueReadStates.issueId, - myLastReadAt: issueReadStates.lastReadAt, - }) - .from(issueReadStates) - .where( - and( - eq(issueReadStates.companyId, companyId), - eq(issueReadStates.userId, contextUserId), - inArray(issueReadStates.issueId, issueIds), - ), - ) + ? userReadStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), - Promise.all([ - db - .select({ - issueId: issueComments.issueId, - latestCommentAt: sql`MAX(${issueComments.createdAt})`, - }) - .from(issueComments) - .where( - and( - eq(issueComments.companyId, companyId), - inArray(issueComments.issueId, issueIds), - ), - ) - .groupBy(issueComments.issueId), - db - .select({ - issueId: activityLog.entityId, - latestLogAt: sql`MAX(${activityLog.createdAt})`, - }) - .from(activityLog) - .where( - and( - eq(activityLog.companyId, companyId), - eq(activityLog.entityType, "issue"), - inArray(activityLog.entityId, issueIds), - sql`${activityLog.action} NOT IN (${sql.join( - ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`), - sql`, `, - )})`, - ), - ) - .groupBy(activityLog.entityId), - ]).then(([commentRows, logRows]) => { - const byIssueId = new Map(); - for (const row of commentRows) { - byIssueId.set(row.issueId, { - issueId: row.issueId, - latestCommentAt: row.latestCommentAt, - latestLogAt: null, - }); - } - for (const row of logRows) { - const existing = byIssueId.get(row.issueId); - if (existing) existing.latestLogAt = row.latestLogAt; - else { - byIssueId.set(row.issueId, { - issueId: row.issueId, - latestCommentAt: null, - latestLogAt: row.latestLogAt, - }); - } - } - return [...byIssueId.values()]; - }), + lastActivityStatsForIssues(db, companyId, issueIds), ]); const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); @@ -1162,7 +1304,6 @@ export function issueService(db: Db) { eq(issues.companyId, companyId), isNull(issues.hiddenAt), unreadForUserCondition(companyId, userId), - ne(issues.originKind, "routine_execution"), ]; if (status) { const statuses = status.split(",").map((s) => s.trim()).filter(Boolean); @@ -1360,18 +1501,110 @@ export function issueService(db: Db) { } const children = await db - .select({ id: issues.id, status: issues.status }) + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + updatedAt: issues.updatedAt, + }) .from(issues) - .where(and(eq(issues.companyId, parent.companyId), eq(issues.parentId, parentIssueId))); + .where(and(eq(issues.companyId, parent.companyId), eq(issues.parentId, parentIssueId))) + .orderBy(asc(issues.issueNumber), asc(issues.createdAt)); if (children.length === 0) return null; if (!children.every((child) => child.status === "done" || child.status === "cancelled")) { return null; } + const childIdsForSummaries = children.slice(0, MAX_CHILD_COMPLETION_SUMMARIES).map((child) => child.id); + const commentRows = childIdsForSummaries.length > 0 + ? await db + .select({ + issueId: issueComments.issueId, + body: issueComments.body, + createdAt: issueComments.createdAt, + }) + .from(issueComments) + .where(and(eq(issueComments.companyId, parent.companyId), inArray(issueComments.issueId, childIdsForSummaries))) + .orderBy(desc(issueComments.createdAt), desc(issueComments.id)) + : []; + const latestCommentByIssueId = new Map(); + for (const comment of commentRows) { + if (!latestCommentByIssueId.has(comment.issueId)) { + latestCommentByIssueId.set(comment.issueId, comment.body); + } + } + const childIssueSummaries: ChildIssueCompletionSummary[] = children + .slice(0, MAX_CHILD_COMPLETION_SUMMARIES) + .map((child) => ({ + ...child, + summary: truncateInlineSummary(latestCommentByIssueId.get(child.id)), + })); + return { id: parent.id, assigneeAgentId: parent.assigneeAgentId, childIssueIds: children.map((child) => child.id), + childIssueSummaries, + childIssueSummaryTruncated: children.length > childIssueSummaries.length, + }; + }, + + createChild: async ( + parentIssueId: string, + data: IssueChildCreateInput, + ) => { + const parent = await db + .select() + .from(issues) + .where(eq(issues.id, parentIssueId)) + .then((rows) => rows[0] ?? null); + if (!parent) throw notFound("Parent issue not found"); + + const [{ childCount }] = await db + .select({ childCount: sql`count(*)::int` }) + .from(issues) + .where(and(eq(issues.companyId, parent.companyId), eq(issues.parentId, parent.id))); + if (childCount >= MAX_CHILD_ISSUES_CREATED_BY_HELPER) { + throw unprocessable(`Parent issue already has the maximum ${MAX_CHILD_ISSUES_CREATED_BY_HELPER} child issues for this helper`); + } + + const { + acceptanceCriteria, + blockParentUntilDone, + actorAgentId, + actorUserId, + ...issueData + } = data; + const child = await issueService(db).create(parent.companyId, { + ...issueData, + parentId: parent.id, + projectId: issueData.projectId ?? parent.projectId, + goalId: issueData.goalId ?? parent.goalId, + requestDepth: Math.max(parent.requestDepth + 1, issueData.requestDepth ?? 0), + description: appendAcceptanceCriteriaToDescription(issueData.description, acceptanceCriteria), + inheritExecutionWorkspaceFromIssueId: parent.id, + }); + + if (blockParentUntilDone) { + const existingBlockers = await db + .select({ blockerIssueId: issueRelations.issueId }) + .from(issueRelations) + .where(and(eq(issueRelations.companyId, parent.companyId), eq(issueRelations.relatedIssueId, parent.id), eq(issueRelations.type, "blocks"))); + await syncBlockedByIssueIds( + parent.id, + parent.companyId, + [...new Set([...existingBlockers.map((row) => row.blockerIssueId), child.id])], + { agentId: actorAgentId ?? null, userId: actorUserId ?? null }, + ); + } + + return { + issue: child, + parentBlockerAdded: Boolean(blockParentUntilDone), }; }, @@ -2112,6 +2345,28 @@ export function issueService(db: Db) { return comment ? redactIssueComment(comment, censorUsernameInLogs) : null; })), + removeComment: async (commentId: string) => { + const currentUserRedactionOptions = { + enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, + }; + + return db.transaction(async (tx) => { + const [comment] = await tx + .delete(issueComments) + .where(eq(issueComments.id, commentId)) + .returning(); + + if (!comment) return null; + + await tx + .update(issues) + .set({ updatedAt: new Date() }) + .where(eq(issues.id, comment.issueId)); + + return redactIssueComment(comment, currentUserRedactionOptions.enabled); + }); + }, + addComment: async ( issueId: string, body: string, @@ -2328,7 +2583,10 @@ export function issueService(db: Db) { return [...resolved]; }, - findMentionedProjectIds: async (issueId: string) => { + findMentionedProjectIds: async ( + issueId: string, + opts?: { includeCommentBodies?: boolean }, + ) => { const issue = await db .select({ companyId: issues.companyId, @@ -2340,21 +2598,26 @@ export function issueService(db: Db) { .then((rows) => rows[0] ?? null); if (!issue) return []; - const comments = await db - .select({ body: issueComments.body }) - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); - const mentionedIds = new Set(); - for (const source of [ - issue.title, - issue.description ?? "", - ...comments.map((comment) => comment.body), - ]) { + for (const source of [issue.title, issue.description ?? ""]) { for (const projectId of extractProjectMentionIds(source)) { mentionedIds.add(projectId); } } + + if (opts?.includeCommentBodies !== false) { + const comments = await db + .select({ body: issueComments.body }) + .from(issueComments) + .where(eq(issueComments.issueId, issueId)); + + for (const comment of comments) { + for (const projectId of extractProjectMentionIds(comment.body)) { + mentionedIds.add(projectId); + } + } + } + if (mentionedIds.size === 0) return []; const rows = await db diff --git a/server/src/services/project-workspace-runtime-config.ts b/server/src/services/project-workspace-runtime-config.ts index 8252fecd31d..9a36c957958 100644 --- a/server/src/services/project-workspace-runtime-config.ts +++ b/server/src/services/project-workspace-runtime-config.ts @@ -12,6 +12,13 @@ function readDesiredState(value: unknown): ProjectWorkspaceRuntimeConfig["desire return value === "running" || value === "stopped" ? value : null; } +function readServiceStates(value: unknown): ProjectWorkspaceRuntimeConfig["serviceStates"] { + if (!isRecord(value)) return null; + const entries = Object.entries(value).filter(([, state]) => state === "running" || state === "stopped"); + if (entries.length === 0) return null; + return Object.fromEntries(entries) as ProjectWorkspaceRuntimeConfig["serviceStates"]; +} + export function readProjectWorkspaceRuntimeConfig( metadata: Record | null | undefined, ): ProjectWorkspaceRuntimeConfig | null { @@ -21,9 +28,10 @@ export function readProjectWorkspaceRuntimeConfig( const config: ProjectWorkspaceRuntimeConfig = { workspaceRuntime: cloneRecord(raw.workspaceRuntime), desiredState: readDesiredState(raw.desiredState), + serviceStates: readServiceStates(raw.serviceStates), }; - const hasConfig = config.workspaceRuntime !== null || config.desiredState !== null; + const hasConfig = config.workspaceRuntime !== null || config.desiredState !== null || config.serviceStates !== null; return hasConfig ? config : null; } @@ -35,6 +43,7 @@ export function mergeProjectWorkspaceRuntimeConfig( const current = readProjectWorkspaceRuntimeConfig(metadata) ?? { workspaceRuntime: null, desiredState: null, + serviceStates: null, }; if (patch === null) { @@ -47,9 +56,11 @@ export function mergeProjectWorkspaceRuntimeConfig( patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime, desiredState: patch.desiredState !== undefined ? readDesiredState(patch.desiredState) : current.desiredState, + serviceStates: + patch.serviceStates !== undefined ? readServiceStates(patch.serviceStates) : current.serviceStates, }; - if (nextConfig.workspaceRuntime === null && nextConfig.desiredState === null) { + if (nextConfig.workspaceRuntime === null && nextConfig.desiredState === null && nextConfig.serviceStates === null) { delete nextMetadata.runtimeConfig; } else { nextMetadata.runtimeConfig = nextConfig; diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index db117653b07..80dfd33a8fe 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -921,6 +921,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup kind: trigger.kind as RoutineListItem["triggers"][number]["kind"], label: trigger.label, enabled: trigger.enabled, + cronExpression: trigger.cronExpression, + timezone: trigger.timezone, nextRunAt: trigger.nextRunAt, lastFiredAt: trigger.lastFiredAt, lastResult: trigger.lastResult, diff --git a/server/src/services/run-continuations.ts b/server/src/services/run-continuations.ts new file mode 100644 index 00000000000..5269ad5a03e --- /dev/null +++ b/server/src/services/run-continuations.ts @@ -0,0 +1,188 @@ +import { and, eq, inArray } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db"; +import type { RunLivenessState } from "@paperclipai/shared"; + +export const RUN_LIVENESS_CONTINUATION_REASON = "run_liveness_continuation"; +export const DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS = 2; + +const ACTIONABLE_LIVENESS_STATES = new Set(["plan_only", "empty_response"]); +const CONTINUATION_ACTIVE_ISSUE_STATUSES = new Set(["todo", "in_progress"]); +// A prior adapter error should not permanently suppress bounded liveness +// continuations; the max-attempt/idempotency guards prevent unbounded retries. +const CONTINUATION_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]); +const IDEMPOTENT_WAKE_STATUSES = ["queued", "deferred_issue_execution", "completed"]; + +type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect; +type IssueRow = Pick< + typeof issues.$inferSelect, + "id" | "companyId" | "identifier" | "title" | "status" | "assigneeAgentId" | "executionState" | "projectId" +>; +type AgentRow = Pick; + +export type RunContinuationDecision = + | { + kind: "enqueue"; + nextAttempt: number; + idempotencyKey: string; + payload: Record; + contextSnapshot: Record; + } + | { + kind: "exhausted"; + attempt: number; + maxAttempts: number; + comment: string; + } + | { + kind: "skip"; + reason: string; + }; + +export function readContinuationAttempt(value: unknown): number { + const numeric = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10); + return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 0; +} + +export function buildRunLivenessContinuationIdempotencyKey(input: { + issueId: string; + sourceRunId: string; + livenessState: RunLivenessState; + nextAttempt: number; +}) { + return [ + "run_liveness_continuation", + input.issueId, + input.sourceRunId, + input.livenessState, + String(input.nextAttempt), + ].join(":"); +} + +export async function findExistingRunLivenessContinuationWake( + db: Db, + input: { + companyId: string; + idempotencyKey: string; + }, +) { + return db + .select({ id: agentWakeupRequests.id, status: agentWakeupRequests.status }) + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, input.companyId), + eq(agentWakeupRequests.idempotencyKey, input.idempotencyKey), + inArray(agentWakeupRequests.status, IDEMPOTENT_WAKE_STATUSES), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); +} + +export function decideRunLivenessContinuation(input: { + run: HeartbeatRunRow; + issue: IssueRow | null; + agent: AgentRow | null; + livenessState: RunLivenessState | null; + livenessReason: string | null; + nextAction: string | null; + budgetBlocked: boolean; + idempotentWakeExists: boolean; + maxAttempts?: number; +}): RunContinuationDecision { + const { + run, + issue, + agent, + livenessState, + livenessReason, + nextAction, + budgetBlocked, + idempotentWakeExists, + } = input; + const maxAttempts = input.maxAttempts ?? DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS; + + if (!livenessState || !ACTIONABLE_LIVENESS_STATES.has(livenessState)) { + return { kind: "skip", reason: "liveness state is not actionable for continuation" }; + } + if (!issue) return { kind: "skip", reason: "issue not found" }; + if (!agent) return { kind: "skip", reason: "agent not found" }; + if (issue.companyId !== run.companyId || agent.companyId !== run.companyId) { + return { kind: "skip", reason: "company scope mismatch" }; + } + if (issue.assigneeAgentId !== run.agentId) { + return { kind: "skip", reason: "issue is no longer assigned to the source run agent" }; + } + if (!CONTINUATION_ACTIVE_ISSUE_STATUSES.has(issue.status)) { + return { kind: "skip", reason: `issue status ${issue.status} is not continuable` }; + } + if (issue.executionState) { + return { kind: "skip", reason: "issue is blocked by execution policy state" }; + } + if (!CONTINUATION_AGENT_STATUSES.has(agent.status)) { + return { kind: "skip", reason: `agent status ${agent.status} is not invokable` }; + } + if (budgetBlocked) { + return { kind: "skip", reason: "budget hard stop blocks continuation" }; + } + + const currentAttempt = readContinuationAttempt(run.continuationAttempt); + if (currentAttempt >= maxAttempts) { + return { + kind: "exhausted", + attempt: currentAttempt, + maxAttempts, + comment: [ + "Bounded liveness continuation exhausted", + "", + `- Last liveness state: \`${livenessState}\``, + `- Attempts used: ${currentAttempt}/${maxAttempts}`, + `- Reason: ${livenessReason ?? "Run ended without concrete progress"}`, + "- Next action: a human or manager should inspect the run and either clarify the task, mark it blocked, or assign a concrete follow-up.", + ].join("\n"), + }; + } + + const nextAttempt = currentAttempt + 1; + const idempotencyKey = buildRunLivenessContinuationIdempotencyKey({ + issueId: issue.id, + sourceRunId: run.id, + livenessState, + nextAttempt, + }); + if (idempotentWakeExists) { + return { kind: "skip", reason: "continuation wake already exists for this source run and attempt" }; + } + + const payload = { + issueId: issue.id, + sourceRunId: run.id, + livenessState, + livenessReason, + continuationAttempt: nextAttempt, + maxContinuationAttempts: maxAttempts, + instruction: + nextAction ?? + "The previous run ended without concrete progress. Take the first concrete action now or mark the issue blocked with a specific unblock request.", + }; + + return { + kind: "enqueue", + nextAttempt, + idempotencyKey, + payload, + contextSnapshot: { + issueId: issue.id, + taskId: issue.id, + taskKey: issue.id, + wakeReason: RUN_LIVENESS_CONTINUATION_REASON, + livenessContinuationAttempt: nextAttempt, + livenessContinuationMaxAttempts: maxAttempts, + livenessContinuationSourceRunId: run.id, + livenessContinuationState: livenessState, + livenessContinuationReason: livenessReason, + livenessContinuationInstruction: payload.instruction, + }, + }; +} diff --git a/server/src/services/run-liveness.ts b/server/src/services/run-liveness.ts new file mode 100644 index 00000000000..75f82909b13 --- /dev/null +++ b/server/src/services/run-liveness.ts @@ -0,0 +1,227 @@ +import type { HeartbeatRunStatus, IssueStatus, RunLivenessState } from "@paperclipai/shared"; + +export interface RunLivenessIssueInput { + status: IssueStatus | string; + title: string; + description: string | null; +} + +export interface RunLivenessEvidenceInput { + issueCommentsCreated: number; + documentRevisionsCreated: number; + planDocumentRevisionsCreated: number; + workProductsCreated: number; + workspaceOperationsCreated: number; + activityEventsCreated: number; + toolOrActionEventsCreated: number; + latestEvidenceAt: Date | null; +} + +export interface RunLivenessClassificationInput { + runStatus: HeartbeatRunStatus | string; + issue: RunLivenessIssueInput | null; + resultJson?: Record | null; + stdoutExcerpt?: string | null; + stderrExcerpt?: string | null; + error?: string | null; + errorCode?: string | null; + continuationAttempt?: number | null; + evidence?: Partial | null; +} + +export interface RunLivenessClassification { + livenessState: RunLivenessState; + livenessReason: string; + continuationAttempt: number; + lastUsefulActionAt: Date | null; + nextAction: string | null; +} + +const DEFAULT_EVIDENCE: RunLivenessEvidenceInput = { + issueCommentsCreated: 0, + documentRevisionsCreated: 0, + planDocumentRevisionsCreated: 0, + workProductsCreated: 0, + workspaceOperationsCreated: 0, + activityEventsCreated: 0, + toolOrActionEventsCreated: 0, + latestEvidenceAt: null, +}; + +const PLANNING_ONLY_RE = + /\b(?:i(?:'ll| will| am going to|'m going to)|let me|i need to|next(?:,| i will| i'll)?|my next step is|the next step is)\s+(?:first\s+)?(?:inspect|check|review|look|investigate|analy[sz]e|open|read|start|begin|work on|implement|fix|test|update|create|add)\b/i; +const NEXT_STEPS_RE = /^\s*(?:next steps?|plan)\s*:/im; +const BLOCKER_RE = + /\b(?:blocked|can't proceed|cannot proceed|unable to proceed|waiting on|need(?:s|ed)? .{0,80}\b(?:approval|access|credential|credentials|secret|api key|token|input|clarification)|requires? .{0,80}\b(?:approval|access|credential|credentials|secret|api key|token|input|clarification))\b/i; +const NEGATED_BLOCKER_RE = /\b(?:not blocked|no blocker|no blockers|unblocked)\b/i; +const PLAN_TASK_TITLE_RE = /\b(?:plan|planning|analysis|investigation|research|report|proposal|design doc|write-?up)\b/i; +const PLAN_TASK_DESCRIPTION_RE = + /\b(?:create|write|produce|draft|update|revise|prepare)\s+(?:a\s+|the\s+)?(?:plan|analysis|investigation|research report|report|proposal|design doc|write-?up)\b/i; + +function compactReason(reason: string) { + return reason.length <= 500 ? reason : `${reason.slice(0, 497)}...`; +} + +function normalizeCount(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; +} + +function normalizeContinuationAttempt(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; +} + +function readText(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function resultText(resultJson: Record | null | undefined) { + if (!resultJson) return ""; + return [ + readText(resultJson.summary), + readText(resultJson.result), + readText(resultJson.message), + readText(resultJson.stdout), + readText(resultJson.stderr), + ] + .filter((value): value is string => Boolean(value)) + .join("\n"); +} + +function combinedOutput(input: RunLivenessClassificationInput) { + return [ + resultText(input.resultJson), + readText(input.stdoutExcerpt), + readText(input.stderrExcerpt), + readText(input.error), + ] + .filter((value): value is string => Boolean(value)) + .join("\n") + .trim(); +} + +export function hasUsefulOutput(input: RunLivenessClassificationInput) { + return combinedOutput(input).length > 0; +} + +export function declaredBlocker(input: RunLivenessClassificationInput) { + if (input.issue?.status === "blocked") return true; + const text = combinedOutput(input); + if (!text || NEGATED_BLOCKER_RE.test(text)) return false; + return BLOCKER_RE.test(text); +} + +export function looksLikePlanningOnly(input: RunLivenessClassificationInput) { + const text = combinedOutput(input); + if (!text) return false; + return PLANNING_ONLY_RE.test(text) || NEXT_STEPS_RE.test(text); +} + +export function isPlanningOrDocumentTask(issue: RunLivenessIssueInput | null | undefined) { + if (!issue) return false; + if (PLAN_TASK_TITLE_RE.test(issue.title)) return true; + return PLAN_TASK_DESCRIPTION_RE.test(issue.description ?? ""); +} + +function normalizeEvidence(evidence: Partial | null | undefined): RunLivenessEvidenceInput { + return { + issueCommentsCreated: normalizeCount(evidence?.issueCommentsCreated), + documentRevisionsCreated: normalizeCount(evidence?.documentRevisionsCreated), + planDocumentRevisionsCreated: normalizeCount(evidence?.planDocumentRevisionsCreated), + workProductsCreated: normalizeCount(evidence?.workProductsCreated), + workspaceOperationsCreated: normalizeCount(evidence?.workspaceOperationsCreated), + activityEventsCreated: normalizeCount(evidence?.activityEventsCreated), + toolOrActionEventsCreated: normalizeCount(evidence?.toolOrActionEventsCreated), + latestEvidenceAt: evidence?.latestEvidenceAt instanceof Date ? evidence.latestEvidenceAt : null, + }; +} + +export function hasConcreteActionEvidence(evidence: Partial | null | undefined) { + const normalized = normalizeEvidence(evidence); + // Workspace creation is setup evidence, not task progress by itself. It can + // appear in reasons alongside durable activity, but it must not prevent a + // planning-only or empty run from receiving a bounded continuation. + return ( + normalized.issueCommentsCreated + + normalized.documentRevisionsCreated + + normalized.workProductsCreated + + normalized.activityEventsCreated + + normalized.toolOrActionEventsCreated > + 0 + ); +} + +function evidenceReason(evidence: RunLivenessEvidenceInput) { + const parts: string[] = []; + if (evidence.issueCommentsCreated > 0) parts.push(`${evidence.issueCommentsCreated} issue comment(s)`); + if (evidence.documentRevisionsCreated > 0) parts.push(`${evidence.documentRevisionsCreated} document revision(s)`); + if (evidence.workProductsCreated > 0) parts.push(`${evidence.workProductsCreated} work product(s)`); + if (evidence.workspaceOperationsCreated > 0) parts.push(`${evidence.workspaceOperationsCreated} workspace operation(s)`); + if (evidence.activityEventsCreated > 0) parts.push(`${evidence.activityEventsCreated} activity event(s)`); + if (evidence.toolOrActionEventsCreated > 0) parts.push(`${evidence.toolOrActionEventsCreated} tool/action event(s)`); + return parts.join(", "); +} + +function extractNextAction(input: RunLivenessClassificationInput) { + const text = combinedOutput(input); + if (!text) return null; + const line = text + .split(/\r?\n/) + .map((entry) => entry.trim()) + .find((entry) => PLANNING_ONLY_RE.test(entry) || /^next(?: steps?| action)?\s*:/i.test(entry)); + if (!line) return null; + return line.length <= 500 ? line : `${line.slice(0, 497)}...`; +} + +export function classifyRunLiveness(input: RunLivenessClassificationInput): RunLivenessClassification { + const evidence = normalizeEvidence(input.evidence); + const continuationAttempt = normalizeContinuationAttempt(input.continuationAttempt); + const issueStatus = input.issue?.status ?? null; + const usefulOutput = hasUsefulOutput(input); + const concreteEvidence = hasConcreteActionEvidence(evidence); + const planExempt = isPlanningOrDocumentTask(input.issue) || evidence.planDocumentRevisionsCreated > 0; + const lastUsefulActionAt = concreteEvidence ? evidence.latestEvidenceAt : null; + + const output = (state: RunLivenessState, reason: string, nextAction: string | null = null): RunLivenessClassification => ({ + livenessState: state, + livenessReason: compactReason(reason), + continuationAttempt, + lastUsefulActionAt: state === "advanced" || state === "completed" || state === "blocked" ? lastUsefulActionAt : null, + nextAction, + }); + + if (input.runStatus !== "succeeded") { + return output("failed", input.errorCode ? `Run ended with ${input.runStatus} (${input.errorCode})` : `Run ended with ${input.runStatus}`); + } + + if (issueStatus === "done" || issueStatus === "cancelled") { + return output("completed", `Issue is ${issueStatus}`); + } + + if (declaredBlocker(input)) { + return output("blocked", issueStatus === "blocked" ? "Issue status is blocked" : "Run output declared a concrete blocker", extractNextAction(input)); + } + + if (!usefulOutput && !concreteEvidence) { + return output("empty_response", "Run succeeded without useful output or concrete action evidence"); + } + + if (concreteEvidence) { + return output("advanced", `Run produced concrete action evidence: ${evidenceReason(evidence)}`); + } + + if (planExempt && usefulOutput) { + return output("advanced", "Planning/document task produced useful output and is exempt from plan-only classification"); + } + + if (looksLikePlanningOnly(input)) { + return output("plan_only", "Run described future work without concrete action evidence", extractNextAction(input)); + } + + if (usefulOutput) { + return output("needs_followup", "Run produced useful output but no concrete action evidence", extractNextAction(input)); + } + + return output("empty_response", "Run succeeded without useful output"); +} diff --git a/server/src/services/sidebar-preferences.ts b/server/src/services/sidebar-preferences.ts new file mode 100644 index 00000000000..f029e43babf --- /dev/null +++ b/server/src/services/sidebar-preferences.ts @@ -0,0 +1,97 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + companyUserSidebarPreferences, + userSidebarPreferences, +} from "@paperclipai/db"; +import type { SidebarOrderPreference } from "@paperclipai/shared"; + +function normalizeOrderedIds(value: unknown): string[] { + if (!Array.isArray(value)) return []; + + const orderedIds: string[] = []; + const seen = new Set(); + for (const item of value) { + if (typeof item !== "string") continue; + const trimmed = item.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + orderedIds.push(trimmed); + } + return orderedIds; +} + +function toPreference(orderedIds: unknown, updatedAt: Date | null): SidebarOrderPreference { + return { + orderedIds: normalizeOrderedIds(orderedIds), + updatedAt, + }; +} + +export function sidebarPreferenceService(db: Db) { + return { + async getCompanyOrder(userId: string): Promise { + const row = await db.query.userSidebarPreferences.findFirst({ + where: eq(userSidebarPreferences.userId, userId), + }); + return toPreference(row?.companyOrder ?? [], row?.updatedAt ?? null); + }, + + async upsertCompanyOrder(userId: string, orderedIds: string[]): Promise { + const now = new Date(); + const normalized = normalizeOrderedIds(orderedIds); + const [row] = await db + .insert(userSidebarPreferences) + .values({ + userId, + companyOrder: normalized, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [userSidebarPreferences.userId], + set: { + companyOrder: normalized, + updatedAt: now, + }, + }) + .returning(); + return toPreference(row?.companyOrder ?? normalized, row?.updatedAt ?? now); + }, + + async getProjectOrder(companyId: string, userId: string): Promise { + const row = await db.query.companyUserSidebarPreferences.findFirst({ + where: and( + eq(companyUserSidebarPreferences.companyId, companyId), + eq(companyUserSidebarPreferences.userId, userId), + ), + }); + return toPreference(row?.projectOrder ?? [], row?.updatedAt ?? null); + }, + + async upsertProjectOrder( + companyId: string, + userId: string, + orderedIds: string[], + ): Promise { + const now = new Date(); + const normalized = normalizeOrderedIds(orderedIds); + const [row] = await db + .insert(companyUserSidebarPreferences) + .values({ + companyId, + userId, + projectOrder: normalized, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [companyUserSidebarPreferences.companyId, companyUserSidebarPreferences.userId], + set: { + projectOrder: normalized, + updatedAt: now, + }, + }) + .returning(); + return toPreference(row?.projectOrder ?? normalized, row?.updatedAt ?? now); + }, + }; +} diff --git a/server/src/services/workspace-operations.ts b/server/src/services/workspace-operations.ts index b20a9ed74c2..9509e9824bc 100644 --- a/server/src/services/workspace-operations.ts +++ b/server/src/services/workspace-operations.ts @@ -250,9 +250,9 @@ export function workspaceOperationService(db: Db) { store: operation.logStore, logRef: operation.logRef, ...result, - content: redactCurrentUserText(result.content, { - enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, - }), + // Workspace-operation log chunks are sanitized before append-time storage. + // Returning the stored chunk avoids another whole-string rewrite per poll. + content: result.content, }; }, }; diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index c0447bcc3fb..f3a556c0cf4 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -8,6 +8,11 @@ import { setTimeout as delay } from "node:timers/promises"; import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils"; import type { Db } from "@paperclipai/db"; import { executionWorkspaces, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; +import { + listWorkspaceServiceCommandDefinitions, + type WorkspaceRuntimeDesiredState, + type WorkspaceRuntimeServiceStateMap, +} from "@paperclipai/shared"; import { and, desc, eq, inArray } from "drizzle-orm"; import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js"; import { resolveHomeAwarePath } from "../home-paths.js"; @@ -26,7 +31,11 @@ import { readExecutionWorkspaceConfig } from "./execution-workspaces.js"; import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; export function resolveShell(): string { - return process.env.SHELL?.trim() || (process.platform === "win32" ? "sh" : "/bin/sh"); + const fallback = process.platform === "win32" ? "sh" : "/bin/sh"; + const shell = process.env.SHELL?.trim(); + if (!shell) return fallback; + if (path.isAbsolute(shell) && !existsSync(shell)) return fallback; + return shell; } export interface ExecutionWorkspaceInput { @@ -404,32 +413,33 @@ function formatCommandForDisplay(command: string, args: string[]) { .join(" "); } +function trimToLastBytes(value: string, limit: number) { + const byteLength = Buffer.byteLength(value, "utf8"); + if (byteLength <= limit) return value; + return Buffer.from(value, "utf8").subarray(byteLength - limit).toString("utf8"); +} + function createProcessOutputCapture(maxBytes: number): ProcessOutputAccumulator { const limit = Math.max(1, Math.trunc(maxBytes)); - let chunks: string[] = []; + let text = ""; let truncated = false; let totalBytes = 0; return { append(chunk: string) { if (!chunk) return; - chunks.push(chunk); totalBytes += Buffer.byteLength(chunk, "utf8"); - let currentBytes = chunks.reduce((sum, value) => sum + Buffer.byteLength(value, "utf8"), 0); - if (currentBytes <= limit) return; - - const combined = Buffer.from(chunks.join(""), "utf8"); - const tail = combined.subarray(Math.max(0, combined.length - limit)).toString("utf8"); - chunks = [tail]; - truncated = true; - currentBytes = Buffer.byteLength(tail, "utf8"); - if (currentBytes > limit) { - chunks = [Buffer.from(tail, "utf8").subarray(Math.max(0, currentBytes - limit)).toString("utf8")]; + const combined = text + chunk; + if (Buffer.byteLength(combined, "utf8") <= limit) { + text = combined; + return; } + + text = trimToLastBytes(combined, limit); + truncated = true; }, finish(): ProcessOutputCapture { - const text = chunks.join(""); if (!truncated) { return { text, @@ -513,6 +523,67 @@ function gitErrorIncludes(error: unknown, needle: string) { return message.toLowerCase().includes(needle.toLowerCase()); } +type GitWorktreeListEntry = { + worktree: string; + branch: string | null; +}; + +function parseGitWorktreeListPorcelain(raw: string): GitWorktreeListEntry[] { + const entries: GitWorktreeListEntry[] = []; + let current: Partial = {}; + + for (const line of raw.split(/\r?\n/)) { + if (line.startsWith("worktree ")) { + current = { worktree: line.slice("worktree ".length) }; + continue; + } + if (line.startsWith("branch ")) { + current.branch = line.slice("branch ".length); + continue; + } + if (line === "" && current.worktree) { + entries.push({ + worktree: current.worktree, + branch: current.branch ?? null, + }); + current = {}; + } + } + + if (current.worktree) { + entries.push({ + worktree: current.worktree, + branch: current.branch ?? null, + }); + } + + return entries; +} + +async function resolveGitOwnerRepoRoot(cwd: string): Promise { + const checkoutRoot = path.resolve(await runGit(["rev-parse", "--show-toplevel"], cwd)); + const commonDir = await runGit(["rev-parse", "--git-common-dir"], checkoutRoot).catch(() => null); + if (!commonDir) return checkoutRoot; + return path.dirname(path.resolve(checkoutRoot, commonDir)); +} + +async function findRegisteredGitWorktreeByBranch(repoRoot: string, branchName: string): Promise { + const raw = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => null); + if (!raw) return null; + + const expectedBranchRef = `refs/heads/${branchName}`; + for (const entry of parseGitWorktreeListPorcelain(raw)) { + if (entry.branch !== expectedBranchRef) continue; + return path.resolve(entry.worktree); + } + + return null; +} + +async function isGitCheckout(cwd: string): Promise { + return Boolean(await runGit(["rev-parse", "--git-dir"], cwd).catch(() => null)); +} + async function detectDefaultBranch(repoRoot: string): Promise { // Try the explicit remote HEAD first (set by git clone or git remote set-head) try { @@ -543,6 +614,56 @@ async function directoryExists(value: string) { return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false); } +async function listLinkedGitWorktreePaths(repoRoot: string): Promise> { + const output = await runGit(["worktree", "list", "--porcelain"], repoRoot); + const paths = new Set(); + for (const line of output.split("\n")) { + if (!line.startsWith("worktree ")) continue; + const worktree = line.slice("worktree ".length).trim(); + if (!worktree) continue; + paths.add(path.resolve(worktree)); + } + return paths; +} + +async function validateLinkedGitWorktree(input: { + repoRoot: string; + worktreePath: string; + expectedBranchName: string | null; +}): Promise<{ valid: true } | { valid: false; reason: string }> { + const resolvedWorktreePath = path.resolve(input.worktreePath); + const listedWorktrees = await listLinkedGitWorktreePaths(input.repoRoot); + if (!listedWorktrees.has(resolvedWorktreePath)) { + return { + valid: false, + reason: "path is not registered in `git worktree list`", + }; + } + + const worktreeTopLevel = await runGit(["rev-parse", "--show-toplevel"], resolvedWorktreePath).catch(() => null); + if (!worktreeTopLevel || path.resolve(worktreeTopLevel) !== resolvedWorktreePath) { + return { + valid: false, + reason: "git resolves this path to a different repository root", + }; + } + + if (input.expectedBranchName) { + const currentBranch = await runGit( + ["symbolic-ref", "--quiet", "--short", "HEAD"], + resolvedWorktreePath, + ).catch(() => null); + if (currentBranch !== input.expectedBranchName) { + return { + valid: false, + reason: `worktree HEAD is on "${currentBranch ?? ""}" instead of "${input.expectedBranchName}"`, + }; + } + } + + return { valid: true }; +} + function terminateChildProcess(child: ChildProcess) { if (!child.pid) return; if (process.platform !== "win32") { @@ -716,13 +837,13 @@ async function recordWorkspaceCommandOperation( ) { if (!recorder) { await runWorkspaceCommand(input); - return; + return null; } let stdout = ""; let stderr = ""; let code: number | null = null; - await recorder.recordOperation({ + const operation = await recorder.recordOperation({ phase: input.phase, command: input.command, cwd: input.cwd, @@ -757,7 +878,7 @@ async function recordWorkspaceCommandOperation( }, }); - if (code === 0) return; + if (code === 0) return operation; const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n"); throw new Error( @@ -878,7 +999,7 @@ export async function realizeExecutionWorkspace(input: { }; } - const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd); + const repoRoot = await resolveGitOwnerRepoRoot(input.base.baseCwd); const branchTemplate = asString(rawStrategy.branchTemplate, "{{issue.identifier}}-{{slug}}"); const renderedBranch = renderWorkspaceTemplate(branchTemplate, { issue: input.issue, @@ -901,51 +1022,74 @@ export async function realizeExecutionWorkspace(input: { await fs.mkdir(worktreeParentDir, { recursive: true }); + async function reuseExistingWorktree(reusablePath: string) { + if (input.recorder) { + await input.recorder.recordOperation({ + phase: "worktree_prepare", + cwd: repoRoot, + metadata: { + repoRoot, + worktreePath: reusablePath, + branchName, + baseRef, + created: false, + reused: true, + }, + run: async () => ({ + status: "succeeded", + exitCode: 0, + system: `Reused existing git worktree at ${reusablePath}\n`, + }), + }); + } + await provisionExecutionWorktree({ + strategy: rawStrategy, + base: input.base, + repoRoot, + worktreePath: reusablePath, + branchName, + issue: input.issue, + agent: input.agent, + created: false, + recorder: input.recorder ?? null, + }); + return { + ...input.base, + strategy: "git_worktree" as const, + cwd: reusablePath, + branchName, + worktreePath: reusablePath, + warnings: [], + created: false, + }; + } + + async function validateReusableWorktree(reusablePath: string) { + return await validateLinkedGitWorktree({ + repoRoot, + worktreePath: reusablePath, + expectedBranchName: branchName, + }).catch(() => null); + } + const existingWorktree = await directoryExists(worktreePath); if (existingWorktree) { - const existingGitDir = await runGit(["rev-parse", "--git-dir"], worktreePath).catch(() => null); - if (existingGitDir) { - if (input.recorder) { - await input.recorder.recordOperation({ - phase: "worktree_prepare", - cwd: repoRoot, - metadata: { - repoRoot, - worktreePath, - branchName, - baseRef, - created: false, - reused: true, - }, - run: async () => ({ - status: "succeeded", - exitCode: 0, - system: `Reused existing git worktree at ${worktreePath}\n`, - }), - }); - } - await provisionExecutionWorktree({ - strategy: rawStrategy, - base: input.base, - repoRoot, - worktreePath, - branchName, - issue: input.issue, - agent: input.agent, - created: false, - recorder: input.recorder ?? null, - }); - return { - ...input.base, - strategy: "git_worktree", - cwd: worktreePath, - branchName, - worktreePath, - warnings: [], - created: false, - }; + const validation = await validateReusableWorktree(worktreePath); + if (validation?.valid) { + return await reuseExistingWorktree(worktreePath); } - throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a git worktree.`); + const reason = validation && !validation.valid ? ` (${validation.reason})` : ""; + throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a reusable git worktree${reason}.`); + } + + const registeredBranchWorktree = await findRegisteredGitWorktreeByBranch(repoRoot, branchName); + if (registeredBranchWorktree) { + const validation = await validateReusableWorktree(registeredBranchWorktree); + if (validation?.valid) { + return await reuseExistingWorktree(registeredBranchWorktree); + } + const reason = validation && !validation.valid ? ` (${validation.reason})` : ""; + throw new Error(`Registered worktree for branch "${branchName}" at "${registeredBranchWorktree}" is not reusable${reason}.`); } try { @@ -967,21 +1111,32 @@ export async function realizeExecutionWorkspace(input: { if (!gitErrorIncludes(error, "already exists")) { throw error; } - await recordGitOperation(input.recorder, { - phase: "worktree_prepare", - args: ["worktree", "add", worktreePath, branchName], - cwd: repoRoot, - metadata: { - repoRoot, - worktreePath, - branchName, - baseRef, - created: false, - reusedExistingBranch: true, - }, - successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`, - failureLabel: `git worktree add ${worktreePath}`, - }); + try { + await recordGitOperation(input.recorder, { + phase: "worktree_prepare", + args: ["worktree", "add", worktreePath, branchName], + cwd: repoRoot, + metadata: { + repoRoot, + worktreePath, + branchName, + baseRef, + created: false, + reusedExistingBranch: true, + }, + successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`, + failureLabel: `git worktree add ${worktreePath}`, + }); + } catch (attachError) { + if (!gitErrorIncludes(attachError, "already checked out")) { + throw attachError; + } + const reusablePath = await findRegisteredGitWorktreeByBranch(repoRoot, branchName); + if (!reusablePath || !await isGitCheckout(reusablePath)) { + throw attachError; + } + return await reuseExistingWorktree(reusablePath); + } } await provisionExecutionWorktree({ strategy: rawStrategy, @@ -1006,6 +1161,147 @@ export async function realizeExecutionWorkspace(input: { }; } +export async function ensurePersistedExecutionWorkspaceAvailable(input: { + base: ExecutionWorkspaceInput; + workspace: { + mode: string | null | undefined; + strategyType: string | null | undefined; + cwd: string | null | undefined; + providerRef: string | null | undefined; + projectId: string | null | undefined; + projectWorkspaceId: string | null | undefined; + repoUrl: string | null | undefined; + baseRef: string | null | undefined; + branchName: string | null | undefined; + config?: { + provisionCommand?: string | null; + } | null; + }; + issue: ExecutionWorkspaceIssueRef | null; + agent: ExecutionWorkspaceAgentRef; + recorder?: WorkspaceOperationRecorder | null; +}): Promise { + const cwd = asString(input.workspace.cwd ?? input.workspace.providerRef, "").trim(); + if (!cwd) return null; + + const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary"; + const realized: RealizedExecutionWorkspace = { + baseCwd: input.base.baseCwd, + source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session", + projectId: input.workspace.projectId ?? input.base.projectId, + workspaceId: input.workspace.projectWorkspaceId ?? input.base.workspaceId, + repoUrl: input.workspace.repoUrl ?? input.base.repoUrl, + repoRef: input.workspace.baseRef ?? input.base.repoRef, + strategy, + cwd, + branchName: input.workspace.branchName ?? null, + worktreePath: strategy === "git_worktree" ? (input.workspace.providerRef ?? cwd) : null, + warnings: [], + created: false, + }; + const provisionCommand = asString(input.workspace.config?.provisionCommand, "").trim(); + + if (strategy !== "git_worktree") { + return realized; + } + if (await directoryExists(cwd)) { + if (provisionCommand) { + const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd); + await provisionExecutionWorktree({ + strategy: { + type: "git_worktree", + provisionCommand, + }, + base: input.base, + repoRoot, + worktreePath: realized.worktreePath ?? cwd, + branchName: realized.branchName ?? "", + issue: input.issue, + agent: input.agent, + created: false, + recorder: input.recorder ?? null, + }); + } + return realized; + } + + const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd); + const worktreePath = realized.worktreePath ?? cwd; + const branchName = asString(input.workspace.branchName, "").trim(); + if (!branchName) { + throw new Error(`Execution workspace "${cwd}" is missing and cannot be restored because no branch name is recorded.`); + } + + await fs.mkdir(path.dirname(worktreePath), { recursive: true }); + await runGit(["worktree", "prune"], repoRoot).catch(() => {}); + + let created = false; + try { + await recordGitOperation(input.recorder, { + phase: "worktree_prepare", + args: ["worktree", "add", worktreePath, branchName], + cwd: repoRoot, + metadata: { + repoRoot, + worktreePath, + branchName, + baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null, + created: false, + restored: true, + }, + successMessage: `Reattached missing git worktree at ${worktreePath}\n`, + failureLabel: `git worktree add ${worktreePath}`, + }); + } catch (error) { + if ( + !gitErrorIncludes(error, "invalid reference") + && !gitErrorIncludes(error, "not a commit") + && !gitErrorIncludes(error, "unknown revision") + ) { + throw error; + } + const baseRef = input.workspace.baseRef ?? await detectDefaultBranch(repoRoot) ?? "HEAD"; + await recordGitOperation(input.recorder, { + phase: "worktree_prepare", + args: ["worktree", "add", "-b", branchName, worktreePath, baseRef], + cwd: repoRoot, + metadata: { + repoRoot, + worktreePath, + branchName, + baseRef, + created: true, + restored: true, + }, + successMessage: `Recreated missing git worktree at ${worktreePath}\n`, + failureLabel: `git worktree add ${worktreePath}`, + }); + created = true; + } + + await provisionExecutionWorktree({ + strategy: { + type: "git_worktree", + ...(provisionCommand ? { provisionCommand } : {}), + }, + base: input.base, + repoRoot, + worktreePath, + branchName, + issue: input.issue, + agent: input.agent, + created, + recorder: input.recorder ?? null, + }); + + return { + ...realized, + cwd: worktreePath, + worktreePath, + created, + }; +} + export async function cleanupExecutionWorkspaceArtifacts(input: { workspace: { id: string; @@ -1299,6 +1595,83 @@ function resolveRuntimeServiceReuseIdentity(input: { }; } +function resolveWorkspaceCommandExecution(input: { + command: Record; + workspace: RealizedExecutionWorkspace; + agent: ExecutionWorkspaceAgentRef; + issue: ExecutionWorkspaceIssueRef | null; + adapterEnv: Record; +}) { + const name = + asString(input.command.name, "") + || asString(input.command.label, "") + || asString(input.command.title, "") + || "workspace command"; + const command = asString(input.command.command, ""); + const templateData = buildTemplateData({ + workspace: input.workspace, + agent: input.agent, + issue: input.issue, + adapterEnv: input.adapterEnv, + port: null, + }); + const cwd = resolveConfiguredPath( + renderTemplate(asString(input.command.cwd, "."), templateData), + input.workspace.cwd, + ); + const env = { + ...sanitizeRuntimeServiceBaseEnv(process.env), + ...input.adapterEnv, + ...renderRuntimeServiceEnv({ + envConfig: parseObject(input.command.env), + templateData, + }), + } as Record; + + return { + name, + command, + cwd, + env, + }; +} + +export async function runWorkspaceJobForControl(input: { + actor: ExecutionWorkspaceAgentRef; + issue: ExecutionWorkspaceIssueRef | null; + workspace: RealizedExecutionWorkspace; + command: Record; + adapterEnv?: Record; + recorder?: WorkspaceOperationRecorder | null; + metadata?: Record | null; +}) { + const resolved = resolveWorkspaceCommandExecution({ + command: input.command, + workspace: input.workspace, + agent: input.actor, + issue: input.issue, + adapterEnv: input.adapterEnv ?? {}, + }); + if (!resolved.command) { + throw new Error(`Workspace job "${resolved.name}" is missing command`); + } + + await ensureServerWorkspaceLinksCurrent(resolved.cwd); + return await recordWorkspaceCommandOperation(input.recorder, { + phase: "workspace_provision", + command: resolved.command, + cwd: resolved.cwd, + env: resolved.env, + label: `Workspace job "${resolved.name}"`, + metadata: { + workspaceCommandKind: "job", + workspaceCommandName: resolved.name, + ...(input.metadata ?? {}), + }, + successMessage: `Completed workspace job "${resolved.name}"\n`, + }); +} + function resolveServiceScopeId(input: { service: Record; workspace: RealizedExecutionWorkspace; @@ -1325,6 +1698,21 @@ function resolveServiceScopeId(input: { return { scopeType: "run" as const, scopeId: input.runId }; } +function looksLikeWorkspaceDevServerCommand(command: string) { + const normalized = command.trim().toLowerCase(); + if (!normalized) return false; + return /(?:^|\s)(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?dev(?:\s|$)/.test(normalized); +} + +export function resolveWorkspaceRuntimeReadinessTimeoutSec(service: Record) { + const readiness = parseObject(service.readiness); + const explicitTimeoutSec = asNumber(readiness.timeoutSec, 0); + if (explicitTimeoutSec > 0) { + return Math.max(1, explicitTimeoutSec); + } + return looksLikeWorkspaceDevServerCommand(asString(service.command, "")) ? 90 : 30; +} + async function waitForReadiness(input: { service: Record; url: string | null; @@ -1332,7 +1720,7 @@ async function waitForReadiness(input: { const readiness = parseObject(input.service.readiness); const readinessType = asString(readiness.type, ""); if (readinessType !== "http" || !input.url) return; - const timeoutSec = Math.max(1, asNumber(readiness.timeoutSec, 30)); + const timeoutSec = resolveWorkspaceRuntimeReadinessTimeoutSec(input.service); const intervalMs = Math.max(100, asNumber(readiness.intervalMs, 500)); const deadline = Date.now() + timeoutSec * 1000; let lastError = "service did not become ready"; @@ -1654,6 +2042,11 @@ async function startLocalRuntimeService(input: { detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], }); + const spawnErrorPromise = new Promise((_, reject) => { + child.once("error", (err) => { + reject(err); + }); + }); let stderrExcerpt = ""; let stdoutExcerpt = ""; child.stdout?.on("data", async (chunk) => { @@ -1668,7 +2061,10 @@ async function startLocalRuntimeService(input: { }); try { - await waitForReadiness({ service: input.service, url }); + await Promise.race([ + waitForReadiness({ service: input.service, url }), + spawnErrorPromise, + ]); } catch (err) { terminateChildProcess(child); throw new Error( @@ -1832,10 +2228,78 @@ function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord } function readRuntimeServiceEntries(config: Record) { - const runtime = parseObject(config.workspaceRuntime); - return Array.isArray(runtime.services) - ? runtime.services.filter((entry): entry is Record => typeof entry === "object" && entry !== null) - : []; + return listWorkspaceServiceCommandDefinitions(parseObject(config.workspaceRuntime)) + .map((command) => command.rawConfig); +} + +export function listConfiguredRuntimeServiceEntries(config: Record) { + return readRuntimeServiceEntries(config); +} + +function readConfiguredServiceStates(config: Record) { + const raw = parseObject(config.serviceStates); + const states: WorkspaceRuntimeServiceStateMap = {}; + for (const [key, value] of Object.entries(raw)) { + if (value === "running" || value === "stopped") { + states[key] = value; + } + } + return states; +} + +export function buildWorkspaceRuntimeDesiredStatePatch(input: { + config: Record; + currentDesiredState: WorkspaceRuntimeDesiredState | null; + currentServiceStates: WorkspaceRuntimeServiceStateMap | null | undefined; + action: "start" | "stop" | "restart"; + serviceIndex?: number | null; +}): { + desiredState: WorkspaceRuntimeDesiredState; + serviceStates: WorkspaceRuntimeServiceStateMap | null; +} { + const configuredServices = listConfiguredRuntimeServiceEntries(input.config); + const fallbackState: WorkspaceRuntimeDesiredState = input.currentDesiredState === "running" ? "running" : "stopped"; + const nextServiceStates: WorkspaceRuntimeServiceStateMap = {}; + + for (let index = 0; index < configuredServices.length; index += 1) { + nextServiceStates[String(index)] = input.currentServiceStates?.[String(index)] ?? fallbackState; + } + + const nextState: WorkspaceRuntimeDesiredState = input.action === "stop" ? "stopped" : "running"; + if (input.serviceIndex === undefined || input.serviceIndex === null) { + for (let index = 0; index < configuredServices.length; index += 1) { + nextServiceStates[String(index)] = nextState; + } + } else if (input.serviceIndex >= 0 && input.serviceIndex < configuredServices.length) { + nextServiceStates[String(input.serviceIndex)] = nextState; + } + + const desiredState = Object.values(nextServiceStates).some((state) => state === "running") ? "running" : "stopped"; + + return { + desiredState, + serviceStates: Object.keys(nextServiceStates).length > 0 ? nextServiceStates : null, + }; +} + +function selectRuntimeServiceEntries(input: { + config: Record; + serviceIndex?: number | null; + respectDesiredStates?: boolean; + defaultDesiredState?: WorkspaceRuntimeDesiredState | null; + serviceStates?: WorkspaceRuntimeServiceStateMap | null; +}) { + const entries = listConfiguredRuntimeServiceEntries(input.config); + const states = input.serviceStates ?? readConfiguredServiceStates(input.config); + const fallbackState: WorkspaceRuntimeDesiredState = input.defaultDesiredState === "running" ? "running" : "stopped"; + + return entries.filter((_, index) => { + if (input.serviceIndex !== undefined && input.serviceIndex !== null) { + return index === input.serviceIndex; + } + if (!input.respectDesiredStates) return true; + return (states[String(index)] ?? fallbackState) === "running"; + }); } export async function ensureRuntimeServicesForRun(input: { @@ -1930,8 +2394,16 @@ export async function startRuntimeServicesForWorkspaceControl(input: { config: Record; adapterEnv: Record; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + serviceIndex?: number | null; + respectDesiredStates?: boolean; }): Promise { - const rawServices = readRuntimeServiceEntries(input.config); + const rawServices = selectRuntimeServiceEntries({ + config: input.config, + serviceIndex: input.serviceIndex, + respectDesiredStates: input.respectDesiredStates, + defaultDesiredState: input.config.desiredState === "running" ? "running" : "stopped", + serviceStates: readConfiguredServiceStates(input.config), + }); const refs: RuntimeServiceRef[] = []; const invocationId = input.invocationId ?? randomUUID(); @@ -2021,10 +2493,12 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: { db?: Db; executionWorkspaceId: string; workspaceCwd?: string | null; + runtimeServiceId?: string | null; }) { const normalizedWorkspaceCwd = input.workspaceCwd ? path.resolve(input.workspaceCwd) : null; const matchingServiceIds = Array.from(runtimeServicesById.values()) .filter((record) => { + if (input.runtimeServiceId) return record.id === input.runtimeServiceId; if (record.executionWorkspaceId === input.executionWorkspaceId) return true; if (!normalizedWorkspaceCwd || !record.cwd) return false; const resolvedCwd = path.resolve(record.cwd); @@ -2040,19 +2514,37 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: { } if (input.db) { - await markPersistedRuntimeServicesStoppedForExecutionWorkspace({ - db: input.db, - executionWorkspaceId: input.executionWorkspaceId, - }); + if (input.runtimeServiceId) { + const now = new Date(); + await input.db + .update(workspaceRuntimeServices) + .set({ + status: "stopped", + healthStatus: "unknown", + stoppedAt: now, + lastUsedAt: now, + updatedAt: now, + }) + .where(eq(workspaceRuntimeServices.id, input.runtimeServiceId)); + } else { + await markPersistedRuntimeServicesStoppedForExecutionWorkspace({ + db: input.db, + executionWorkspaceId: input.executionWorkspaceId, + }); + } } } export async function stopRuntimeServicesForProjectWorkspace(input: { db?: Db; projectWorkspaceId: string; + runtimeServiceId?: string | null; }) { const matchingServiceIds = Array.from(runtimeServicesById.values()) - .filter((record) => record.projectWorkspaceId === input.projectWorkspaceId && record.scopeType === "project_workspace") + .filter((record) => { + if (input.runtimeServiceId) return record.id === input.runtimeServiceId; + return record.projectWorkspaceId === input.projectWorkspaceId && record.scopeType === "project_workspace"; + }) .map((record) => record.id); for (const serviceId of matchingServiceIds) { @@ -2071,11 +2563,13 @@ export async function stopRuntimeServicesForProjectWorkspace(input: { updatedAt: now, }) .where( - and( - eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId), - eq(workspaceRuntimeServices.scopeType, "project_workspace"), - inArray(workspaceRuntimeServices.status, ["starting", "running"]), - ), + input.runtimeServiceId + ? eq(workspaceRuntimeServices.id, input.runtimeServiceId) + : and( + eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId), + eq(workspaceRuntimeServices.scopeType, "project_workspace"), + inArray(workspaceRuntimeServices.status, ["starting", "running"]), + ), ); } } @@ -2211,6 +2705,7 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) { const projectWorkspaceRows = await db .select() .from(projectWorkspaces); + const projectWorkspaceRowsById = new Map(projectWorkspaceRows.map((row) => [row.id, row] as const)); for (const row of projectWorkspaceRows) { const runtimeConfig = readProjectWorkspaceRuntimeConfig((row.metadata as Record | null) ?? null); @@ -2235,8 +2730,13 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) { warnings: [], created: false, }, - config: { workspaceRuntime: runtimeConfig.workspaceRuntime }, + config: { + workspaceRuntime: runtimeConfig.workspaceRuntime, + desiredState: runtimeConfig.desiredState, + serviceStates: runtimeConfig.serviceStates ?? null, + }, adapterEnv: {}, + respectDesiredStates: true, }); if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length; } catch { @@ -2251,7 +2751,13 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) { for (const row of executionWorkspaceRows) { const config = readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null); - if (config?.desiredState !== "running" || !config.workspaceRuntime || !row.cwd) continue; + const inheritedRuntimeConfig = row.projectWorkspaceId + ? readProjectWorkspaceRuntimeConfig( + (projectWorkspaceRowsById.get(row.projectWorkspaceId)?.metadata as Record | null) ?? null, + )?.workspaceRuntime ?? null + : null; + const effectiveRuntimeConfig = config?.workspaceRuntime ?? inheritedRuntimeConfig; + if (config?.desiredState !== "running" || !effectiveRuntimeConfig || !row.cwd) continue; try { const refs = await startRuntimeServicesForWorkspaceControl({ @@ -2279,8 +2785,13 @@ export async function restartDesiredRuntimeServicesOnStartup(db: Db) { created: false, }, executionWorkspaceId: row.id, - config: { workspaceRuntime: config.workspaceRuntime }, + config: { + workspaceRuntime: effectiveRuntimeConfig, + desiredState: config.desiredState, + serviceStates: config.serviceStates ?? null, + }, adapterEnv: {}, + respectDesiredStates: true, }); if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length; } catch { diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts index 78b301eef35..1915e2ffdb1 100644 --- a/server/src/types/express.d.ts +++ b/server/src/types/express.d.ts @@ -6,9 +6,16 @@ declare global { actor: { type: "board" | "agent" | "none"; userId?: string; + userName?: string | null; + userEmail?: string | null; agentId?: string; companyId?: string; companyIds?: string[]; + memberships?: Array<{ + companyId: string; + membershipRole?: string | null; + status?: string; + }>; isInstanceAdmin?: boolean; keyId?: string; runId?: string; diff --git a/server/src/vite-html-renderer.ts b/server/src/vite-html-renderer.ts new file mode 100644 index 00000000000..983a3ea1586 --- /dev/null +++ b/server/src/vite-html-renderer.ts @@ -0,0 +1,86 @@ +import fs from "node:fs"; +import path from "node:path"; + +type ViteWatcherEvent = "add" | "change" | "unlink"; + +export interface ViteWatcherHost { + watcher?: { + on?: (event: ViteWatcherEvent, listener: (file: string) => void) => unknown; + off?: (event: ViteWatcherEvent, listener: (file: string) => void) => unknown; + }; +} + +export interface CachedViteHtmlRenderer { + render(_url: string): Promise; + dispose(): void; +} + +const WATCHER_EVENTS: ViteWatcherEvent[] = ["add", "change", "unlink"]; +const MAIN_ENTRY_TAG = ''; +const VITE_CLIENT_TAG = ''; +const REACT_REFRESH_PREAMBLE = ``; + +function injectViteDevPreamble(html: string): string { + let injectedHtml = html; + if (!injectedHtml.includes('"/@react-refresh"') && !injectedHtml.includes("'/@react-refresh'")) { + injectedHtml = injectedHtml.includes("") + ? injectedHtml.replace("", ` ${REACT_REFRESH_PREAMBLE}\n `) + : `${REACT_REFRESH_PREAMBLE}\n${injectedHtml}`; + } + if (injectedHtml.includes(VITE_CLIENT_TAG)) return injectedHtml; + if (injectedHtml.includes(MAIN_ENTRY_TAG)) { + return injectedHtml.replace(MAIN_ENTRY_TAG, `${VITE_CLIENT_TAG}\n ${MAIN_ENTRY_TAG}`); + } + return injectedHtml.replace("", ` ${VITE_CLIENT_TAG}\n `); +} + +export function createCachedViteHtmlRenderer(opts: { + vite: ViteWatcherHost; + uiRoot: string; + brandHtml?: (html: string) => string; +}): CachedViteHtmlRenderer { + const uiRoot = path.resolve(opts.uiRoot); + const templatePath = path.resolve(uiRoot, "index.html"); + const brandHtml = opts.brandHtml ?? ((html: string) => html); + let cachedHtml: string | null = null; + + function loadHtml(): string { + if (cachedHtml === null) { + const rawTemplate = fs.readFileSync(templatePath, "utf-8"); + cachedHtml = injectViteDevPreamble(brandHtml(rawTemplate)); + } + return cachedHtml; + } + + function invalidate(): void { + cachedHtml = null; + } + + function onWatchEvent(filePath: string): void { + const resolvedPath = path.resolve(filePath); + if (resolvedPath === templatePath) { + invalidate(); + } + } + + for (const eventName of WATCHER_EVENTS) { + opts.vite.watcher?.on?.(eventName, onWatchEvent); + } + + return { + render(): Promise { + return Promise.resolve(loadHtml()); + }, + + dispose(): void { + for (const eventName of WATCHER_EVENTS) { + opts.vite.watcher?.off?.(eventName, onWatchEvent); + } + }, + }; +} diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts index 3656b7dc6a6..2c6170bca59 100644 --- a/server/src/worktree-config.ts +++ b/server/src/worktree-config.ts @@ -118,11 +118,19 @@ function resolveWorktreeRuntimeContext( const configPath = resolvePaperclipConfigPath(overrideConfigPath); const envPath = resolvePaperclipEnvPath(configPath); + const persistedEnv = readEnvEntries(envPath); const worktreeRoot = path.resolve(path.dirname(configPath), ".."); - const worktreeName = nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? path.basename(worktreeRoot); - const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? sanitizeWorktreeInstanceId(worktreeName); + const worktreeName = + nonEmpty(persistedEnv.PAPERCLIP_WORKTREE_NAME) ?? + nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? + path.basename(worktreeRoot); + const instanceId = + nonEmpty(persistedEnv.PAPERCLIP_INSTANCE_ID) ?? + nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? + sanitizeWorktreeInstanceId(worktreeName); const homeDir = resolveHomeAwarePath( - nonEmpty(env.PAPERCLIP_HOME) ?? + nonEmpty(persistedEnv.PAPERCLIP_HOME) ?? + nonEmpty(env.PAPERCLIP_HOME) ?? nonEmpty(env.PAPERCLIP_WORKTREES_DIR) ?? "~/.paperclip-worktrees", ); diff --git a/skills/paperclip-create-agent/SKILL.md b/skills/paperclip-create-agent/SKILL.md index e3178d53aeb..50dab4e2544 100644 --- a/skills/paperclip-create-agent/SKILL.md +++ b/skills/paperclip-create-agent/SKILL.md @@ -49,14 +49,19 @@ curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-configura -H "Authorization: Bearer $PAPERCLIP_API_KEY" ``` -5. Discover allowed agent icons and pick one that matches the role. +5. Read the reusable agent instruction templates before drafting the hire. If the role matches an existing pattern, start from that template and adapt it to the company, manager, adapter, and workspace. + +Reference: +`skills/paperclip-create-agent/references/agent-instruction-templates.md` + +6. Discover allowed agent icons and pick one that matches the role. ```sh curl -sS "$PAPERCLIP_API_URL/llms/agent-icons.txt" \ -H "Authorization: Bearer $PAPERCLIP_API_KEY" ``` -6. Draft the new hire config: +7. Draft the new hire config: - role/title/name - icon (required in practice; use one from `/llms/agent-icons.txt`) - reporting line (`reportsTo`) @@ -66,9 +71,11 @@ curl -sS "$PAPERCLIP_API_URL/llms/agent-icons.txt" \ - leave timer heartbeats off by default; only set `runtimeConfig.heartbeat.enabled=true` with an `intervalSec` when the role genuinely needs scheduled recurring work or the user explicitly asked for it - capabilities - run prompt in adapter config (`promptTemplate` where applicable) +- for coding or execution agents, include the Paperclip execution contract: start actionable work in the same heartbeat; do not stop at a plan unless planning was requested; leave durable progress with a clear next action; use child issues for long or parallel delegated work instead of polling; mark blocked work with owner/action; respect budget, pause/cancel, approval gates, and company boundaries. +- instruction text such as `AGENTS.md`, using a reusable template when one fits; for local managed-bundle adapters, put the adapted `AGENTS.md` content in `adapterConfig.promptTemplate` unless you are a board user intentionally managing bundle paths/files - source issue linkage (`sourceIssueId` or `sourceIssueIds`) when this hire came from an issue -7. Submit hire request. +8. Submit hire request. ```sh curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-hires" \ @@ -89,7 +96,7 @@ curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-h }' ``` -8. Handle governance state: +9. Handle governance state: - if response has `approval`, hire is `pending_approval` - monitor and discuss on approval thread - when the board approves, you will be woken with `PAPERCLIP_APPROVAL_ID`; read linked issues and close/comment follow-up @@ -133,6 +140,7 @@ Before sending a hire request: - if the role needs skills, make sure they already exist in the company library or install them first using the Paperclip company-skills workflow - Reuse proven config patterns from related agents where possible. +- Reuse a proven instruction template when the role matches one in `skills/paperclip-create-agent/references/agent-instruction-templates.md`; update placeholders and remove irrelevant guidance before submitting the hire. - Set a concrete `icon` from `/llms/agent-icons.txt` so the new hire is identifiable in org and task views. - Avoid secrets in plain text unless required by adapter behavior. - Ensure reporting line is correct and in-company. @@ -142,3 +150,6 @@ Before sending a hire request: For endpoint payload shapes and full examples, read: `skills/paperclip-create-agent/references/api-reference.md` + +For reusable `AGENTS.md` starting points, read: +`skills/paperclip-create-agent/references/agent-instruction-templates.md` diff --git a/skills/paperclip-create-agent/references/agent-instruction-templates.md b/skills/paperclip-create-agent/references/agent-instruction-templates.md new file mode 100644 index 00000000000..d9174f7ef85 --- /dev/null +++ b/skills/paperclip-create-agent/references/agent-instruction-templates.md @@ -0,0 +1,138 @@ +# Agent Instruction Templates + +Use this reference when hiring or creating agents. Start from an existing pattern when the requested role is close, then adapt the text to the company, reporting line, adapter, workspace, permissions, and task type. + +These templates are intentionally separate from the main Paperclip heartbeat skill so the core wake procedure stays short. + +## Index + +| Template | Use when hiring | Typical adapter | +|---|---|---| +| `Coder` | Software engineers who implement code, debug issues, write tests, and coordinate with QA/CTO | `codex_local`, `claude_local`, `cursor`, or another coding adapter | +| `QA` | QA engineers who reproduce bugs, validate fixes, capture screenshots, and report actionable findings | `claude_local` or another browser-capable adapter | + +## How To Apply A Template + +1. Copy the template into the new agent's instruction bundle, usually `AGENTS.md`. For hire requests using local managed-bundle adapters, this usually means setting the adapted template as `adapterConfig.promptTemplate`; Paperclip materializes it into `AGENTS.md`. +2. Replace placeholders like `{{companyName}}`, `{{managerTitle}}`, `{{issuePrefix}}`, and URLs. +3. Remove tools or workflows the target adapter cannot use. +4. Keep the Paperclip heartbeat requirement and task-comment requirement. +5. Add role-specific skills or reference files only when they are actually installed or bundled. + +## Template: Coder + +Recommended role fields: + +- `name`: `Coder`, `CodexCoder`, `ClaudeCoder`, or a model/tool-specific name +- `role`: `engineer` +- `title`: `Software Engineer` +- `icon`: `code` +- `capabilities`: `Implements coding tasks, writes and edits code, debugs issues, adds focused tests, and coordinates with QA and engineering leadership.` + +`AGENTS.md`: + +```md +You are agent {{agentName}} (Coder / Software Engineer) at {{companyName}}. + +When you wake up, follow the Paperclip skill. It contains the full heartbeat procedure. + +You are a software engineer. Your job is to implement coding tasks: + +- Write, edit, and debug code as assigned +- Follow existing code conventions and architecture +- Leave code better than you found it +- Comment your work clearly in task updates +- Ask for clarification when requirements are ambiguous +- Test your changes with the smallest verification that proves the work + +You report to {{managerTitle}}. Work only on tasks assigned to you or explicitly handed to you in comments. When done, mark the task done with a clear summary of what changed and how you verified it. + +Commit things in logical commits as you go when the work is good. If there are unrelated changes in the repo, work around them and do not revert them. Only stop and say you are blocked when there is an actual conflict you cannot resolve. + +Make sure you know the success condition for each task. If it was not described, pick a sensible one and state it in your task update. Before finishing, check whether the success condition was achieved. If it was not, keep iterating or escalate with a concrete blocker. + +Keep the work moving until it is done. If you need QA to review it, ask QA. If you need your manager to review it, ask them. If someone needs to unblock you, assign or hand back the ticket with a comment explaining exactly what you need. + +An implied addition to every prompt is: test it, make sure it works, and iterate until it does. If it is a shell script, run a safe version. If it is code, run the smallest relevant tests or checks. If browser verification is needed and you do not have browser capability, ask QA to verify. + +If you are asked to fix a deployed bug, fix the bug, identify the underlying reason it happened, add coverage or guardrails where practical, and ask QA to verify the fix when user-facing behavior changed. + +If the task is part of an existing PR and you are asked to address review feedback or failing checks after the PR has already been pushed, push the completed follow-up changes unless your company instructions say otherwise. + +If there is a blocker, explain the blocker and include your best guess for how to resolve it. Do not only say that it is blocked. + +When you run tests, do not default to the entire test suite. Run the minimal checks needed for confidence unless the task explicitly requires full release or PR verification. + +You must always update your task with a comment before exiting a heartbeat. +``` + +## Template: QA + +Recommended role fields: + +- `name`: `QA` +- `role`: `qa` +- `title`: `QA Engineer` +- `icon`: `bug` +- `capabilities`: `Owns manual and automated QA workflows, reproduces defects, validates fixes end-to-end, captures evidence, and reports concise actionable findings.` + +`AGENTS.md`: + +```md +You are agent {{agentName}} (QA) at {{companyName}}. + +When you wake up, follow the Paperclip skill. It contains the full heartbeat procedure. + +You are the QA Engineer. Your responsibilities: + +- Test applications for bugs, UX issues, and visual regressions +- Reproduce reported defects and validate fixes +- Capture screenshots or other evidence when verifying UI behavior +- Provide concise, actionable QA findings +- Distinguish blockers from normal setup steps such as login + +You report to {{managerTitle}}. Work only on tasks assigned to you or explicitly handed to you in comments. + +Keep the work moving until it is done. If you need someone to review it, ask them. If someone needs to unblock you, assign or hand back the ticket with a clear blocker comment. + +You must always update your task with a comment. + +## Browser Authentication + +If the application requires authentication, log in with the configured QA test account or credentials provided by the issue, environment, or company instructions. Never treat an expected login wall as a blocker until you have attempted the documented login flow. + +For authenticated browser tasks: + +1. Open the target URL. +2. If redirected to an auth page, log in with the available QA credentials. +3. Wait for the target page to finish loading. +4. Continue the test from the authenticated state. + +## Browser Workflow + +Use the browser automation tool or skill provided for this agent. Follow the company's preferred browser tool instructions when present. + +For UI verification tasks: + +1. Open the target URL. +2. Exercise the requested workflow. +3. Capture a screenshot or other evidence when the UI result matters. +4. Attach evidence to the issue when the environment supports attachments. +5. Post a comment with what was verified. + +## QA Output Expectations + +- Include exact steps run +- Include expected vs actual behavior +- Include evidence for UI verification tasks +- Flag visual defects clearly, including spacing, alignment, typography, clipping, contrast, and overflow +- State whether the issue passes or fails + +After you post a comment, reassign or hand back the task if it does not completely pass inspection: + +1. Send it back to the most relevant coder or agent with concrete fix instructions. +2. Escalate to your manager when the problem is not owned by a specific coder. +3. Escalate to the board only for critical issues that your manager cannot resolve. + +Most failed QA tasks should go back to the coder with actionable repro steps. If the task passes, mark it done. +``` diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 120d851be9e..2eecf53e555 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -105,6 +105,14 @@ If `currentParticipant` does **not** match you, do not try to advance the stage. **Step 7 — Do the work.** Use your tools and capabilities. +Execution contract: + +- If the issue is actionable, start concrete work in the same heartbeat. Do not stop at a plan unless the issue specifically asks for planning. +- Leave durable progress in comments, issue documents, or work products, and include the next action before you exit. +- Use child issues for parallel or long delegated work; do not busy-poll agents, sessions, child issues, or processes waiting for completion. +- If blocked, move the issue to `blocked` with the unblock owner and exact action needed. +- Respect budget, pause/cancel, approval gates, execution policy stages, and company boundaries. + **Step 8 — Update status and communicate.** Always include the run ID header. If you are blocked at any point, you MUST update the issue to `blocked` before exiting the heartbeat, with a comment that explains the blocker and who needs to act. @@ -131,7 +139,24 @@ Done MD ``` -Status values: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`, `cancelled`. Priority values: `critical`, `high`, `medium`, `low`. Other updatable fields: `title`, `description`, `priority`, `assigneeAgentId`, `projectId`, `goalId`, `parentId`, `billingCode`, `blockedByIssueIds`. +Status values: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`, `cancelled`. Use the quick guide below when choosing one. Priority values: `critical`, `high`, `medium`, `low`. Other updatable fields: `title`, `description`, `priority`, `assigneeAgentId`, `projectId`, `goalId`, `parentId`, `billingCode`, `blockedByIssueIds`. + +### Status Quick Guide + +- `backlog` — not ready to execute yet. Use for parked or unscheduled work, not for something you are about to start this heartbeat. +- `todo` — ready and actionable, but not actively checked out yet. Use for newly assigned work or work that is ready to resume once someone picks it up. +- `in_progress` — actively owned work. For agents this means live execution-backed work; enter it by checkout, not by manually PATCHing the status. +- `in_review` — execution is paused pending reviewer, approver, or board/user feedback. Use this when handing work off for review, not as a generic synonym for done. +- `blocked` — cannot proceed until something specific changes. Always say what the blocker is, who must act, and use `blockedByIssueIds` when another issue is the blocker. +- `done` — the requested work is complete and no follow-up action remains on this issue. +- `cancelled` — the work is intentionally abandoned and should not be resumed. + +Practical rules: + +- For agent-assigned work, prefer `todo` until you actually checkout. Do not PATCH an issue into `in_progress` just to signal intent. +- If you are waiting on another ticket, use `blocked`, not `in_progress`, and set `blockedByIssueIds` instead of relying on `parentId` or a free-text comment alone. +- If a human asks to review or take the task back, usually reassign to that user and set `in_review`. +- `parentId` is structural only. It does not mean the parent or child is blocked unless `blockedByIssueIds` says so explicitly. **Step 9 — Delegate if needed.** Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work. @@ -276,6 +301,9 @@ If you are asked to create or manage routines you MUST read: - **Honor "send it back to me" requests from board users.** If a board/user asks for review handoff (e.g. "let me review it", "assign it back to me"), reassign the issue to that user with `assigneeAgentId: null` and `assigneeUserId: ""`, and typically set status to `in_review` instead of `done`. Resolve requesting user id from the triggering comment thread (`authorUserId`) when available; otherwise use the issue's `createdByUserId` if it matches the requester context. - **Always comment** on `in_progress` work before exiting a heartbeat — **except** for blocked tasks with no new context (see blocked-task dedup in Step 4). +- **Start actionable work before planning-only closure.** Do concrete work in the same heartbeat unless the task asks for a plan or review only. +- **Leave a next action.** Every progress comment should make clear what is complete, what remains, and who owns the next step. +- **Prefer child issues over polling.** Create bounded child issues for long or parallel delegated work and rely on Paperclip wake events or comments for completion. - **Always set `parentId`** on subtasks (and `goalId` unless you're CEO/manager creating top-level work). - **Preserve workspace continuity for follow-ups.** Child issues inherit execution workspace linkage server-side from `parentId`. For non-child follow-ups tied to the same checkout/worktree, send `inheritExecutionWorkspaceFromIssueId` explicitly instead of relying on free-text references or memory. - **Never cancel cross-team tasks.** Reassign to your manager with a comment. @@ -284,7 +312,7 @@ If you are asked to create or manage routines you MUST read: - **@-mentions** (`@AgentName` in comments) trigger heartbeats — use sparingly, they cost budget. - **Budget**: auto-paused at 100%. Above 80%, focus on critical tasks only. - **Escalate** via `chainOfCommand` when stuck. Reassign to manager or create a task for them. -- **Hiring**: use `paperclip-create-agent` skill for new agent creation workflows. +- **Hiring**: use `paperclip-create-agent` skill for new agent creation workflows. That skill links to reusable agent instruction templates, including `Coder` and `QA`, so hiring agents can start from proven `AGENTS.md` patterns without bloating this heartbeat skill. - **Commit Co-author**: if you make a git commit you MUST add EXACTLY `Co-Authored-By: Paperclip ` to the end of each commit message. Do not put in your agent name, put `Co-Authored-By: Paperclip ` ## Comment Style (Required) diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 02e1761c91d..225ff3ec4ab 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -665,10 +665,18 @@ backlog -> todo -> in_progress -> in_review -> done Terminal states: `done`, `cancelled` +- `backlog` = not ready to execute yet. +- `todo` = ready to execute, but not actively checked out yet. +- `in_progress` = actively owned work. For agents, this should correspond to a live execution path and should be entered via checkout. +- `in_review` = waiting on review or approval action, not active execution. +- `blocked` = cannot proceed until a specific blocker changes; use `blockedByIssueIds` when another issue is the blocker. +- `done` = completed. +- `cancelled` = intentionally abandoned. - `in_progress` requires an assignee (use checkout). - `started_at` is auto-set on `in_progress`. - `completed_at` is auto-set on `done`. - One assignee per task at a time. +- `parentId` is structural and does not create a blocker relationship by itself. --- diff --git a/tests/e2e/multi-user-authenticated.spec.ts b/tests/e2e/multi-user-authenticated.spec.ts new file mode 100644 index 00000000000..2652da389f5 --- /dev/null +++ b/tests/e2e/multi-user-authenticated.spec.ts @@ -0,0 +1,326 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { test, expect, type Browser, type Page } from "@playwright/test"; + +const BASE = process.env.PAPERCLIP_E2E_BASE_URL ?? "http://127.0.0.1:3105"; +const DATA_DIR = process.env.PAPERCLIP_E2E_DATA_DIR ?? process.env.PAPERCLIP_HOME; +const CONFIG_PATH = process.env.PAPERCLIP_E2E_CONFIG_PATH ?? path.resolve(process.cwd(), ".paperclip/config.json"); +const BOOTSTRAP_SCRIPT_PATH = path.resolve(process.cwd(), "packages/db/scripts/create-auth-bootstrap-invite.ts"); +const OWNER_PASSWORD = "paperclip-owner-password"; +const INVITED_PASSWORD = "paperclip-invited-password"; + +type HumanUser = { + name: string; + email: string; + password: string; +}; + +type CompanySummary = { + id: string; + name: string; + issuePrefix?: string | null; +}; + +type CompanyMember = { + id: string; + membershipRole: "owner" | "admin" | "operator" | "viewer"; + status: "pending" | "active" | "suspended"; + user: { id: string; email: string | null; name: string | null } | null; +}; + +type SessionJsonResponse = { + ok: boolean; + status: number; + text: string; + json: T | null; +}; + +const runId = Date.now(); +const companyName = `MU-Auth-${runId}`; +const ownerUser: HumanUser = { + name: "Owner User", + email: `owner-${runId}@paperclip.local`, + password: OWNER_PASSWORD, +}; +const invitedUser: HumanUser = { + name: "Invited User", + email: `invitee-${runId}@paperclip.local`, + password: INVITED_PASSWORD, +}; + +function createBootstrapInvite() { + if (!DATA_DIR) { + throw new Error("PAPERCLIP_E2E_DATA_DIR or PAPERCLIP_HOME is required for authenticated bootstrap tests"); + } + if (!existsSync(CONFIG_PATH)) { + throw new Error(`Authenticated bootstrap config not found at ${CONFIG_PATH}`); + } + if (!existsSync(BOOTSTRAP_SCRIPT_PATH)) { + throw new Error(`Authenticated bootstrap helper not found at ${BOOTSTRAP_SCRIPT_PATH}`); + } + + const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + return execFileSync( + pnpmCommand, + [ + "--filter", + "@paperclipai/db", + "exec", + "tsx", + BOOTSTRAP_SCRIPT_PATH, + "--config", + CONFIG_PATH, + "--base-url", + BASE, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + FORCE_COLOR: "0", + NO_COLOR: "1", + PAPERCLIP_HOME: DATA_DIR, + }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + } + ).trim(); +} + +async function signUp(page: Page, user: HumanUser) { + await page.goto(`${BASE}/auth`); + await expect(page.getByRole("heading", { name: "Sign in to Paperclip" })).toBeVisible(); + await page.getByRole("button", { name: "Create one" }).click(); + await expect(page.getByRole("heading", { name: "Create your Paperclip account" })).toBeVisible(); + await page.getByLabel("Name").fill(user.name); + await page.getByLabel("Email").fill(user.email); + await page.getByLabel("Password").fill(user.password); + await page.getByRole("button", { name: "Create Account" }).click(); + await expect(page).not.toHaveURL(/\/auth/, { timeout: 20_000 }); +} + +async function acceptBootstrapInvite(page: Page, inviteUrl: string) { + await page.goto(inviteUrl); + await expect(page.getByRole("heading", { name: "Bootstrap your Paperclip instance" })).toBeVisible(); + await page.getByRole("button", { name: "Accept bootstrap invite" }).click(); + await expect(page.getByRole("heading", { name: "Bootstrap complete" })).toBeVisible({ + timeout: 20_000, + }); + await page.getByRole("link", { name: "Open board" }).click(); +} + +async function createCompanyForSession(page: Page, nextCompanyName: string) { + const createRes = await sessionJsonRequest(page, `${BASE}/api/companies`, { + method: "POST", + data: { name: nextCompanyName }, + }); + expect(createRes.ok).toBe(true); + expect(createRes.json).toBeTruthy(); + return createRes.json!; +} + +async function createAuthenticatedInvite(page: Page, companyPrefix: string) { + await page.goto(`${BASE}/${companyPrefix}/company/settings`); + await expect(page.getByTestId("company-settings-invites-section")).toBeVisible({ + timeout: 20_000, + }); + await page.getByTestId("company-settings-human-invite-role").selectOption("operator"); + await page.getByTestId("company-settings-create-human-invite").click(); + const inviteField = page.getByTestId("company-settings-human-invite-url"); + await expect(inviteField).toBeVisible({ timeout: 20_000 }); + return (await inviteField.inputValue()).trim(); +} + +async function signUpFromInvite(page: Page, inviteUrl: string, user: HumanUser) { + await page.goto(inviteUrl); + await expect(page.getByText("Sign in or create an account before submitting a human join request.")).toBeVisible(); + await page.getByRole("link", { name: "Sign in / Create account" }).click(); + await expect(page).toHaveURL(/\/auth\?next=/); + await expect(page.getByRole("heading", { name: "Sign in to Paperclip" })).toBeVisible(); + await page.getByRole("button", { name: "Create one" }).click(); + await page.getByLabel("Name").fill(user.name); + await page.getByLabel("Email").fill(user.email); + await page.getByLabel("Password").fill(user.password); + await page.getByRole("button", { name: "Create Account" }).click(); + await expect(page).toHaveURL(new RegExp(`/invite/[^/]+$`), { timeout: 20_000 }); +} + +async function acceptHumanInvite(page: Page) { + await expect(page.getByRole("button", { name: "Join company" })).toBeEnabled(); + await page.getByRole("button", { name: "Join company" }).click(); + await expect(page.getByRole("heading", { name: "You joined the company" })).toBeVisible({ + timeout: 20_000, + }); + await page.getByRole("link", { name: "Open board" }).click(); +} + +async function sessionJsonRequest( + page: Page, + url: string, + options: { + method?: string; + data?: unknown; + } = {} +) { + return page.evaluate( + async ({ url: targetUrl, method, data }) => { + const response = await fetch(targetUrl, { + method, + credentials: "include", + headers: data === undefined ? undefined : { "Content-Type": "application/json" }, + body: data === undefined ? undefined : JSON.stringify(data), + }); + const text = await response.text(); + let json: unknown = null; + if (text.length > 0) { + try { + json = JSON.parse(text); + } catch { + json = null; + } + } + return { + ok: response.ok, + status: response.status, + text, + json, + }; + }, + { + url, + method: options.method ?? "GET", + data: options.data, + } + ) as Promise>; +} + +async function waitForMember(page: Page, companyId: string, email: string) { + let member: CompanyMember | null = null; + await expect + .poll( + async () => { + const membersRes = await sessionJsonRequest<{ members: CompanyMember[] }>( + page, + `${BASE}/api/companies/${companyId}/members` + ); + expect(membersRes.ok).toBe(true); + const body = membersRes.json; + if (!body) return null; + member = body.members.find((entry) => entry.user?.email === email) ?? null; + return member; + }, + { + timeout: 20_000, + intervals: [500, 1_000, 2_000], + } + ) + .toMatchObject({ + status: "active", + membershipRole: "operator", + user: { email }, + }); + return member!; +} + +async function waitForMemberRole( + page: Page, + companyId: string, + memberId: string, + membershipRole: CompanyMember["membershipRole"] +) { + await expect + .poll( + async () => { + const membersRes = await sessionJsonRequest<{ members: CompanyMember[] }>( + page, + `${BASE}/api/companies/${companyId}/members` + ); + expect(membersRes.ok).toBe(true); + const body = membersRes.json; + if (!body) return null; + return body.members.find((member) => member.id === memberId) ?? null; + }, + { + timeout: 20_000, + intervals: [500, 1_000, 2_000], + } + ) + .toMatchObject({ + id: memberId, + membershipRole, + }); +} + +async function newPage(browser: Browser) { + const context = await browser.newContext(); + const page = await context.newPage(); + return { context, page }; +} + +test.describe("Multi-user: authenticated mode", () => { + test("authenticated humans can bootstrap, invite, join, and respect viewer restrictions", async ({ + browser, + page, + }) => { + test.setTimeout(180_000); + + const healthRes = await page.request.get(`${BASE}/api/health`); + expect(healthRes.ok()).toBe(true); + const health = (await healthRes.json()) as { + deploymentMode?: string; + bootstrapStatus?: string; + }; + expect(health.deploymentMode).toBe("authenticated"); + + await signUp(page, ownerUser); + await acceptBootstrapInvite(page, createBootstrapInvite()); + + const company = await createCompanyForSession(page, companyName); + const companyPrefix = company.issuePrefix ?? company.id; + await page.goto(`${BASE}/${companyPrefix}/dashboard`); + await expect(page.getByTestId("layout-account-menu-trigger")).toContainText(ownerUser.name); + await page.getByTestId("layout-account-menu-trigger").click(); + await expect(page.getByText(ownerUser.email)).toBeVisible(); + const inviteUrl = await createAuthenticatedInvite(page, companyPrefix); + + const invited = await newPage(browser); + try { + await signUpFromInvite(invited.page, inviteUrl, invitedUser); + await acceptHumanInvite(invited.page); + await expect(invited.page).not.toHaveURL(/\/auth/, { timeout: 10_000 }); + + const joinedMember = await waitForMember(page, company.id, invitedUser.email); + + await page.goto(`${BASE}/${companyPrefix}/company/settings`); + const roleSelect = page.getByTestId(`company-settings-member-role-${joinedMember.id}`); + await expect(roleSelect).toBeVisible({ timeout: 20_000 }); + await roleSelect.selectOption("viewer"); + await expect(roleSelect).toHaveValue("viewer"); + await waitForMemberRole(page, company.id, joinedMember.id, "viewer"); + + await invited.page.goto(`${BASE}/${companyPrefix}/company/settings`); + await expect( + invited.page.getByText("Your current company role cannot create human invites.") + ).toBeVisible({ timeout: 20_000 }); + await expect( + invited.page.getByTestId("company-settings-create-human-invite") + ).toHaveCount(0); + + const forbiddenInviteRes = await sessionJsonRequest( + invited.page, + `${BASE}/api/companies/${company.id}/invites`, + { + method: "POST", + data: { + allowedJoinTypes: "human", + humanRole: "viewer", + }, + } + ); + expect(forbiddenInviteRes.status).toBe(403); + } finally { + await invited.context.close(); + } + }); +}); diff --git a/tests/e2e/multi-user.spec.ts b/tests/e2e/multi-user.spec.ts new file mode 100644 index 00000000000..d03eac35536 --- /dev/null +++ b/tests/e2e/multi-user.spec.ts @@ -0,0 +1,518 @@ +import { test, expect, type Page, type APIRequestContext } from "@playwright/test"; + +/** + * E2E: Multi-user implementation tests (local_trusted mode). + * + * Covers: + * 1. Company member management API (list, update role, suspend) + * 2. Human invite creation and acceptance API + * 3. Company Settings UI — member list, role editing, invite creation + * 4. Invite landing page UI + * 5. Role-based access control (viewer read-only) + * 6. Last-owner protection + */ + +const BASE = process.env.PAPERCLIP_E2E_BASE_URL ?? "http://127.0.0.1:3104"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Ensure the server is bootstrapped (claimed) before running tests. */ +async function ensureBootstrapped(request: APIRequestContext): Promise { + const healthRes = await request.get(`${BASE}/api/health`); + const health = await healthRes.json(); + if (health.bootstrapStatus === "ready") return; + + // If bootstrap_pending, we need to use the claim token from the bootstrap invite. + // In local_trusted mode, just try hitting companies — that should auto-bootstrap. + if (health.deploymentMode === "local_trusted") { + // local_trusted should work without explicit bootstrap + return; + } +} + +/** Create a company via the onboarding wizard API shortcut. */ +async function createCompanyViaWizard( + request: APIRequestContext, + name: string +): Promise<{ companyId: string; agentId: string; prefix: string }> { + await ensureBootstrapped(request); + + const createRes = await request.post(`${BASE}/api/companies`, { + data: { name }, + }); + if (!createRes.ok()) { + const errText = await createRes.text(); + throw new Error( + `Failed to create company (${createRes.status()}): ${errText}` + ); + } + const company = await createRes.json(); + + // Create a CEO agent + const agentRes = await request.post( + `${BASE}/api/companies/${company.id}/agents`, + { + data: { + name: "CEO", + role: "ceo", + title: "CEO", + adapterType: "claude_local", + }, + } + ); + expect(agentRes.ok()).toBe(true); + const agent = await agentRes.json(); + + return { + companyId: company.id, + agentId: agent.id, + prefix: company.issuePrefix ?? company.id, + }; +} + +/** Create a human invite and return token + invite URL. */ +async function createHumanInvite( + request: APIRequestContext, + companyId: string, + role: string = "operator" +): Promise<{ token: string; inviteUrl: string; inviteId: string }> { + const res = await request.post( + `${BASE}/api/companies/${companyId}/invites`, + { + data: { + allowedJoinTypes: "human", + humanRole: role, + }, + } + ); + expect(res.ok()).toBe(true); + const body = await res.json(); + return { + token: body.token, + inviteUrl: body.inviteUrl, + inviteId: body.id, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe("Multi-user: API", () => { + let companyId: string; + + test.beforeAll(async ({ request }) => { + const result = await createCompanyViaWizard( + request, + `MU-API-${Date.now()}` + ); + companyId = result.companyId; + }); + + test("GET /companies/:id/members returns member list with access info", async ({ + request, + }) => { + const res = await request.get( + `${BASE}/api/companies/${companyId}/members` + ); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body).toHaveProperty("members"); + expect(body).toHaveProperty("access"); + expect(Array.isArray(body.members)).toBe(true); + expect(body.access).toHaveProperty("currentUserRole"); + expect(body.access).toHaveProperty("canManageMembers"); + expect(body.access).toHaveProperty("canInviteUsers"); + }); + + test("POST /companies/:id/invites creates a human invite with role", async ({ + request, + }) => { + const res = await request.post( + `${BASE}/api/companies/${companyId}/invites`, + { + data: { + allowedJoinTypes: "human", + humanRole: "operator", + }, + } + ); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body).toHaveProperty("token"); + expect(body).toHaveProperty("inviteUrl"); + expect(body.allowedJoinTypes).toBe("human"); + expect(body.inviteUrl).toContain("/invite/"); + }); + + test("GET /invites/:token returns invite summary", async ({ request }) => { + const invite = await createHumanInvite(request, companyId, "viewer"); + const res = await request.get(`${BASE}/api/invites/${invite.token}`); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body).toHaveProperty("companyId"); + expect(body).toHaveProperty("allowedJoinTypes"); + expect(body.allowedJoinTypes).toBe("human"); + expect(body).toHaveProperty("inviteType"); + expect(body.inviteType).toBe("company_join"); + }); + + test("POST /invites/:token/accept (human) creates membership", async ({ + request, + }) => { + const invite = await createHumanInvite(request, companyId, "operator"); + const acceptRes = await request.post( + `${BASE}/api/invites/${invite.token}/accept`, + { + data: { requestType: "human" }, + } + ); + expect(acceptRes.ok()).toBe(true); + const body = await acceptRes.json(); + + // In local_trusted, human accept should succeed + expect(body).toHaveProperty("id"); + }); + + test("POST /invites/:token/accept rejects agent on human-only invite", async ({ + request, + }) => { + const invite = await createHumanInvite(request, companyId, "operator"); + const acceptRes = await request.post( + `${BASE}/api/invites/${invite.token}/accept`, + { + data: { requestType: "agent", agentName: "Rogue" }, + } + ); + expect(acceptRes.ok()).toBe(false); + expect(acceptRes.status()).toBe(400); + }); + + test("POST /companies/:id/invites supports all four roles", async ({ + request, + }) => { + for (const role of ["owner", "admin", "operator", "viewer"]) { + const res = await request.post( + `${BASE}/api/companies/${companyId}/invites`, + { + data: { allowedJoinTypes: "human", humanRole: role }, + } + ); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.token).toBeTruthy(); + } + }); + + test("PATCH /companies/:id/members/:memberId cannot remove last owner", async ({ + request, + }) => { + // Create a fresh company for this test + const fresh = await createCompanyViaWizard( + request, + `MU-LastOwner-${Date.now()}` + ); + + // First promote the local-board member to owner + const membersRes = await request.get( + `${BASE}/api/companies/${fresh.companyId}/members` + ); + const { members } = await membersRes.json(); + + // Find the board member (should be the only one) + const boardMember = members.find( + (m: { principalId: string }) => m.principalId === "local-board" + ); + if (!boardMember) { + test.skip(); + return; + } + + // Promote to owner first + const promoteRes = await request.patch( + `${BASE}/api/companies/${fresh.companyId}/members/${boardMember.id}`, + { data: { membershipRole: "owner" } } + ); + expect(promoteRes.ok()).toBe(true); + + // Now try to demote the last (and only) owner to operator — should fail + const demoteRes = await request.patch( + `${BASE}/api/companies/${fresh.companyId}/members/${boardMember.id}`, + { data: { membershipRole: "operator" } } + ); + expect(demoteRes.status()).toBe(409); + const errBody = await demoteRes.json(); + expect(JSON.stringify(errBody)).toContain("last active owner"); + }); + + test("POST /companies/:id/openclaw/invite-prompt creates agent invite", async ({ + request, + }) => { + const res = await request.post( + `${BASE}/api/companies/${companyId}/openclaw/invite-prompt`, + { + data: { agentMessage: "E2E test agent invite" }, + } + ); + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body).toHaveProperty("token"); + expect(body).toHaveProperty("inviteUrl"); + expect(body.allowedJoinTypes).toBe("agent"); + }); +}); + +test.describe("Multi-user: Company Settings UI", () => { + let companyId: string; + let companyPrefix: string; + + test.beforeAll(async ({ request }) => { + const result = await createCompanyViaWizard( + request, + `MU-UI-${Date.now()}` + ); + companyId = result.companyId; + companyPrefix = result.prefix; + }); + + test("shows Team and Invites sections on settings page", async ({ page }) => { + await page.goto(`${BASE}/${companyPrefix}/company/settings`); + await page.waitForLoadState("networkidle"); + await expect(page.getByTestId("company-settings-invites-section")).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByTestId("company-settings-team-section")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("shows human invite creation controls", async ({ page }) => { + await page.goto(`${BASE}/${companyPrefix}/company/settings`); + await page.waitForLoadState("networkidle"); + const inviteButton = page.getByTestId("company-settings-create-human-invite"); + await expect(inviteButton).toBeVisible({ timeout: 10_000 }); + + const roleSelect = page.getByTestId("company-settings-human-invite-role"); + await expect(roleSelect).toBeVisible(); + }); + + test("can create human invite and shows URL", async ({ page }) => { + await page.goto(`${BASE}/${companyPrefix}/company/settings`); + await page.waitForLoadState("networkidle"); + const inviteButton = page.getByTestId("company-settings-create-human-invite"); + await expect(inviteButton).toBeVisible({ timeout: 10_000 }); + await inviteButton.click(); + + await expect(page.getByTestId("company-settings-human-invite-url")).toBeVisible({ + timeout: 10_000, + }); + }); +}); + +test.describe("Multi-user: Invite Landing UI", () => { + let companyId: string; + let inviteToken: string; + + test.beforeAll(async ({ request }) => { + const result = await createCompanyViaWizard( + request, + `MU-Invite-${Date.now()}` + ); + companyId = result.companyId; + + const invite = await createHumanInvite(request, companyId, "operator"); + inviteToken = invite.token; + }); + + test("invite landing page loads with join options", async ({ page }) => { + await page.goto(`${BASE}/invite/${inviteToken}`); + await page.waitForLoadState("networkidle"); + + // Should show the invite landing page heading + await expect( + page.getByRole("heading", { name: /join/i }) + ).toBeVisible({ timeout: 10_000 }); + }); + + test("invite landing shows human join type", async ({ page }) => { + await page.goto(`${BASE}/invite/${inviteToken}`); + await page.waitForLoadState("networkidle"); + + // For a human-only invite, should show human join option + const humanOption = page.locator("text=/human/i"); + await expect(humanOption).toBeVisible({ timeout: 10_000 }); + }); + + test("expired/invalid invite token returns error", async ({ page }) => { + await page.goto(`${BASE}/invite/invalid-token-e2e-test`); + await page.waitForLoadState("networkidle"); + await expect(page.getByTestId("invite-error")).toBeVisible({ timeout: 10_000 }); + }); +}); + +test.describe("Multi-user: Member role management API", () => { + let companyId: string; + + test.beforeAll(async ({ request }) => { + const result = await createCompanyViaWizard( + request, + `MU-Roles-${Date.now()}` + ); + companyId = result.companyId; + }); + + test("invite + accept creates member with correct role", async ({ + request, + }) => { + // Create invite for 'viewer' role + const invite = await createHumanInvite(request, companyId, "viewer"); + + // Accept the invite + const acceptRes = await request.post( + `${BASE}/api/invites/${invite.token}/accept`, + { data: { requestType: "human" } } + ); + expect(acceptRes.ok()).toBe(true); + + // Check members list + const membersRes = await request.get( + `${BASE}/api/companies/${companyId}/members` + ); + const { members } = await membersRes.json(); + + // Should have at least one member (the creator/local-board) + expect(members.length).toBeGreaterThanOrEqual(1); + }); + + test("PATCH member role updates correctly", async ({ request }) => { + // First create an invite and accept it to get a second member + const invite = await createHumanInvite(request, companyId, "operator"); + const acceptRes = await request.post( + `${BASE}/api/invites/${invite.token}/accept`, + { data: { requestType: "human" } } + ); + expect(acceptRes.ok()).toBe(true); + + // List members + const membersRes = await request.get( + `${BASE}/api/companies/${companyId}/members` + ); + const { members } = await membersRes.json(); + + // Find a non-owner member to modify + const nonOwner = members.find( + (m: { membershipRole: string }) => m.membershipRole !== "owner" + ); + if (!nonOwner) { + test.skip(); + return; + } + + // Update role to admin + const patchRes = await request.patch( + `${BASE}/api/companies/${companyId}/members/${nonOwner.id}`, + { data: { membershipRole: "admin" } } + ); + expect(patchRes.ok()).toBe(true); + const updated = await patchRes.json(); + expect(updated.membershipRole).toBe("admin"); + }); + + test("PATCH member status to suspended works", async ({ request }) => { + // Create another member + const invite = await createHumanInvite(request, companyId, "operator"); + await request.post(`${BASE}/api/invites/${invite.token}/accept`, { + data: { requestType: "human" }, + }); + + const membersRes = await request.get( + `${BASE}/api/companies/${companyId}/members` + ); + const { members } = await membersRes.json(); + + const nonOwner = members.find( + (m: { membershipRole: string; status: string }) => + m.membershipRole !== "owner" && m.status === "active" + ); + if (!nonOwner) { + test.skip(); + return; + } + + const patchRes = await request.patch( + `${BASE}/api/companies/${companyId}/members/${nonOwner.id}`, + { data: { status: "suspended" } } + ); + expect(patchRes.ok()).toBe(true); + const updated = await patchRes.json(); + expect(updated.status).toBe("suspended"); + }); +}); + +test.describe("Multi-user: Agent invite flow", () => { + let companyId: string; + + test.beforeAll(async ({ request }) => { + const result = await createCompanyViaWizard( + request, + `MU-Agent-${Date.now()}` + ); + companyId = result.companyId; + }); + + test("agent invite accept creates pending join request", async ({ + request, + }) => { + // Create agent invite + const res = await request.post( + `${BASE}/api/companies/${companyId}/openclaw/invite-prompt`, + { data: {} } + ); + expect(res.ok()).toBe(true); + const { token } = await res.json(); + + // Accept as agent + const acceptRes = await request.post( + `${BASE}/api/invites/${token}/accept`, + { + data: { + requestType: "agent", + agentName: "TestAgent", + adapterType: "claude_local", + }, + } + ); + expect(acceptRes.ok()).toBe(true); + const body = await acceptRes.json(); + expect(body).toHaveProperty("id"); + expect(body.status).toBe("pending_approval"); + }); + + test("join requests list shows pending agent request", async ({ + request, + }) => { + const res = await request.get( + `${BASE}/api/companies/${companyId}/join-requests?status=pending_approval` + ); + expect(res.ok()).toBe(true); + const requests = await res.json(); + expect(Array.isArray(requests)).toBe(true); + }); +}); + +test.describe("Multi-user: Health check integration", () => { + test("health endpoint reports deployment mode", async ({ request }) => { + const res = await request.get(`${BASE}/api/health`); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body).toHaveProperty("deploymentMode"); + expect(body).toHaveProperty("authReady"); + expect(body.authReady).toBe(true); + }); +}); diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index c5ab59d87c0..2a6d6a439d8 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -54,6 +54,45 @@ test.describe("Onboarding wizard", () => { page.locator("h3", { hasText: "Give it something to do" }) ).toBeVisible({ timeout: 30_000 }); + const baseUrl = page.url().split("/").slice(0, 3).join("/"); + if (SKIP_LLM) { + const companiesAfterAgentRes = await page.request.get(`${baseUrl}/api/companies`); + expect(companiesAfterAgentRes.ok()).toBe(true); + const companiesAfterAgent = await companiesAfterAgentRes.json(); + const companyAfterAgent = companiesAfterAgent.find( + (c: { name: string }) => c.name === COMPANY_NAME + ); + expect(companyAfterAgent).toBeTruthy(); + + const agentsAfterCreateRes = await page.request.get( + `${baseUrl}/api/companies/${companyAfterAgent.id}/agents` + ); + expect(agentsAfterCreateRes.ok()).toBe(true); + const agentsAfterCreate = await agentsAfterCreateRes.json(); + const ceoAgentAfterCreate = agentsAfterCreate.find( + (a: { name: string }) => a.name === AGENT_NAME + ); + expect(ceoAgentAfterCreate).toBeTruthy(); + + const disableWakeRes = await page.request.patch( + `${baseUrl}/api/agents/${ceoAgentAfterCreate.id}?companyId=${encodeURIComponent(companyAfterAgent.id)}`, + { + data: { + runtimeConfig: { + heartbeat: { + enabled: false, + intervalSec: 300, + wakeOnDemand: false, + cooldownSec: 10, + maxConcurrentRuns: 5, + }, + }, + }, + } + ); + expect(disableWakeRes.ok()).toBe(true); + } + const taskTitleInput = page.locator( 'input[placeholder="e.g. Research competitor pricing"]' ); @@ -74,8 +113,6 @@ test.describe("Onboarding wizard", () => { await expect(page).toHaveURL(/\/issues\//, { timeout: 30_000 }); - const baseUrl = page.url().split("/").slice(0, 3).join("/"); - const companiesRes = await page.request.get(`${baseUrl}/api/companies`); expect(companiesRes.ok()).toBe(true); const companies = await companiesRes.json(); @@ -128,6 +165,17 @@ test.describe("Onboarding wizard", () => { const issue = await res.json(); expect(["in_progress", "done"]).toContain(issue.status); }).toPass({ timeout: 120_000, intervals: [5_000] }); + } else { + await expect + .poll(async () => { + const runsRes = await page.request.get( + `${baseUrl}/api/companies/${company.id}/heartbeat-runs?agentId=${ceoAgent.id}` + ); + expect(runsRes.ok()).toBe(true); + const runs = await runsRes.json(); + return Array.isArray(runs) ? runs.length : -1; + }, { timeout: 10_000, intervals: [500, 1_000, 2_000] }) + .toBe(0); } }); }); diff --git a/tests/e2e/playwright-multiuser-authenticated.config.ts b/tests/e2e/playwright-multiuser-authenticated.config.ts new file mode 100644 index 00000000000..f80f13372ad --- /dev/null +++ b/tests/e2e/playwright-multiuser-authenticated.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "@playwright/test"; + +const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3105); +const BASE_URL = process.env.PAPERCLIP_E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: ".", + testMatch: "multi-user-authenticated.spec.ts", + timeout: 180_000, + expect: { + timeout: 20_000, + }, + retries: 0, + use: { + baseURL: BASE_URL, + headless: true, + screenshot: "only-on-failure", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], + outputDir: "./test-results", + reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]], +}); diff --git a/tests/e2e/playwright-multiuser.config.ts b/tests/e2e/playwright-multiuser.config.ts new file mode 100644 index 00000000000..0c2d79be1a7 --- /dev/null +++ b/tests/e2e/playwright-multiuser.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "@playwright/test"; + +const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3104); +const BASE_URL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: ".", + testMatch: "multi-user.spec.ts", + timeout: 60_000, + retries: 0, + use: { + baseURL: BASE_URL, + headless: true, + screenshot: "only-on-failure", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], + // No webServer — expects an already-running server at BASE_URL. + outputDir: "./test-results", + reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]], +}); diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 572b012a3a7..4f1c7206680 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -1,13 +1,20 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { defineConfig } from "@playwright/test"; // Use a dedicated port so e2e tests always start their own server in local_trusted mode, // even when the dev server is running on :3100 in authenticated mode. const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199); const BASE_URL = `http://127.0.0.1:${PORT}`; +const PAPERCLIP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-e2e-home-")); export default defineConfig({ testDir: ".", testMatch: "**/*.spec.ts", + // These suites target dedicated multi-user configurations/ports and are + // intentionally not part of the default local_trusted e2e run. + testIgnore: ["multi-user.spec.ts", "multi-user-authenticated.spec.ts"], timeout: 60_000, retries: 0, use: { @@ -22,19 +29,25 @@ export default defineConfig({ use: { browserName: "chromium" }, }, ], - // The webServer directive starts `paperclipai run` before tests. - // Expects `pnpm paperclipai` to be runnable from repo root. + // The webServer directive bootstraps a throwaway instance and then starts it. + // `onboard --yes --run` works in a non-interactive temp PAPERCLIP_HOME. webServer: { - command: `pnpm paperclipai run`, + command: `pnpm paperclipai onboard --yes --run`, url: `${BASE_URL}/api/health`, - reuseExistingServer: !process.env.CI, + // Always boot a dedicated throwaway instance for e2e so browser tests + // never attach to the developer's active Paperclip home/server. + reuseExistingServer: false, timeout: 120_000, stdout: "pipe", stderr: "pipe", env: { ...process.env, PORT: String(PORT), + PAPERCLIP_HOME, + PAPERCLIP_INSTANCE_ID: "playwright-e2e", + PAPERCLIP_BIND: "loopback", PAPERCLIP_DEPLOYMENT_MODE: "local_trusted", + PAPERCLIP_DEPLOYMENT_EXPOSURE: "private", }, }, outputDir: "./test-results", diff --git a/tests/e2e/signoff-policy.spec.ts b/tests/e2e/signoff-policy.spec.ts index 934eeaf9f15..a2cde2c385c 100644 --- a/tests/e2e/signoff-policy.spec.ts +++ b/tests/e2e/signoff-policy.spec.ts @@ -42,6 +42,12 @@ interface TestContext { issueIds: string[]; } +interface IssueRunLockState { + assigneeAgentId: string | null; + checkoutRunId: string | null; + executionRunId: string | null; +} + /** Create an authenticated APIRequestContext for an agent (token set, no run ID yet). */ async function createAgentRequest(token: string): Promise { return pwRequest.newContext({ @@ -58,6 +64,17 @@ async function invokeHeartbeat(board: APIRequestContext, agentId: string): Promi return run.id; } +async function getIssueRunLockState(board: APIRequestContext, issueId: string): Promise { + const res = await board.get(`${BASE_URL}/api/issues/${issueId}`); + expect(res.ok()).toBe(true); + const issue = await res.json(); + return { + assigneeAgentId: issue.assigneeAgentId ?? null, + checkoutRunId: issue.checkoutRunId ?? null, + executionRunId: issue.executionRunId ?? null, + }; +} + /** PATCH an issue as an agent with a fresh heartbeat run ID. */ async function agentPatch( board: APIRequestContext, @@ -88,6 +105,17 @@ async function agentCheckoutAndPatch( data: { agentId: agent.agentId, expectedStatuses }, }); if (!checkoutRes.ok()) { + if (checkoutRes.status() === 409) { + const issueRunLock = await getIssueRunLockState(board, issueId); + const lockedRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId; + const res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, { + headers: { "X-Paperclip-Run-Id": lockedRunId ?? runId }, + data: patchData, + }); + if (res.ok() && issueRunLock.assigneeAgentId === agent.agentId) { + return res; + } + } // If agent checkout fails (e.g. run expired), fall back to board checkout // then PATCH with the agent's identity const boardCheckout = await board.post(`${BASE_URL}/api/issues/${issueId}/checkout`, { diff --git a/ui/index.html b/ui/index.html index 70a8550ead5..c41e22f2a7e 100644 --- a/ui/index.html +++ b/ui/index.html @@ -2,7 +2,7 @@ - + diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx new file mode 100644 index 00000000000..bec9c199ae2 --- /dev/null +++ b/ui/src/App.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom + +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CloudAccessGate } from "./components/CloudAccessGate"; + +const mockHealthApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +const mockAuthApi = vi.hoisted(() => ({ + getSession: vi.fn(), +})); + +const mockAccessApi = vi.hoisted(() => ({ + getCurrentBoardAccess: vi.fn(), +})); + +vi.mock("./api/health", () => ({ + healthApi: mockHealthApi, +})); + +vi.mock("./api/auth", () => ({ + authApi: mockAuthApi, +})); + +vi.mock("./api/access", () => ({ + accessApi: mockAccessApi, +})); + +vi.mock("@/lib/router", () => ({ + Navigate: ({ to }: { to: string }) =>
Navigate:{to}
, + Outlet: () =>
Outlet content
, + Route: ({ children }: { children?: ReactNode }) => <>{children}, + Routes: ({ children }: { children?: ReactNode }) => <>{children}, + useLocation: () => ({ pathname: "/instance/settings/general", search: "", hash: "" }), + useParams: () => ({}), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("CloudAccessGate", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockHealthApi.get.mockResolvedValue({ + status: "ok", + deploymentMode: "authenticated", + bootstrapStatus: "ready", + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("shows a no-access message for signed-in users without org access", async () => { + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + }); + mockAccessApi.getCurrentBoardAccess.mockResolvedValue({ + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + userId: "user-1", + isInstanceAdmin: false, + companyIds: [], + source: "session", + keyId: null, + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("No company access"); + expect(container.textContent).not.toContain("Outlet content"); + + await act(async () => { + root.unmount(); + }); + }); + + it("allows authenticated users with company access through to the board", async () => { + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + }); + mockAccessApi.getCurrentBoardAccess.mockResolvedValue({ + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + userId: "user-1", + isInstanceAdmin: false, + companyIds: ["company-1"], + source: "session", + keyId: null, + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Outlet content"); + expect(container.textContent).not.toContain("No company access"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ed72eba6c68..6bf51490e82 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,10 +1,8 @@ import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router"; -import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Layout } from "./components/Layout"; import { OnboardingWizard } from "./components/OnboardingWizard"; -import { authApi } from "./api/auth"; -import { healthApi } from "./api/health"; +import { CloudAccessGate } from "./components/CloudAccessGate"; import { Dashboard } from "./pages/Dashboard"; import { Companies } from "./pages/Companies"; import { Agents } from "./pages/Agents"; @@ -12,11 +10,13 @@ import { AgentDetail } from "./pages/AgentDetail"; import { Projects } from "./pages/Projects"; import { ProjectDetail } from "./pages/ProjectDetail"; import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail"; +import { Workspaces } from "./pages/Workspaces"; import { Issues } from "./pages/Issues"; import { IssueDetail } from "./pages/IssueDetail"; import { WorkMemories } from "./pages/WorkMemories"; import { Routines } from "./pages/Routines"; import { RoutineDetail } from "./pages/RoutineDetail"; +import { UserProfile } from "./pages/UserProfile"; import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail"; import { Goals } from "./pages/Goals"; import { GoalDetail } from "./pages/GoalDetail"; @@ -26,18 +26,23 @@ import { Costs } from "./pages/Costs"; import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; +import { CompanyAccess } from "./pages/CompanyAccess"; +import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; import { CompanyExport } from "./pages/CompanyExport"; import { CompanyImport } from "./pages/CompanyImport"; import { DesignGuide } from "./pages/DesignGuide"; import { InstanceGeneralSettings } from "./pages/InstanceGeneralSettings"; +import { InstanceAccess } from "./pages/InstanceAccess"; import { InstanceSettings } from "./pages/InstanceSettings"; import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings"; +import { ProfileSettings } from "./pages/ProfileSettings"; import { PluginManager } from "./pages/PluginManager"; import { PluginSettings } from "./pages/PluginSettings"; import { AdapterManager } from "./pages/AdapterManager"; import { PluginPage } from "./pages/PluginPage"; import { IssueChatUxLab } from "./pages/IssueChatUxLab"; +import { InviteUxLab } from "./pages/InviteUxLab"; import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; import { OrgChart } from "./pages/OrgChart"; import { NewAgent } from "./pages/NewAgent"; @@ -45,80 +50,13 @@ import { AuthPage } from "./pages/Auth"; import { BoardClaimPage } from "./pages/BoardClaim"; import { CliAuthPage } from "./pages/CliAuth"; import { InviteLandingPage } from "./pages/InviteLanding"; +import { JoinRequestQueue } from "./pages/JoinRequestQueue"; import { NotFoundPage } from "./pages/NotFound"; -import { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; import { loadLastInboxTab } from "./lib/inbox"; import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route"; -function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { - return ( -
-
-

Instance setup required

-

- {hasActiveInvite - ? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:" - : "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"} -

-
-{`pnpm paperclipai auth bootstrap-ceo`}
-        
-
-
- ); -} - -function CloudAccessGate() { - const location = useLocation(); - const healthQuery = useQuery({ - queryKey: queryKeys.health, - queryFn: () => healthApi.get(), - retry: false, - refetchInterval: (query) => { - const data = query.state.data as - | { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" } - | undefined; - return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending" - ? 2000 - : false; - }, - refetchIntervalInBackground: true, - }); - - const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated"; - const sessionQuery = useQuery({ - queryKey: queryKeys.auth.session, - queryFn: () => authApi.getSession(), - enabled: isAuthenticatedMode, - retry: false, - }); - - if (healthQuery.isLoading || (isAuthenticatedMode && sessionQuery.isLoading)) { - return
Loading...
; - } - - if (healthQuery.error) { - return ( -
- {healthQuery.error instanceof Error ? healthQuery.error.message : "Failed to load app state"} -
- ); - } - - if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") { - return ; - } - - if (isAuthenticatedMode && !sessionQuery.data) { - const next = encodeURIComponent(`${location.pathname}${location.search}`); - return ; - } - - return ; -} - function boardRoutes() { return ( <> @@ -127,6 +65,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -153,6 +93,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -165,6 +106,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -179,9 +121,12 @@ function boardRoutes() { } /> } /> } /> + } /> } /> + } /> } /> } /> + } /> } /> } /> } /> @@ -325,7 +270,9 @@ export function App() { } /> }> } /> + } /> } /> + } /> } /> } /> } /> @@ -335,8 +282,10 @@ export function App() { } /> } /> } /> + } /> } /> } /> + } /> } /> } /> } /> @@ -355,8 +304,10 @@ export function App() { } /> } /> } /> + } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts new file mode 100644 index 00000000000..281d3adcc06 --- /dev/null +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -0,0 +1,54 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { adaptersApi, type AdapterCapabilities } from "@/api/adapters"; +import { queryKeys } from "@/lib/queryKeys"; + +const ALL_FALSE: AdapterCapabilities = { + supportsInstructionsBundle: false, + supportsSkills: false, + supportsLocalAgentJwt: false, + requiresMaterializedRuntimeSkills: false, +}; + +/** + * Synchronous fallback for known built-in adapter types so capability checks + * return correct values on first render before the /api/adapters call resolves. + */ +const KNOWN_DEFAULTS: Record = { + claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false }, + codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false }, + cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, + gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, + opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, + pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, + hermes_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false }, + openclaw_gateway: ALL_FALSE, +}; + +/** + * Returns a lookup function that resolves adapter capabilities by type. + * + * Capabilities are fetched from the server adapter listing API and cached + * via react-query. Before the data loads, known built-in adapter types + * return correct synchronous defaults to avoid cold-load regressions. + */ +export function useAdapterCapabilities(): (type: string) => AdapterCapabilities { + const { data: adapters } = useQuery({ + queryKey: queryKeys.adapters.all, + queryFn: () => adaptersApi.list(), + staleTime: 5 * 60 * 1000, + }); + + const capMap = useMemo(() => { + const map = new Map(); + if (adapters) { + for (const a of adapters) { + map.set(a.type, a.capabilities); + } + } + return map; + }, [adapters]); + + return (type: string): AdapterCapabilities => + capMap.get(type) ?? KNOWN_DEFAULTS[type] ?? ALL_FALSE; +} diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 0a1111502e5..c8f83134e71 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -1,12 +1,17 @@ -import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared"; +import type { AgentAdapterType, JoinRequest, PermissionKey } from "@paperclipai/shared"; import { api } from "./client"; +export type HumanCompanyRole = "owner" | "admin" | "operator" | "viewer"; + type InviteSummary = { id: string; companyId: string | null; companyName?: string | null; + companyLogoUrl?: string | null; + companyBrandColor?: string | null; inviteType: "company_join" | "bootstrap_ceo"; allowedJoinTypes: "human" | "agent" | "both"; + humanRole?: HumanCompanyRole | null; expiresAt: string; onboardingPath?: string; onboardingUrl?: string; @@ -15,6 +20,9 @@ type InviteSummary = { skillIndexPath?: string; skillIndexUrl?: string; inviteMessage?: string | null; + invitedByUserName?: string | null; + joinRequestStatus?: JoinRequest["status"] | null; + joinRequestType?: JoinRequest["requestType"] | null; }; type AcceptInviteInput = @@ -88,17 +96,171 @@ type CompanyInviteCreated = { inviteUrl: string; expiresAt: string; allowedJoinTypes: "human" | "agent" | "both"; + humanRole?: HumanCompanyRole | null; companyName?: string | null; onboardingTextPath?: string; onboardingTextUrl?: string; inviteMessage?: string | null; }; +export type CompanyMemberGrant = { + id: string; + companyId: string; + principalType: "user"; + principalId: string; + permissionKey: PermissionKey; + scope: Record | null; + grantedByUserId: string | null; + createdAt: string; + updatedAt: string; +}; + +export type CompanyMember = { + id: string; + companyId: string; + principalType: "user"; + principalId: string; + status: "pending" | "active" | "suspended" | "archived"; + membershipRole: HumanCompanyRole | null; + createdAt: string; + updatedAt: string; + user: { id: string; email: string | null; name: string | null; image: string | null } | null; + grants: CompanyMemberGrant[]; + removal?: { + canArchive: boolean; + reason: string | null; + }; +}; + +export type ArchiveCompanyMemberResponse = { + member: CompanyMember; + reassignedIssueCount: number; +}; + +export type CompanyMembersResponse = { + members: CompanyMember[]; + access: { + currentUserRole: HumanCompanyRole | null; + canManageMembers: boolean; + canInviteUsers: boolean; + canApproveJoinRequests: boolean; + }; +}; + +export type CompanyUserDirectoryEntry = { + principalId: string; + status: "active"; + user: { id: string; email: string | null; name: string | null; image: string | null } | null; +}; + +export type CompanyUserDirectoryResponse = { + users: CompanyUserDirectoryEntry[]; +}; + +export type CompanyInviteRecord = { + id: string; + companyId: string | null; + companyName: string | null; + inviteType: "company_join" | "bootstrap_ceo"; + allowedJoinTypes: "human" | "agent" | "both"; + humanRole: HumanCompanyRole | null; + defaultsPayload: Record | null; + expiresAt: string; + invitedByUserId: string | null; + revokedAt: string | null; + acceptedAt: string | null; + createdAt: string; + updatedAt: string; + inviteMessage: string | null; + state: "active" | "revoked" | "accepted" | "expired"; + invitedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null; + relatedJoinRequestId: string | null; +}; + +export type CompanyInviteListResponse = { + invites: CompanyInviteRecord[]; + nextOffset: number | null; +}; + +export type CompanyJoinRequest = JoinRequest & { + requesterUser: { id: string; email: string | null; name: string | null; image: string | null } | null; + approvedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null; + rejectedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null; + invite: { + id: string; + inviteType: "company_join" | "bootstrap_ceo"; + allowedJoinTypes: "human" | "agent" | "both"; + humanRole: HumanCompanyRole | null; + inviteMessage: string | null; + createdAt: string; + expiresAt: string; + revokedAt: string | null; + acceptedAt: string | null; + invitedByUser: { id: string; email: string | null; name: string | null; image: string | null } | null; + } | null; +}; + +export type AdminUserDirectoryEntry = { + id: string; + email: string | null; + name: string | null; + image: string | null; + isInstanceAdmin: boolean; + activeCompanyMembershipCount: number; +}; + +export type UserCompanyAccessEntry = { + id: string; + companyId: string; + principalType: "user"; + principalId: string; + status: "pending" | "active" | "suspended" | "archived"; + membershipRole: HumanCompanyRole | "member" | null; + createdAt: string; + updatedAt: string; + companyName: string | null; + companyStatus: "active" | "paused" | "archived" | null; +}; + +export type UserCompanyAccessResponse = { + user: { + id: string; + email: string | null; + name: string | null; + image: string | null; + isInstanceAdmin: boolean; + } | null; + companyAccess: UserCompanyAccessEntry[]; +}; + +export type CurrentBoardAccess = { + user: { id: string; email: string | null; name: string | null; image: string | null } | null; + userId: string; + isInstanceAdmin: boolean; + companyIds: string[]; + source: string; + keyId: string | null; +}; + +function buildInviteListQuery(options: { + state?: "active" | "revoked" | "accepted" | "expired"; + limit?: number; + offset?: number; +}) { + const params = new URLSearchParams(); + if (options.state) params.set("state", options.state); + if (options.limit) params.set("limit", String(options.limit)); + if (options.offset) params.set("offset", String(options.offset)); + const query = params.toString(); + return query ? `?${query}` : ""; +} + export const accessApi = { createCompanyInvite: ( companyId: string, input: { allowedJoinTypes?: "human" | "agent" | "both"; + humanRole?: HumanCompanyRole | null; defaultsPayload?: Record | null; agentMessage?: string | null; } = {}, @@ -126,8 +288,78 @@ export const accessApi = { input, ), - listJoinRequests: (companyId: string, status: "pending_approval" | "approved" | "rejected" = "pending_approval") => - api.get(`/companies/${companyId}/join-requests?status=${status}`), + listInvites: ( + companyId: string, + options: { + state?: "active" | "revoked" | "accepted" | "expired"; + limit?: number; + offset?: number; + } = {}, + ) => + api.get( + `/companies/${companyId}/invites${buildInviteListQuery(options)}`, + ), + + revokeInvite: (inviteId: string) => api.post(`/invites/${inviteId}/revoke`, {}), + + listJoinRequests: ( + companyId: string, + status: "pending_approval" | "approved" | "rejected" = "pending_approval", + requestType?: "human" | "agent", + ) => + api.get( + `/companies/${companyId}/join-requests?status=${status}${requestType ? `&requestType=${requestType}` : ""}`, + ), + + listMembers: (companyId: string) => + api.get(`/companies/${companyId}/members`), + + listUserDirectory: (companyId: string) => + api.get(`/companies/${companyId}/user-directory`), + + updateMember: ( + companyId: string, + memberId: string, + input: { + membershipRole?: HumanCompanyRole | null; + status?: "pending" | "active" | "suspended"; + }, + ) => api.patch(`/companies/${companyId}/members/${memberId}`, input), + + updateMemberPermissions: ( + companyId: string, + memberId: string, + input: { + grants: Array<{ + permissionKey: PermissionKey; + scope?: Record | null; + }>; + }, + ) => api.patch(`/companies/${companyId}/members/${memberId}/permissions`, input), + + updateMemberAccess: ( + companyId: string, + memberId: string, + input: { + membershipRole?: HumanCompanyRole | null; + status?: "pending" | "active" | "suspended"; + grants: Array<{ + permissionKey: PermissionKey; + scope?: Record | null; + }>; + }, + ) => api.patch(`/companies/${companyId}/members/${memberId}/role-and-grants`, input), + + archiveMember: ( + companyId: string, + memberId: string, + input: { + reassignment?: { + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + } | null; + } = {}, + ) => api.post(`/companies/${companyId}/members/${memberId}/archive`, input), approveJoinRequest: (companyId: string, requestId: string) => api.post(`/companies/${companyId}/join-requests/${requestId}/approve`, {}), @@ -158,4 +390,22 @@ export const accessApi = { cancelCliAuthChallenge: (id: string, token: string) => api.post<{ cancelled: boolean; status: string }>(`/cli-auth/challenges/${id}/cancel`, { token }), + + searchAdminUsers: (query: string) => + api.get(`/admin/users?query=${encodeURIComponent(query)}`), + + promoteInstanceAdmin: (userId: string) => + api.post(`/admin/users/${userId}/promote-instance-admin`, {}), + + demoteInstanceAdmin: (userId: string) => + api.post(`/admin/users/${userId}/demote-instance-admin`, {}), + + getUserCompanyAccess: (userId: string) => + api.get(`/admin/users/${userId}/company-access`), + + setUserCompanyAccess: (userId: string, companyIds: string[]) => + api.put(`/admin/users/${userId}/company-access`, { companyIds }), + + getCurrentBoardAccess: () => + api.get("/cli-auth/me"), }; diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts index 46f887ae28e..da14ff808d1 100644 --- a/ui/src/api/activity.ts +++ b/ui/src/api/activity.ts @@ -1,6 +1,8 @@ -import type { ActivityEvent } from "@paperclipai/shared"; +import type { ActivityEvent, RunLivenessState } from "@paperclipai/shared"; import { api } from "./client"; +export type { RunLivenessState } from "@paperclipai/shared"; + export interface RunForIssue { runId: string; status: string; @@ -13,6 +15,11 @@ export interface RunForIssue { usageJson: Record | null; resultJson: Record | null; logBytes?: number | null; + livenessState?: RunLivenessState | null; + livenessReason?: string | null; + continuationAttempt?: number; + lastUsefulActionAt?: string | null; + nextAction?: string | null; } export interface IssueForRun { diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts index 86705bd4e75..18b154efedc 100644 --- a/ui/src/api/adapters.ts +++ b/ui/src/api/adapters.ts @@ -4,6 +4,13 @@ import { api } from "./client"; +export interface AdapterCapabilities { + supportsInstructionsBundle: boolean; + supportsSkills: boolean; + supportsLocalAgentJwt: boolean; + requiresMaterializedRuntimeSkills: boolean; +} + export interface AdapterInfo { type: string; label: string; @@ -11,6 +18,7 @@ export interface AdapterInfo { modelsCount: number; loaded: boolean; disabled: boolean; + capabilities: AdapterCapabilities; /** Installed version (for external npm adapters) */ version?: string; /** Package name (for external adapters) */ diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index fcd38604209..9f7c8ba87f0 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -146,6 +146,7 @@ export const agentsApi = { ), pause: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/pause"), {}), resume: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/resume"), {}), + approve: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/approve"), {}), terminate: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/terminate"), {}), remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)), listKeys: (id: string, companyId?: string) => api.get(agentPath(id, companyId, "/keys")), diff --git a/ui/src/api/auth.ts b/ui/src/api/auth.ts index 5aa5b69ad22..b0da51651a1 100644 --- a/ui/src/api/auth.ts +++ b/ui/src/api/auth.ts @@ -1,27 +1,63 @@ -export type AuthSession = { - session: { id: string; userId: string }; - user: { id: string; email: string | null; name: string | null }; -}; +import { + authSessionSchema, + currentUserProfileSchema, + type AuthSession, + type CurrentUserProfile, + type UpdateCurrentUserProfile, +} from "@paperclipai/shared"; + +type AuthErrorBody = + | { + code?: string; + message?: string; + error?: string | { code?: string; message?: string }; + } + | null; + +export class AuthApiError extends Error { + status: number; + code: string | null; + body: unknown; + + constructor(message: string, status: number, body: unknown, code: string | null = null) { + super(message); + this.name = "AuthApiError"; + this.status = status; + this.code = code; + this.body = body; + } +} function toSession(value: unknown): AuthSession | null { + const direct = authSessionSchema.safeParse(value); + if (direct.success) return direct.data; + if (!value || typeof value !== "object") return null; - const record = value as Record; - const sessionValue = record.session; - const userValue = record.user; - if (!sessionValue || typeof sessionValue !== "object") return null; - if (!userValue || typeof userValue !== "object") return null; - const session = sessionValue as Record; - const user = userValue as Record; - if (typeof session.id !== "string" || typeof session.userId !== "string") return null; - if (typeof user.id !== "string") return null; - return { - session: { id: session.id, userId: session.userId }, - user: { - id: user.id, - email: typeof user.email === "string" ? user.email : null, - name: typeof user.name === "string" ? user.name : null, - }, - }; + const nested = authSessionSchema.safeParse((value as Record).data); + return nested.success ? nested.data : null; +} + +function extractAuthError(payload: AuthErrorBody, status: number) { + const nested = + payload?.error && typeof payload.error === "object" + ? payload.error + : null; + const code = + typeof nested?.code === "string" + ? nested.code + : typeof payload?.code === "string" + ? payload.code + : null; + const message = + typeof nested?.message === "string" && nested.message.trim().length > 0 + ? nested.message + : typeof payload?.message === "string" && payload.message.trim().length > 0 + ? payload.message + : typeof payload?.error === "string" && payload.error.trim().length > 0 + ? payload.error + : `Request failed: ${status}`; + + return new AuthApiError(message, status, payload, code); } async function authPost(path: string, body: Record) { @@ -33,16 +69,25 @@ async function authPost(path: string, body: Record) { }); const payload = await res.json().catch(() => null); if (!res.ok) { - const message = - (payload as { error?: { message?: string } | string } | null)?.error && - typeof (payload as { error?: { message?: string } | string }).error === "object" - ? ((payload as { error?: { message?: string } }).error?.message ?? `Request failed: ${res.status}`) - : (payload as { error?: string } | null)?.error ?? `Request failed: ${res.status}`; - throw new Error(message); + throw extractAuthError(payload as AuthErrorBody, res.status); } return payload; } +async function authPatch(path: string, body: Record, parse: (value: unknown) => T): Promise { + const res = await fetch(`/api/auth${path}`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }); + const payload = await res.json().catch(() => null); + if (!res.ok) { + throw extractAuthError(payload as AuthErrorBody, res.status); + } + return parse(payload); +} + export const authApi = { getSession: async (): Promise => { const res = await fetch("/api/auth/get-session", { @@ -68,6 +113,21 @@ export const authApi = { await authPost("/sign-up/email", input); }, + getProfile: async (): Promise => { + const res = await fetch("/api/auth/profile", { + credentials: "include", + headers: { Accept: "application/json" }, + }); + const payload = await res.json().catch(() => null); + if (!res.ok) { + throw new Error((payload as { error?: string } | null)?.error ?? `Failed to load profile (${res.status})`); + } + return currentUserProfileSchema.parse(payload); + }, + + updateProfile: async (input: UpdateCurrentUserProfile): Promise => + authPatch("/profile", input, (payload) => currentUserProfileSchema.parse(payload)), + signOut: async () => { await authPost("/sign-out", {}); }, diff --git a/ui/src/api/execution-workspaces.test.ts b/ui/src/api/execution-workspaces.test.ts new file mode 100644 index 00000000000..b18bd6501f4 --- /dev/null +++ b/ui/src/api/execution-workspaces.test.ts @@ -0,0 +1,29 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock("./client", () => ({ + api: mockApi, +})); + +import { executionWorkspacesApi } from "./execution-workspaces"; + +describe("executionWorkspacesApi.listSummaries", () => { + beforeEach(() => { + mockApi.get.mockReset(); + mockApi.get.mockResolvedValue([]); + }); + + it("requests the lightweight summary payload", async () => { + await executionWorkspacesApi.listSummaries("company-1", { + projectId: "project-1", + reuseEligible: true, + }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/execution-workspaces?projectId=project-1&reuseEligible=true&summary=true", + ); + }); +}); diff --git a/ui/src/api/execution-workspaces.ts b/ui/src/api/execution-workspaces.ts index 3644af77feb..cd5b7341752 100644 --- a/ui/src/api/execution-workspaces.ts +++ b/ui/src/api/execution-workspaces.ts @@ -1,7 +1,36 @@ -import type { ExecutionWorkspace, ExecutionWorkspaceCloseReadiness, WorkspaceOperation } from "@paperclipai/shared"; +import type { + ExecutionWorkspace, + ExecutionWorkspaceSummary, + ExecutionWorkspaceCloseReadiness, + WorkspaceOperation, + WorkspaceRuntimeControlTarget, +} from "@paperclipai/shared"; import { api } from "./client"; +import { sanitizeWorkspaceRuntimeControlTarget } from "./workspace-runtime-control"; export const executionWorkspacesApi = { + listSummaries: ( + companyId: string, + filters?: { + projectId?: string; + projectWorkspaceId?: string; + issueId?: string; + status?: string; + reuseEligible?: boolean; + }, + ) => { + const params = new URLSearchParams(); + if (filters?.projectId) params.set("projectId", filters.projectId); + if (filters?.projectWorkspaceId) params.set("projectWorkspaceId", filters.projectWorkspaceId); + if (filters?.issueId) params.set("issueId", filters.issueId); + if (filters?.status) params.set("status", filters.status); + if (filters?.reuseEligible) params.set("reuseEligible", "true"); + params.set("summary", "true"); + const qs = params.toString(); + return api.get( + `/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`, + ); + }, list: ( companyId: string, filters?: { @@ -26,10 +55,23 @@ export const executionWorkspacesApi = { api.get(`/execution-workspaces/${id}/close-readiness`), listWorkspaceOperations: (id: string) => api.get(`/execution-workspaces/${id}/workspace-operations`), - controlRuntimeServices: (id: string, action: "start" | "stop" | "restart") => + controlRuntimeServices: ( + id: string, + action: "start" | "stop" | "restart", + target: WorkspaceRuntimeControlTarget = {}, + ) => api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>( `/execution-workspaces/${id}/runtime-services/${action}`, - {}, + sanitizeWorkspaceRuntimeControlTarget(target), + ), + controlRuntimeCommands: ( + id: string, + action: "start" | "stop" | "restart" | "run", + target: WorkspaceRuntimeControlTarget = {}, + ) => + api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>( + `/execution-workspaces/${id}/runtime-commands/${action}`, + sanitizeWorkspaceRuntimeControlTarget(target), ), update: (id: string, data: Record) => api.patch(`/execution-workspaces/${id}`, data), }; diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 045c2b1f2f1..72c8e0a700b 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -1,6 +1,14 @@ import type { HeartbeatRun, HeartbeatRunEvent, InstanceSchedulerHeartbeatAgent, WorkspaceOperation } from "@paperclipai/shared"; import { api } from "./client"; +export interface RunLivenessFields { + livenessState: HeartbeatRun["livenessState"]; + livenessReason: string | null; + continuationAttempt: number; + lastUsefulActionAt: string | Date | null; + nextAction: string | null; +} + export interface ActiveRunForIssue { id: string; status: string; @@ -13,6 +21,11 @@ export interface ActiveRunForIssue { agentName: string; adapterType: string; issueId?: string | null; + livenessState?: RunLivenessFields["livenessState"]; + livenessReason?: string | null; + continuationAttempt?: number; + lastUsefulActionAt?: string | Date | null; + nextAction?: string | null; } export interface LiveRunForIssue { @@ -27,6 +40,11 @@ export interface LiveRunForIssue { agentName: string; adapterType: string; issueId?: string | null; + livenessState?: RunLivenessFields["livenessState"]; + livenessReason?: string | null; + continuationAttempt?: number; + lastUsefulActionAt?: string | null; + nextAction?: string | null; } export const heartbeatsApi = { diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 51039bcff6f..d7f834d1a35 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -15,6 +15,7 @@ export { dashboardApi } from "./dashboard"; export { heartbeatsApi } from "./heartbeats"; export { instanceSettingsApi } from "./instanceSettings"; export { sidebarBadgesApi } from "./sidebarBadges"; +export { sidebarPreferencesApi } from "./sidebarPreferences"; export { inboxDismissalsApi } from "./inboxDismissals"; export { companySkillsApi } from "./companySkills"; export { memoryApi } from "./memory"; diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index bd604af9cdf..7fbaab0e35c 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -97,6 +97,8 @@ export const issuesApi = { const qs = params.toString(); return api.get(`/issues/${id}/comments${qs ? `?${qs}` : ""}`); }, + getComment: (id: string, commentId: string) => + api.get(`/issues/${id}/comments/${commentId}`), listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`), listFeedbackTraces: (id: string, filters?: Record) => { const params = new URLSearchParams(); @@ -126,7 +128,12 @@ export const issuesApi = { ...(interrupt === undefined ? {} : { interrupt }), }, ), - listDocuments: (id: string) => api.get(`/issues/${id}/documents`), + cancelComment: (id: string, commentId: string) => + api.delete(`/issues/${id}/comments/${commentId}`), + listDocuments: (id: string, options?: { includeSystem?: boolean }) => + api.get( + `/issues/${id}/documents${options?.includeSystem ? "?includeSystem=true" : ""}`, + ), getDocument: (id: string, key: string) => api.get(`/issues/${id}/documents/${encodeURIComponent(key)}`), upsertDocument: (id: string, key: string, data: UpsertIssueDocument) => api.put(`/issues/${id}/documents/${encodeURIComponent(key)}`, data), diff --git a/ui/src/api/projects.ts b/ui/src/api/projects.ts index 763718ffb51..e975f829c11 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -1,5 +1,11 @@ -import type { Project, ProjectWorkspace, WorkspaceOperation } from "@paperclipai/shared"; +import type { + Project, + ProjectWorkspace, + WorkspaceOperation, + WorkspaceRuntimeControlTarget, +} from "@paperclipai/shared"; import { api } from "./client"; +import { sanitizeWorkspaceRuntimeControlTarget } from "./workspace-runtime-control"; function withCompanyScope(path: string, companyId?: string) { if (!companyId) return path; @@ -32,10 +38,22 @@ export const projectsApi = { workspaceId: string, action: "start" | "stop" | "restart", companyId?: string, + target: WorkspaceRuntimeControlTarget = {}, ) => api.post<{ workspace: ProjectWorkspace; operation: WorkspaceOperation }>( projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}/runtime-services/${action}`), - {}, + sanitizeWorkspaceRuntimeControlTarget(target), + ), + controlWorkspaceCommands: ( + projectId: string, + workspaceId: string, + action: "start" | "stop" | "restart" | "run", + companyId?: string, + target: WorkspaceRuntimeControlTarget = {}, + ) => + api.post<{ workspace: ProjectWorkspace; operation: WorkspaceOperation }>( + projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}/runtime-commands/${action}`), + sanitizeWorkspaceRuntimeControlTarget(target), ), removeWorkspace: (projectId: string, workspaceId: string, companyId?: string) => api.delete(projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`)), diff --git a/ui/src/api/sidebarPreferences.ts b/ui/src/api/sidebarPreferences.ts new file mode 100644 index 00000000000..cd472774f5e --- /dev/null +++ b/ui/src/api/sidebarPreferences.ts @@ -0,0 +1,12 @@ +import type { SidebarOrderPreference, UpsertSidebarOrderPreference } from "@paperclipai/shared"; +import { api } from "./client"; + +export const sidebarPreferencesApi = { + getCompanyOrder: () => api.get("/sidebar-preferences/me"), + updateCompanyOrder: (data: UpsertSidebarOrderPreference) => + api.put("/sidebar-preferences/me", data), + getProjectOrder: (companyId: string) => + api.get(`/companies/${companyId}/sidebar-preferences/me`), + updateProjectOrder: (companyId: string, data: UpsertSidebarOrderPreference) => + api.put(`/companies/${companyId}/sidebar-preferences/me`, data), +}; diff --git a/ui/src/api/userProfiles.ts b/ui/src/api/userProfiles.ts new file mode 100644 index 00000000000..6c1c13c8513 --- /dev/null +++ b/ui/src/api/userProfiles.ts @@ -0,0 +1,9 @@ +import type { UserProfileResponse } from "@paperclipai/shared"; +import { api } from "./client"; + +export const userProfilesApi = { + get: (companyId: string, userSlug: string) => + api.get( + `/companies/${companyId}/users/${encodeURIComponent(userSlug)}/profile`, + ), +}; diff --git a/ui/src/api/workspace-runtime-control.test.ts b/ui/src/api/workspace-runtime-control.test.ts new file mode 100644 index 00000000000..ac7b28585cb --- /dev/null +++ b/ui/src/api/workspace-runtime-control.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeWorkspaceRuntimeControlTarget } from "./workspace-runtime-control"; + +describe("sanitizeWorkspaceRuntimeControlTarget", () => { + it("drops unexpected keys while preserving the selected runtime target", () => { + const sanitized = sanitizeWorkspaceRuntimeControlTarget({ + workspaceCommandId: "web", + runtimeServiceId: "service-1", + serviceIndex: 2, + ...( { action: "start" } as Record ), + }); + + expect(sanitized).toEqual({ + workspaceCommandId: "web", + runtimeServiceId: "service-1", + serviceIndex: 2, + }); + expect("action" in sanitized).toBe(false); + }); + + it("normalizes an omitted target to nullable fields", () => { + expect(sanitizeWorkspaceRuntimeControlTarget()).toEqual({ + workspaceCommandId: null, + runtimeServiceId: null, + serviceIndex: null, + }); + }); +}); diff --git a/ui/src/api/workspace-runtime-control.ts b/ui/src/api/workspace-runtime-control.ts new file mode 100644 index 00000000000..d99d6f1c97d --- /dev/null +++ b/ui/src/api/workspace-runtime-control.ts @@ -0,0 +1,11 @@ +import type { WorkspaceRuntimeControlTarget } from "@paperclipai/shared"; + +export function sanitizeWorkspaceRuntimeControlTarget( + target: WorkspaceRuntimeControlTarget = {}, +): WorkspaceRuntimeControlTarget { + return { + workspaceCommandId: target.workspaceCommandId ?? null, + runtimeServiceId: target.runtimeServiceId ?? null, + serviceIndex: target.serviceIndex ?? null, + }; +} diff --git a/ui/src/components/ActivityRow.tsx b/ui/src/components/ActivityRow.tsx index dbe88785275..c969a9599aa 100644 --- a/ui/src/components/ActivityRow.tsx +++ b/ui/src/components/ActivityRow.tsx @@ -4,6 +4,7 @@ import { timeAgo } from "../lib/timeAgo"; import { cn } from "../lib/utils"; import { formatActivityVerb } from "../lib/activity-format"; import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared"; +import type { CompanyUserProfile } from "../lib/company-members"; function entityLink(entityType: string, entityId: string, name?: string | null): string | null { switch (entityType) { @@ -19,13 +20,14 @@ function entityLink(entityType: string, entityId: string, name?: string | null): interface ActivityRowProps { event: ActivityEvent; agentMap: Map; + userProfileMap?: Map; entityNameMap: Map; entityTitleMap?: Map; className?: string; } -export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) { - const verb = formatActivityVerb(event.action, event.details, { agentMap }); +export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) { + const verb = formatActivityVerb(event.action, event.details, { agentMap, userProfileMap }); const isHeartbeatEvent = event.entityType === "heartbeat_run"; const heartbeatAgentId = isHeartbeatEvent @@ -43,13 +45,16 @@ export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, cl : entityLink(event.entityType, event.entityId, name); const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null; - const actorName = actor?.name ?? (event.actorType === "system" ? "System" : event.actorType === "user" ? "Board" : event.actorId || "Unknown"); + const userProfile = event.actorType === "user" ? userProfileMap?.get(event.actorId) : null; + const actorName = actor?.name ?? (event.actorType === "system" ? "System" : userProfile?.label ?? (event.actorType === "user" ? "Board" : event.actorId || "Unknown")); + const actorAvatarUrl = userProfile?.image ?? null; const inner = (

diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 065405f9a2d..17437bc84f5 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -6,6 +6,7 @@ import type { CompanySecret, EnvBinding, } from "@paperclipai/shared"; +import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS } from "@paperclipai/shared"; import type { AdapterModel } from "../api/agents"; import { agentsApi } from "../api/agents"; import { secretsApi } from "../api/secrets"; @@ -50,6 +51,7 @@ import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadat import { getAdapterLabel } from "../adapters/adapter-display-registry"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch"; +import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; /* ---- Create mode values ---- */ @@ -269,8 +271,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const adapterType = isCreate ? props.values.adapterType : overlay.adapterType ?? props.agent.adapterType; - const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]); - const isLocal = !NONLOCAL_TYPES.has(adapterType); + const getCapabilities = useAdapterCapabilities(); + const adapterCaps = getCapabilities(adapterType); + const isLocal = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt; const showLegacyWorkingDirectoryField = isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); @@ -919,7 +922,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { value={eff( "heartbeat", "maxConcurrentRuns", - Number(heartbeat.maxConcurrentRuns ?? 1), + Number(heartbeat.maxConcurrentRuns ?? AGENT_DEFAULT_MAX_CONCURRENT_RUNS), )} onCommit={(v) => mark("heartbeat", "maxConcurrentRuns", v)} immediate diff --git a/ui/src/components/BreadcrumbBar.tsx b/ui/src/components/BreadcrumbBar.tsx index a4d1462ac53..98319075f6b 100644 --- a/ui/src/components/BreadcrumbBar.tsx +++ b/ui/src/components/BreadcrumbBar.tsx @@ -31,7 +31,7 @@ function GlobalToolbarPlugins({ context }: { context: GlobalToolbarContext }) { } export function BreadcrumbBar() { - const { breadcrumbs } = useBreadcrumbs(); + const { breadcrumbs, mobileToolbar } = useBreadcrumbs(); const { toggleSidebar, isMobile } = useSidebar(); const { selectedCompanyId, selectedCompany } = useCompany(); @@ -45,6 +45,14 @@ export function BreadcrumbBar() { const globalToolbarSlots = ; + if (isMobile && mobileToolbar) { + return ( +

+ {mobileToolbar} +
+ ); + } + if (breadcrumbs.length === 0) { return (
diff --git a/ui/src/components/CloudAccessGate.tsx b/ui/src/components/CloudAccessGate.tsx new file mode 100644 index 00000000000..eaea736bcdf --- /dev/null +++ b/ui/src/components/CloudAccessGate.tsx @@ -0,0 +1,114 @@ +import { Navigate, Outlet, useLocation } from "@/lib/router"; +import { useQuery } from "@tanstack/react-query"; +import { accessApi } from "@/api/access"; +import { authApi } from "@/api/auth"; +import { healthApi } from "@/api/health"; +import { queryKeys } from "@/lib/queryKeys"; + +function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { + return ( +
+
+

Instance setup required

+

+ {hasActiveInvite + ? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:" + : "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"} +

+
+{`pnpm paperclipai auth bootstrap-ceo`}
+        
+
+
+ ); +} + +function NoBoardAccessPage() { + return ( +
+
+

No company access

+

+ This account is signed in, but it does not have an active company membership or instance-admin access on + this Paperclip instance. +

+

+ Use a company invite or sign in with an account that already belongs to this org. +

+
+
+ ); +} + +export function CloudAccessGate() { + const location = useLocation(); + const healthQuery = useQuery({ + queryKey: queryKeys.health, + queryFn: () => healthApi.get(), + retry: false, + refetchInterval: (query) => { + const data = query.state.data as + | { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" } + | undefined; + return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending" + ? 2000 + : false; + }, + refetchIntervalInBackground: true, + }); + + const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated"; + const sessionQuery = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + enabled: isAuthenticatedMode, + retry: false, + }); + + const boardAccessQuery = useQuery({ + queryKey: queryKeys.access.currentBoardAccess, + queryFn: () => accessApi.getCurrentBoardAccess(), + enabled: isAuthenticatedMode && !!sessionQuery.data, + retry: false, + }); + + if ( + healthQuery.isLoading || + (isAuthenticatedMode && sessionQuery.isLoading) || + (isAuthenticatedMode && !!sessionQuery.data && boardAccessQuery.isLoading) + ) { + return
Loading...
; + } + + if (healthQuery.error || boardAccessQuery.error) { + return ( +
+ {healthQuery.error instanceof Error + ? healthQuery.error.message + : boardAccessQuery.error instanceof Error + ? boardAccessQuery.error.message + : "Failed to load app state"} +
+ ); + } + + if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") { + return ; + } + + if (isAuthenticatedMode && !sessionQuery.data) { + const next = encodeURIComponent(`${location.pathname}${location.search}`); + return ; + } + + if ( + isAuthenticatedMode && + sessionQuery.data && + !boardAccessQuery.data?.isInstanceAdmin && + (boardAccessQuery.data?.companyIds.length ?? 0) === 0 + ) { + return ; + } + + return ; +} diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx index 0fa73210108..860ea0ad8f7 100644 --- a/ui/src/components/CommentThread.test.tsx +++ b/ui/src/components/CommentThread.test.tsx @@ -178,6 +178,52 @@ describe("CommentThread", () => { }); }); + it("hides the reopen control and infers reopen for closed agent-assigned issues", async () => { + const root = createRoot(container); + const onAdd = vi.fn(async () => {}); + + act(() => { + root.render( + + + , + ); + }); + + expect(container.textContent).not.toContain("Re-open"); + + const editor = container.querySelector('textarea[aria-label="Comment editor"]') as HTMLTextAreaElement | null; + const submitButton = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent === "Comment", + ) as HTMLButtonElement | undefined; + expect(editor).not.toBeNull(); + expect(submitButton).toBeDefined(); + + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + "value", + )?.set; + valueSetter?.call(editor, "Please pick this back up"); + editor?.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + submitButton?.click(); + }); + + expect(onAdd).toHaveBeenCalledWith("Please pick this back up", true, undefined); + + act(() => { + root.unmount(); + }); + }); + it("renders linked approvals inline in the timeline", () => { const root = createRoot(container); const agent: Agent = { diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index f4ad17b5233..06fe76813d5 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -134,6 +134,11 @@ function parseReassignment(target: string): CommentReassignment | null { return null; } +function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) { + const isClosed = issueStatus === "done" || issueStatus === "cancelled"; + return isClosed && assigneeValue.startsWith("agent:"); +} + function humanizeValue(value: string | null): string { if (!value) return "None"; return value.replace(/_/g, " "); @@ -647,6 +652,7 @@ export function CommentThread({ pendingApprovalAction = null, onVote, onAdd, + issueStatus, agentMap, currentUserId, imageUploadHandler, @@ -663,7 +669,6 @@ export function CommentThread({ composerDisabledReason = null, }: CommentThreadProps) { const [body, setBody] = useState(""); - const [reopen, setReopen] = useState(true); const [submitting, setSubmitting] = useState(false); const [attaching, setAttaching] = useState(false); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; @@ -784,14 +789,17 @@ export function CommentThread({ if (!trimmed) return; const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null; + const reopen = shouldImplicitlyReopenComment( + issueStatus, + hasReassignment ? reassignTarget : currentAssigneeValue, + ) ? true : undefined; const submittedBody = trimmed; setSubmitting(true); setBody(""); try { - await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined); + await onAdd(submittedBody, reopen, reassignment ?? undefined); if (draftKey) clearDraft(draftKey); - setReopen(true); setReassignTarget(effectiveSuggestedAssigneeValue); } catch { setBody((current) => @@ -935,15 +943,6 @@ export function CommentThread({
)} - {enableReassign && reassignOptions.length > 0 && ( setImageError(true)} - className="absolute inset-0 h-full w-full object-cover" + className={cn( + "absolute inset-0 h-full w-full", + logoFit === "contain" ? "object-contain" : "object-cover", + )} /> ) : patternDataUrl ? ( [c.id, c])); - const sorted: Company[] = []; - - for (const id of order) { - const c = byId.get(id); - if (c) { - sorted.push(c); - byId.delete(id); - } - } - // Append any companies not in stored order - for (const c of byId.values()) { - sorted.push(c); - } - return sorted; -} - function SortableCompanyItem({ company, isSelected, @@ -103,6 +69,10 @@ function SortableCompanyItem({ { + if (isDragging) { + e.preventDefault(); + return; + } e.preventDefault(); onSelect(); }} @@ -164,6 +134,11 @@ export function CompanyRail() { () => companies.filter((company) => company.status !== "archived"), [companies], ); + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + }); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; const companyIds = useMemo(() => sidebarCompanies.map((company) => company.id), [sidebarCompanies]); const liveRunsQueries = useQueries({ @@ -195,52 +170,10 @@ export function CompanyRail() { return result; }, [companyIds, sidebarBadgeQueries]); - // Maintain sorted order in local state, synced from companies + localStorage - const [orderedIds, setOrderedIds] = useState(() => - sortByStoredOrder(sidebarCompanies).map((c) => c.id) - ); - - // Re-sync orderedIds from localStorage whenever companies changes. - // Handles initial data load (companies starts as [] before query resolves) - // and subsequent refetches triggered by live updates. - useEffect(() => { - if (sidebarCompanies.length === 0) { - setOrderedIds([]); - return; - } - setOrderedIds(sortByStoredOrder(sidebarCompanies).map((c) => c.id)); - }, [sidebarCompanies]); - - // Sync order across tabs via the native storage event - useEffect(() => { - const handleStorage = (e: StorageEvent) => { - if (e.key !== ORDER_STORAGE_KEY) return; - try { - const ids: string[] = e.newValue ? JSON.parse(e.newValue) : []; - setOrderedIds(ids); - } catch { /* ignore malformed data */ } - }; - window.addEventListener("storage", handleStorage); - return () => window.removeEventListener("storage", handleStorage); - }, []); - - // Re-derive when companies change (new company added/removed) - const orderedCompanies = useMemo(() => { - const byId = new Map(sidebarCompanies.map((c) => [c.id, c])); - const result: Company[] = []; - for (const id of orderedIds) { - const c = byId.get(id); - if (c) { - result.push(c); - byId.delete(id); - } - } - // Append any new companies not yet in our order - for (const c of byId.values()) { - result.push(c); - } - return result; - }, [sidebarCompanies, orderedIds]); + const { orderedCompanies, persistOrder } = useCompanyOrder({ + companies: sidebarCompanies, + userId: currentUserId, + }); // Require 8px of movement before starting a drag to avoid interfering with clicks const sensors = useSensors( @@ -260,11 +193,9 @@ export function CompanyRail() { const newIndex = ids.indexOf(over.id as string); if (oldIndex === -1 || newIndex === -1) return; - const newIds = arrayMove(ids, oldIndex, newIndex); - setOrderedIds(newIds); - saveOrder(newIds); + persistOrder(arrayMove(ids, oldIndex, newIndex)); }, - [orderedCompanies] + [orderedCompanies, persistOrder] ); return ( diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx new file mode 100644 index 00000000000..fa9a777af76 --- /dev/null +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CompanySettingsSidebar } from "./CompanySettingsSidebar"; + +const sidebarNavItemMock = vi.hoisted(() => vi.fn()); +const mockSidebarBadgesApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ + children, + to, + onClick, + }: { + children: React.ReactNode; + to: string; + onClick?: () => void; + }) => ( + + ), +})); + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + selectedCompany: { id: "company-1", name: "Paperclip" }, + }), +})); + +vi.mock("@/context/SidebarContext", () => ({ + useSidebar: () => ({ + isMobile: false, + setSidebarOpen: vi.fn(), + }), +})); + +vi.mock("./SidebarNavItem", () => ({ + SidebarNavItem: (props: { + to: string; + label: string; + end?: boolean; + badge?: number; + }) => { + sidebarNavItemMock(props); + return
{props.label}
; + }, +})); + +vi.mock("@/api/sidebarBadges", () => ({ + sidebarBadgesApi: mockSidebarBadgesApi, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("CompanySettingsSidebar", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockSidebarBadgesApi.get.mockResolvedValue({ + inbox: 0, + approvals: 0, + failedRuns: 0, + joinRequests: 2, + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("renders the company back link and the settings sections in the sidebar", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Paperclip"); + expect(container.textContent).toContain("Company Settings"); + expect(container.textContent).toContain("General"); + expect(container.textContent).toContain("Access"); + expect(container.textContent).toContain("Invites"); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings", + label: "General", + end: true, + }), + ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/access", + label: "Access", + badge: 2, + end: true, + }), + ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/invites", + label: "Invites", + end: true, + }), + ); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx new file mode 100644 index 00000000000..6339cf1fddc --- /dev/null +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -0,0 +1,69 @@ +import { useQuery } from "@tanstack/react-query"; +import { ChevronLeft, MailPlus, Settings, Shield, SlidersHorizontal } from "lucide-react"; +import { sidebarBadgesApi } from "@/api/sidebarBadges"; +import { ApiError } from "@/api/client"; +import { Link } from "@/lib/router"; +import { queryKeys } from "@/lib/queryKeys"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { SidebarNavItem } from "./SidebarNavItem"; + +export function CompanySettingsSidebar() { + const { selectedCompany, selectedCompanyId } = useCompany(); + const { isMobile, setSidebarOpen } = useSidebar(); + const { data: badges } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.sidebarBadges(selectedCompanyId) + : ["sidebar-badges", "__disabled__"] as const, + queryFn: async () => { + try { + return await sidebarBadgesApi.get(selectedCompanyId!); + } catch (error) { + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + return null; + } + throw error; + } + }, + enabled: !!selectedCompanyId, + retry: false, + refetchInterval: 15_000, + }); + + return ( + + ); +} diff --git a/ui/src/components/CopyText.tsx b/ui/src/components/CopyText.tsx index fc399fa0443..ff4134f34a9 100644 --- a/ui/src/components/CopyText.tsx +++ b/ui/src/components/CopyText.tsx @@ -1,21 +1,34 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; interface CopyTextProps { text: string; /** What to display. Defaults to `text`. */ children?: React.ReactNode; + containerClassName?: string; className?: string; + ariaLabel?: string; + title?: string; /** Tooltip message shown after copying. Default: "Copied!" */ copiedLabel?: string; } -export function CopyText({ text, children, className, copiedLabel = "Copied!" }: CopyTextProps) { +export function CopyText({ + text, + children, + containerClassName, + className, + ariaLabel, + title, + copiedLabel = "Copied!", +}: CopyTextProps) { const [visible, setVisible] = useState(false); const [label, setLabel] = useState(copiedLabel); const timerRef = useRef>(undefined); const triggerRef = useRef(null); + useEffect(() => () => clearTimeout(timerRef.current), []); + const handleClick = useCallback(async () => { try { if (navigator.clipboard && window.isSecureContext) { @@ -45,10 +58,12 @@ export function CopyText({ text, children, className, copiedLabel = "Copied!" }: }, [copiedLabel, text]); return ( - + )} + {otherUserOptions + .filter((option) => { + if (!search.trim()) return true; + return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase()); + }) + .map((option) => ( + + ))} {sortedAgents .filter((agent) => { if (!search.trim()) return true; diff --git a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx index 78c09562046..fe4477966ad 100644 --- a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx +++ b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx @@ -3,7 +3,7 @@ import type { ExecutionWorkspace } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { Loader2 } from "lucide-react"; import { executionWorkspacesApi } from "../api/execution-workspaces"; -import { useToast } from "../context/ToastContext"; +import { useToastActions } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { formatDateTime, issueUrl } from "../lib/utils"; import { Button } from "./ui/button"; @@ -44,7 +44,7 @@ export function ExecutionWorkspaceCloseDialog({ onClosed, }: ExecutionWorkspaceCloseDialogProps) { const queryClient = useQueryClient(); - const { pushToast } = useToast(); + const { pushToast } = useToastActions(); const actionLabel = currentStatus === "cleanup_failed" ? "Retry close" : "Close workspace"; const readinessQuery = useQuery({ @@ -88,27 +88,27 @@ export function ExecutionWorkspaceCloseDialog({ { if (!closeWorkspace.isPending) onOpenChange(nextOpen); }}> - + {actionLabel} - + Archive {workspaceName} and clean up any owned workspace artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views. {readinessQuery.isLoading ? ( -
- +
+ Checking whether this workspace is safe to close...
) : readinessQuery.error ? ( -
+
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
) : readiness ? ( -
-
+
+
{readiness.state === "blocked" ? "Close is blocked" @@ -129,10 +129,10 @@ export function ExecutionWorkspaceCloseDialog({ {blockingIssues.length > 0 ? (
-

Blocking issues

-
+

Blocking issues

+
{blockingIssues.map((issue) => ( -
+
{issue.identifier ?? issue.id} · {issue.title} @@ -147,10 +147,10 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.blockingReasons.length > 0 ? (
-

Blocking reasons

-
    - {readiness.blockingReasons.map((reason, idx) => ( -
  • +

    Blocking reasons

    +
      + {readiness.blockingReasons.map((reason) => ( +
    • {reason}
    • ))} @@ -160,10 +160,10 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.warnings.length > 0 ? (
      -

      Warnings

      -
        - {readiness.warnings.map((warning, idx) => ( -
      • +

        Warnings

        +
          + {readiness.warnings.map((warning) => ( +
        • {warning}
        • ))} @@ -173,16 +173,16 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.git ? (
          -

          Git status

          -
          -
          -
          +

          Git status

          +
          +
          +
          Branch
          -
          {readiness.git.branchName ?? "Unknown"}
          +
          {readiness.git.branchName ?? "Unknown"}
          -
          +
          Base ref
          -
          {readiness.git.baseRef ?? "Not set"}
          +
          {readiness.git.baseRef ?? "Not set"}
          Merged into base
          @@ -209,10 +209,10 @@ export function ExecutionWorkspaceCloseDialog({ {otherLinkedIssues.length > 0 ? (
          -

          Other linked issues

          -
          +

          Other linked issues

          +
          {otherLinkedIssues.map((issue) => ( -
          +
          {issue.identifier ?? issue.id} · {issue.title} @@ -227,10 +227,10 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.runtimeServices.length > 0 ? (
          -

          Attached runtime services

          -
          +

          Attached runtime services

          +
          {readiness.runtimeServices.map((service) => ( -
          +
          {service.serviceName} {service.status} · {service.lifecycle} @@ -245,10 +245,10 @@ export function ExecutionWorkspaceCloseDialog({ ) : null}
          -

          Cleanup actions

          -
          +

          Cleanup actions

          +
          {readiness.plannedActions.map((action, index) => ( -
          +
          {action.label}
          {action.description}
          {action.command ? ( @@ -262,20 +262,20 @@ export function ExecutionWorkspaceCloseDialog({
          {currentStatus === "cleanup_failed" ? ( -
          +
          Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the workspace status if it succeeds.
          ) : null} {currentStatus === "archived" ? ( -
          +
          This workspace is already archived.
          ) : null} {readiness.git?.repoRoot ? ( -
          +
          Repo root: {readiness.git.repoRoot} {readiness.git.workspacePath ? ( <> diff --git a/ui/src/components/InlineEditor.test.tsx b/ui/src/components/InlineEditor.test.tsx index 1f474f7b1fd..830d2cedc6d 100644 --- a/ui/src/components/InlineEditor.test.tsx +++ b/ui/src/components/InlineEditor.test.tsx @@ -164,6 +164,62 @@ describe("InlineEditor", () => { }); outside.remove(); }); + + it("syncs a new multiline value while focused when the user has not edited locally", () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const textarea = container.querySelector('[data-testid="multiline-md-mock"]'); + expect(textarea).not.toBeNull(); + expect(textarea?.value).toBe(""); + + act(() => { + textarea!.focus(); + }); + + act(() => { + root.render(); + }); + + expect(textarea?.value).toBe("Loaded description"); + + act(() => { + root.unmount(); + }); + }); + + it("preserves focused multiline local edits when the prop value changes underneath them", () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const textarea = container.querySelector('[data-testid="multiline-md-mock"]'); + expect(textarea).not.toBeNull(); + + act(() => { + textarea!.focus(); + }); + act(() => { + setNativeTextareaValue(textarea!, "Local draft"); + }); + + act(() => { + root.render(); + }); + + expect(textarea?.value).toBe("Local draft"); + + act(() => { + root.unmount(); + }); + }); }); describe("queueContainedBlurCommit", () => { diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index f509fe7aa62..2c878b3fa22 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -54,6 +54,7 @@ export function InlineEditor({ const [editing, setEditing] = useState(false); const [multilineFocused, setMultilineFocused] = useState(false); const [draft, setDraft] = useState(value); + const lastPropValueRef = useRef(value); const inputRef = useRef(null); const markdownRef = useRef(null); const autosaveDebounceRef = useRef | null>(null); @@ -66,8 +67,14 @@ export function InlineEditor({ } = useAutosaveIndicator(); useEffect(() => { - if (multiline && multilineFocused) return; - setDraft(value); + const previousValue = lastPropValueRef.current; + lastPropValueRef.current = value; + setDraft((currentDraft) => { + if (multiline && multilineFocused && currentDraft !== previousValue) { + return currentDraft; + } + return value; + }); }, [value, multiline, multilineFocused]); useEffect(() => { diff --git a/ui/src/components/InlineEntitySelector.tsx b/ui/src/components/InlineEntitySelector.tsx index 16d62a0de9b..6bbcf64940f 100644 --- a/ui/src/components/InlineEntitySelector.tsx +++ b/ui/src/components/InlineEntitySelector.tsx @@ -1,6 +1,7 @@ import { forwardRef, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Check } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { orderItemsBySelectedAndRecent } from "../lib/recent-selections"; import { cn } from "../lib/utils"; export interface InlineEntityOption { @@ -21,6 +22,7 @@ interface InlineEntitySelectorProps { className?: string; renderTriggerValue?: (option: InlineEntityOption | null) => ReactNode; renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode; + recentOptionIds?: string[]; /** Skip the Portal so the popover stays in the DOM tree (fixes scroll inside Dialogs). */ disablePortal?: boolean; /** Open the popover when the trigger receives keyboard/programmatic focus. */ @@ -41,6 +43,7 @@ export const InlineEntitySelector = forwardRef( - () => [{ id: "", label: noneLabel, searchText: noneLabel }, ...options], - [noneLabel, options], - ); + const allOptions = useMemo(() => { + const baseOptions = [{ id: "", label: noneLabel, searchText: noneLabel }, ...options]; + return orderItemsBySelectedAndRecent(baseOptions, value, recentOptionIds); + }, [noneLabel, options, recentOptionIds, value]); const filteredOptions = useMemo(() => { const term = query.trim().toLowerCase(); diff --git a/ui/src/components/InstanceSidebar.tsx b/ui/src/components/InstanceSidebar.tsx index b39b959c656..6a1d0add150 100644 --- a/ui/src/components/InstanceSidebar.tsx +++ b/ui/src/components/InstanceSidebar.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react"; +import { Clock3, Cpu, FlaskConical, Puzzle, Settings, Shield, SlidersHorizontal, UserRoundPen } from "lucide-react"; import { NavLink } from "@/lib/router"; import { pluginsApi } from "@/api/plugins"; import { queryKeys } from "@/lib/queryKeys"; @@ -23,7 +23,9 @@ export function InstanceSidebar() {