diff --git a/.agents/skills/pr-gardening/SKILL.md b/.agents/skills/pr-gardening/SKILL.md index 80dbb6f987..925ca6db55 100644 --- a/.agents/skills/pr-gardening/SKILL.md +++ b/.agents/skills/pr-gardening/SKILL.md @@ -19,13 +19,14 @@ Actively garden pull requests referenced by Paperclip issues active in a recent - Never use mutating `gh` commands or mutating GitHub API requests. The scripts only use `gh pr view` and read-only `gh api` GET requests. - Draft pull requests are report-only. Do not post gardening comments for drafts. - Comment only on existing originating issues. Never create a gardening issue per pull request. -- `--dry-run` suppresses all Paperclip gardening comments. Discovery and GitHub inspection remain read-only in every mode. +- `--dry-run` suppresses all Paperclip mutations, including gardening comments and inbox archives. Discovery and GitHub inspection remain read-only in every mode. ## Inputs - `--days `: issue activity window, default `30`. - `--repo `: GitHub repository, default detected by `gh repo view`. -- `--dry-run`: discover, verify, and report without posting Stage C comments. +- `--dry-run`: discover, verify, and report without posting comments or archiving inbox entries. +- `--archive-inbox`: after GitHub confirms a candidate PR is merged at its current head, archive the originating issue from the responsible user's inbox in Stage D. - `--cooldown-hours `: repeat-comment cooldown, default `48`. - `--max-rounds `: maximum gardening rounds per PR, default `3`. @@ -96,7 +97,28 @@ Current-head verification at `abc123` found: Gardening round 1/3. Re-verification is required after changes; do not merge based on this comment. ``` -## Stage D — Monitor to Termination +## Stage D — Optional Inbox Tidy-Up + +Run this stage only when the caller explicitly supplied `--archive-inbox`. `--dry-run` always suppresses inbox mutations, even when `--archive-inbox` is also present. Without the flag, do not archive anything. + +Stage D applies to a previously monitored candidate that transitions to merged. Rerun Stage B immediately before this stage and require GitHub to report `state: merged` for the same current head SHA recorded by that verification. Fresh Stage A discovery intentionally drops PRs that were already merged or closed. + +For each qualifying candidate, archive its `originatingIssue` from the responsible user's inbox with `POST /api/issues/:issueId/inbox-archive` and an empty JSON body. Do not pass `userId`; the Paperclip API resolves the responsible user from the gardener's run context and enforces that user's inbox-agent policy. GitHub access remains read-only. + +Do not archive PRs that are merely `ready`, closed without merging, draft, pending, or merged at an unverified or stale head. Never archive an originating issue while the user is still awaiting review, a decision, approval, or other action on it. If Paperclip denies the mutation because the responsible user is unresolved, inbox management is disabled, the gardener is not allowlisted, or a trust boundary applies, report the denial and continue without retrying around policy. + +After a successful archive, leave a standard gardening marker comment on the originating issue that names the archived issue and merged PR: + +```markdown + +Inbox tidy-up: archived [PAP-310](/PAP/issues/PAP-310) from the responsible user's inbox after confirming https://github.com/paperclipai/paperclip/pull/1234 merged at current head `abc123`. + +This archive is audited and reversible; later issue activity may resurface the item. +``` + +Use `POST /api/issues/:issueId/comments` and include `X-Paperclip-Run-Id` on both the archive and comment requests. In `--dry-run`, report the archive and marker comment that would have been written, but perform neither mutation. + +## Stage E — Monitor to Termination Set the gardening run issue's `blockedByIssueIds` to the non-terminal issues commented in Stage C so blocker resolution wakes the gardener. A scheduled or manual rerun is the fallback. @@ -108,7 +130,7 @@ On every wake, rerun Stage B first. A PR terminates from active gardening only w Do not leave the gardening issue blocked on terminal issues. Do not poll agents or long-running sessions. -## Stage E — Render and Publish the Report +## Stage F — Render and Publish the Report ```bash node .agents/skills/pr-gardening/scripts/render-report.mjs \ @@ -132,4 +154,4 @@ Run focused script tests: node --test .agents/skills/pr-gardening/scripts/pr-gardening.test.mjs ``` -For a live dry run, execute Stages A, B, and E with `--dry-run`, then sanity-check named PRs only if they are still open. Merged or closed examples should appear under `droppedClosedPullRequests`, not in readiness results. +For a live dry run, execute Stages A, B, and F with `--dry-run`, then sanity-check named PRs only if they are still open. Merged or closed examples should appear under `droppedClosedPullRequests`, not in readiness results. If also exercising `--archive-inbox`, confirm the report describes the suppressed Stage D action and that no Paperclip archive or marker-comment mutation occurred. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index c151566373..6ad5fb2474 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -543,6 +543,8 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and | Report cost | yes | yes | | Set company budget | yes | no | | Set subordinate budget | yes | yes (manager subtree only) | +| Manage responsible user's inbox state | yes | yes (default-open policy) | +| Manage another user's inbox state | yes | scoped `inbox:manage` grant | | Set work-object visibility (issue/project) | no | no (pro gate) | ## 9.4 Permission Terminology and Default Visibility Rule @@ -576,6 +578,7 @@ The approved term set is: | Work-object visibility | All issues and projects in-company are visible to board and agents | Project/issue ACLs and reviewer-only channels | | Tool/secret policy | Secret refs, log redaction, and adapter-level command/webhook restrictions | Tool allowlists with centralized policy evaluation | | Company skills | Open to authenticated company agents; core enforces invariants and any stored restriction policy | Paperclip EE policy editor, protected-skill controls, presets, simulation, and policy audit UX | +| Inbox management | Responsible agent may archive/unarchive its responsible user's Mine items under a default-open user policy; cross-user access requires `inbox:manage`; all mutations are audited | Policy administration UX, organization presets, simulations, bulk controls, and richer audit/reporting surfaces | | Escalation | Escalate from agent to manager to board; board approval/budget gates remain authoritative | Escalation routing and SLA windows | ## 9.7 Recommended first-slice implementation order @@ -786,6 +789,24 @@ Phase 2 server tests and Phase 4 UI tests must prove: - explicit policy denials return `skill_policy_denied`, while platform safety failures return the stable invariant denial codes above - successful skill mutations and policy mutations persist activity records with actor, company, run attribution, normalized action, and revision/change summary; audit-write failures do not leave successful unaudited mutations behind +## 9.11 Inbox Management Permission and Ownership Contract + +`inbox:manage` is the permission key for agent-driven per-user inbox archive state. Inbox archive state changes presentation in a user's Mine inbox; it does not change issue status, assignment, visibility, or the underlying work record. + +Core authorization follows these rules: + +- Board users may archive or unarchive inbox entries for users in the company. +- An agent may manage the responsible user's inbox without an explicit grant when the authenticated run resolves that user and the user's inbox-agent policy permits the agent. This is the default-open path. +- A user may set inbox-agent policy to `disabled` or `allowlist`. Policy restrictions override the default-open path, and low-trust agents are denied. +- An agent targeting any user other than its resolved responsible user requires an explicit `inbox:manage` grant. Grants may be unscoped or constrained by `scope.userIds`. +- Archive and unarchive operations are company-scoped, reversible, and activity logged with actor, agent, run, target user, target-resolution source, and policy mode. +- New qualifying issue activity may invalidate an archive so the item resurfaces; archival is not a substitute for resolving or closing work. + +Ownership split: + +- **Core / Free:** permission key and scoped-grant enforcement; responsible-user resolution; default-open, disabled, and allowlist policy modes; archive/unarchive APIs; per-user archive persistence; resurfacing behavior; activity audit records; and stable denial codes. +- **Paperclip EE / Enterprise:** centralized policy administration beyond the per-user controls, organization-wide presets, policy simulation, bulk inbox operations, advanced compliance reporting, and richer administrative audit UX. EE may extend policy management surfaces but must not weaken core company boundaries, user policy restrictions, scoped grants, or audit requirements. + ## 10. API Contract (REST) All endpoints are under `/api` and return JSON. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 93947c96fc..6fab18a6b2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1269,6 +1269,16 @@ export { updateResourceMembershipSchema, type UpdateResourceMembership, } from "./validators/resource-memberships.js"; +export { + inboxAgentPolicyModeSchema, + updateInboxAgentPolicySchema, + type UpdateInboxAgentPolicy, +} from "./validators/inbox-agent-policy.js"; +export { + INBOX_AGENT_POLICY_MODES, + type InboxAgentPolicyMode, + type InboxAgentPolicy, +} from "./types/inbox-agent-policy.js"; export { RESOURCE_MEMBERSHIP_STATES, type ResourceMembershipResourceType, diff --git a/packages/shared/src/types/inbox-agent-policy.ts b/packages/shared/src/types/inbox-agent-policy.ts new file mode 100644 index 0000000000..cb9d41d968 --- /dev/null +++ b/packages/shared/src/types/inbox-agent-policy.ts @@ -0,0 +1,13 @@ +export const INBOX_AGENT_POLICY_MODES = ["open", "allowlist", "disabled"] as const; + +export type InboxAgentPolicyMode = (typeof INBOX_AGENT_POLICY_MODES)[number]; + +export interface InboxAgentPolicy { + companyId: string; + userId: string; + mode: InboxAgentPolicyMode; + allowedAgentIds: string[]; + materialized: boolean; + createdAt: Date | null; + updatedAt: Date | null; +} diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 2d453c2d6f..7fe47abe89 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -774,6 +774,10 @@ export interface Issue { lastExternalCommentAt?: Date | null; lastActivityAt?: Date | null; isUnreadForMe?: boolean; + archivedAt?: Date | null; + archivedByActorType?: "user" | "agent" | null; + archivedByAgentId?: string | null; + archivedByRunId?: string | null; createdAt: Date; updatedAt: Date; } @@ -825,6 +829,10 @@ export type CompactIssue = Pick< lastExternalCommentAt?: Date | null; lastActivityAt?: Date | null; isUnreadForMe?: boolean; + archivedAt?: Date | null; + archivedByActorType?: "user" | "agent" | null; + archivedByAgentId?: string | null; + archivedByRunId?: string | null; activeRecoveryAction: IssueRecoveryAction | null; successfulRunHandoff: SuccessfulRunHandoffState | null; }; diff --git a/packages/shared/src/validators/inbox-agent-policy.ts b/packages/shared/src/validators/inbox-agent-policy.ts new file mode 100644 index 0000000000..214bffcad1 --- /dev/null +++ b/packages/shared/src/validators/inbox-agent-policy.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const inboxAgentPolicyModeSchema = z.enum(["open", "allowlist", "disabled"]); + +export const updateInboxAgentPolicySchema = z.object({ + mode: inboxAgentPolicyModeSchema, + allowedAgentIds: z.array(z.string().uuid()).max(100).default([]), +}).strict().superRefine((value, ctx) => { + if (value.mode !== "allowlist" && value.allowedAgentIds.length > 0) { + ctx.addIssue({ + code: "custom", + message: "allowedAgentIds must be empty when mode is not \"allowlist\"", + path: ["allowedAgentIds"], + }); + } +}); + +export type UpdateInboxAgentPolicy = z.infer; diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 89e1af61a7..f7f5375d46 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -1905,7 +1905,11 @@ describeEmbeddedPostgres("authorization service", () => { }, action: "inbox:manage", resource: { type: "company", companyId: company.id }, - })).resolves.toMatchObject({ allowed: true, reason: "allow_self" }); + })).resolves.toMatchObject({ + allowed: true, + reason: "allow_self", + inboxPolicyMode: "open", + }); }); it("denies responsible-user inbox management when disabled", async () => { @@ -1968,7 +1972,11 @@ describeEmbeddedPostgres("authorization service", () => { resource: { type: "company" as const, companyId: company.id }, }); - await expect(decideFor(allowedAgent.id)).resolves.toMatchObject({ allowed: true, reason: "allow_self" }); + await expect(decideFor(allowedAgent.id)).resolves.toMatchObject({ + allowed: true, + reason: "allow_self", + inboxPolicyMode: "allowlist", + }); await expect(decideFor(deniedAgent.id)).resolves.toMatchObject({ allowed: false, reason: "inbox_agent_not_allowed", diff --git a/server/src/__tests__/inbox-agent-policy-routes.test.ts b/server/src/__tests__/inbox-agent-policy-routes.test.ts new file mode 100644 index 0000000000..e48b8dbfd5 --- /dev/null +++ b/server/src/__tests__/inbox-agent-policy-routes.test.ts @@ -0,0 +1,230 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + authUsers, + companies, + companyMemberships, + createDb, + principalPermissionGrants, + userInboxAgentPolicies, +} from "@paperclipai/db"; +import { errorHandler } from "../middleware/index.js"; +import { inboxAgentPolicyRoutes } from "../routes/inbox-agent-policy.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("inbox agent policy routes", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-inbox-agent-policy-routes-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(userInboxAgentPolicies); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(agents); + await db.delete(companies); + await db.delete(authUsers); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function appFor(actor: Express.Request["actor"]) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use(inboxAgentPolicyRoutes(db)); + app.use(errorHandler); + return app; + } + + async function seed() { + const companyId = randomUUID(); + const userId = `user-${randomUUID()}`; + const otherUserId = `user-${randomUUID()}`; + const agentId = randomUUID(); + const now = new Date(); + await db.insert(companies).values({ + id: companyId, + name: `Policy ${companyId}`, + issuePrefix: `IP${companyId.replaceAll("-", "").slice(0, 6).toUpperCase()}`, + }); + await db.insert(authUsers).values([ + { + id: userId, + name: "User", + email: `${userId}@example.com`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }, + { + id: otherUserId, + name: "Other", + email: `${otherUserId}@example.com`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }, + ]); + await db.insert(companyMemberships).values([ + { + companyId, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "operator", + }, + { + companyId, + principalType: "user", + principalId: otherUserId, + status: "active", + membershipRole: "operator", + }, + ]); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Allowed agent", + role: "engineer", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + return { companyId, userId, otherUserId, agentId }; + } + + function boardActor(companyId: string, userId: string): Express.Request["actor"] { + return { + type: "board", + source: "session", + userId, + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "operator", status: "active" }], + isInstanceAdmin: false, + }; + } + + it("returns the open default and lets users update their own policy", async () => { + const seeded = await seed(); + const app = appFor(boardActor(seeded.companyId, seeded.userId)); + + await request(app) + .get(`/companies/${seeded.companyId}/users/me/inbox-agent-policy`) + .expect(200) + .expect(({ body }) => expect(body).toMatchObject({ + companyId: seeded.companyId, + userId: seeded.userId, + mode: "open", + allowedAgentIds: [], + materialized: false, + })); + + await request(app) + .put(`/companies/${seeded.companyId}/users/me/inbox-agent-policy`) + .send({ mode: "allowlist", allowedAgentIds: [seeded.agentId] }) + .expect(200) + .expect(({ body }) => expect(body).toMatchObject({ + mode: "allowlist", + allowedAgentIds: [seeded.agentId], + materialized: true, + })); + + const [audit] = await db.select().from(activityLog); + expect(audit).toMatchObject({ + action: "inbox.agent_policy_updated", + actorType: "user", + entityType: "user_inbox_agent_policy", + entityId: seeded.userId, + details: { + userId: seeded.userId, + previousMode: "open", + mode: "allowlist", + allowedAgentIds: [seeded.agentId], + }, + }); + }); + + it("gates the admin variant with users:manage_permissions", async () => { + const seeded = await seed(); + const actor = boardActor(seeded.companyId, seeded.userId); + const app = appFor(actor); + + await request(app) + .put(`/companies/${seeded.companyId}/users/${seeded.otherUserId}/inbox-agent-policy`) + .send({ mode: "disabled", allowedAgentIds: [] }) + .expect(403) + .expect(({ body }) => expect(body.code).toBe("inbox_agent_policy_admin_required")); + + await db.insert(principalPermissionGrants).values({ + companyId: seeded.companyId, + principalType: "user", + principalId: seeded.userId, + permissionKey: "users:manage_permissions", + }); + + await request(app) + .put(`/companies/${seeded.companyId}/users/${seeded.otherUserId}/inbox-agent-policy`) + .send({ mode: "disabled", allowedAgentIds: [] }) + .expect(200) + .expect(({ body }) => expect(body).toMatchObject({ + userId: seeded.otherUserId, + mode: "disabled", + allowedAgentIds: [], + })); + }); + + it("rejects admin policies for users without an active company membership", async () => { + const seeded = await seed(); + const actor = boardActor(seeded.companyId, seeded.userId); + await db.insert(principalPermissionGrants).values({ + companyId: seeded.companyId, + principalType: "user", + principalId: seeded.userId, + permissionKey: "users:manage_permissions", + }); + + await request(appFor(actor)) + .put(`/companies/${seeded.companyId}/users/user-missing/inbox-agent-policy`) + .send({ mode: "disabled", allowedAgentIds: [] }) + .expect(404); + }); + + it("rejects agent IDs outside allowlist mode", async () => { + const seeded = await seed(); + await request(appFor(boardActor(seeded.companyId, seeded.userId))) + .put(`/companies/${seeded.companyId}/users/me/inbox-agent-policy`) + .send({ mode: "disabled", allowedAgentIds: [seeded.agentId] }) + .expect(400); + }); + + it("rejects allowlist agents from another company", async () => { + const seeded = await seed(); + await request(appFor(boardActor(seeded.companyId, seeded.userId))) + .put(`/companies/${seeded.companyId}/users/me/inbox-agent-policy`) + .send({ mode: "allowlist", allowedAgentIds: [randomUUID()] }) + .expect(422) + .expect(({ body }) => expect(body.code).toBe("inbox_agent_policy_invalid_agents")); + }); +}); diff --git a/server/src/__tests__/inbox-archive-routes.test.ts b/server/src/__tests__/inbox-archive-routes.test.ts new file mode 100644 index 0000000000..4e6bc3063a --- /dev/null +++ b/server/src/__tests__/inbox-archive-routes.test.ts @@ -0,0 +1,383 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + authUsers, + companies, + companyMemberships, + createDb, + heartbeatRuns, + issueComments, + issueInboxArchives, + issues, + principalPermissionGrants, + userInboxAgentPolicies, +} from "@paperclipai/db"; +import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("inbox archive routes", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-inbox-archive-routes-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(issueInboxArchives); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(heartbeatRuns); + await db.delete(userInboxAgentPolicies); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(agents); + await db.delete(companies); + await db.delete(authUsers); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function appFor(actor: Express.Request["actor"]) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", issueRoutes(db, {} as never)); + app.use(errorHandler); + return app; + } + + async function seed(input: { lowTrust?: boolean } = {}) { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const issueId = randomUUID(); + const responsibleUserId = `user-${randomUUID()}`; + const targetUserId = `user-${randomUUID()}`; + const now = new Date(); + await db.insert(companies).values({ + id: companyId, + name: `Inbox ${companyId}`, + issuePrefix: `IA${companyId.replaceAll("-", "").slice(0, 6).toUpperCase()}`, + }); + await db.insert(authUsers).values([ + { + id: responsibleUserId, + name: "Responsible", + email: `${responsibleUserId}@example.com`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }, + { + id: targetUserId, + name: "Target", + email: `${targetUserId}@example.com`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }, + ]); + await db.insert(companyMemberships).values([ + { + companyId, + principalType: "user", + principalId: responsibleUserId, + status: "active", + membershipRole: "operator", + }, + { + companyId, + principalType: "user", + principalId: targetUserId, + status: "active", + membershipRole: "operator", + }, + ]); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "InboxAgent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: input.lowTrust + ? { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: { + trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, companyId }, + }, + } + : {}, + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + status: "running", + invocationSource: "assignment", + responsibleUserId, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Archive me", + status: "todo", + priority: "medium", + createdByUserId: responsibleUserId, + }); + return { companyId, agentId, runId, issueId, responsibleUserId, targetUserId }; + } + + function agentActor(seed: Awaited>): Express.Request["actor"] { + return { + type: "agent", + source: "agent_jwt", + agentId: seed.agentId, + companyId: seed.companyId, + runId: seed.runId, + onBehalfOfUserId: seed.responsibleUserId, + onBehalfOfMemberships: [{ + companyId: seed.companyId, + membershipRole: "operator", + status: "active", + }], + }; + } + + it("preserves board idempotency and returns the resolved user", async () => { + const seeded = await seed(); + const app = appFor({ + type: "board", + source: "session", + userId: seeded.responsibleUserId, + companyIds: [seeded.companyId], + memberships: [{ companyId: seeded.companyId, membershipRole: "operator", status: "active" }], + isInstanceAdmin: false, + }); + + const first = await request(app).post(`/api/issues/${seeded.issueId}/inbox-archive`).send({}).expect(200); + const second = await request(app).post(`/api/issues/${seeded.issueId}/inbox-archive`).send({}).expect(200); + expect(first.body).toMatchObject({ userId: seeded.responsibleUserId, archivedByActorType: "user" }); + expect(second.body.id).toBe(first.body.id); + expect(await db.select().from(issueInboxArchives)).toHaveLength(1); + + await request(app).delete(`/api/issues/${seeded.issueId}/inbox-archive`).send({}).expect(200); + await request(app) + .delete(`/api/issues/${seeded.issueId}/inbox-archive`) + .send({}) + .expect(200) + .expect(({ body }) => expect(body).toEqual({ ok: true, userId: seeded.responsibleUserId })); + }); + + it("archives for the responsible user with agent/run attribution and resurfaces after new activity", async () => { + const seeded = await seed(); + const app = appFor(agentActor(seeded)); + + await request(app) + .post(`/api/issues/${seeded.issueId}/inbox-archive`) + .send({}) + .expect(200) + .expect(({ body }) => expect(body).toMatchObject({ + userId: seeded.responsibleUserId, + archivedByActorType: "agent", + archivedByAgentId: seeded.agentId, + archivedByRunId: seeded.runId, + })); + + const [audit] = await db.select().from(activityLog); + expect(audit).toMatchObject({ + action: "issue.inbox_archived", + actorType: "agent", + agentId: seeded.agentId, + runId: seeded.runId, + details: { + userId: seeded.responsibleUserId, + targetResolvedFrom: "responsible_user", + policyMode: "open", + }, + }); + + const archivedList = await request(app) + .get(`/api/companies/${seeded.companyId}/issues`) + .query({ touchedByUserId: seeded.responsibleUserId }) + .expect(200); + expect(archivedList.body[0]).toMatchObject({ + id: seeded.issueId, + archivedByActorType: "agent", + archivedByAgentId: seeded.agentId, + archivedByRunId: seeded.runId, + }); + + const boardApp = appFor({ + type: "board", + source: "session", + userId: seeded.responsibleUserId, + companyIds: [seeded.companyId], + memberships: [{ companyId: seeded.companyId, membershipRole: "operator", status: "active" }], + isInstanceAdmin: false, + }); + await request(boardApp) + .get(`/api/issues/${seeded.issueId}`) + .expect(200) + .expect(({ body }) => expect(body).toMatchObject({ + id: seeded.issueId, + archivedAt: expect.any(String), + archivedByActorType: "agent", + archivedByAgentId: seeded.agentId, + archivedByRunId: seeded.runId, + })); + + await db.insert(issueComments).values({ + companyId: seeded.companyId, + issueId: seeded.issueId, + authorUserId: seeded.targetUserId, + body: "New work arrived", + createdAt: new Date(Date.now() + 1000), + updatedAt: new Date(Date.now() + 1000), + }); + const resurfaced = await request(app) + .get(`/api/companies/${seeded.companyId}/issues`) + .query({ + touchedByUserId: seeded.responsibleUserId, + inboxArchivedByUserId: seeded.responsibleUserId, + }) + .expect(200); + expect(resurfaced.body[0]).toMatchObject({ id: seeded.issueId }); + expect(resurfaced.body[0].archivedByAgentId).toBeUndefined(); + await request(boardApp) + .get(`/api/issues/${seeded.issueId}`) + .expect(200) + .expect(({ body }) => expect(body.archivedByAgentId).toBeUndefined()); + }); + + it("returns stable typed denials for unresolved, disabled, allowlist, and low-trust actors", async () => { + const unresolved = await seed(); + const unresolvedActor = agentActor(unresolved); + unresolvedActor.onBehalfOfUserId = null; + unresolvedActor.onBehalfOfMemberships = []; + await request(appFor(unresolvedActor)) + .post(`/api/issues/${unresolved.issueId}/inbox-archive`) + .send({}) + .expect(403) + .expect(({ body }) => expect(body.code).toBe("inbox_target_user_unresolved")); + + const disabled = await seed(); + await db.insert(userInboxAgentPolicies).values({ + companyId: disabled.companyId, + userId: disabled.responsibleUserId, + mode: "disabled", + }); + await request(appFor(agentActor(disabled))) + .post(`/api/issues/${disabled.issueId}/inbox-archive`) + .send({}) + .expect(403) + .expect(({ body }) => expect(body.code).toBe("inbox_management_disabled")); + + const allowlist = await seed(); + await db.insert(userInboxAgentPolicies).values({ + companyId: allowlist.companyId, + userId: allowlist.responsibleUserId, + mode: "allowlist", + allowedAgentIds: [], + }); + await request(appFor(agentActor(allowlist))) + .post(`/api/issues/${allowlist.issueId}/inbox-archive`) + .send({}) + .expect(403) + .expect(({ body }) => expect(body.code).toBe("inbox_agent_not_allowed")); + await db + .update(userInboxAgentPolicies) + .set({ allowedAgentIds: [allowlist.agentId] }) + .where(eq(userInboxAgentPolicies.companyId, allowlist.companyId)); + await request(appFor(agentActor(allowlist))) + .post(`/api/issues/${allowlist.issueId}/inbox-archive`) + .send({}) + .expect(200); + + const lowTrust = await seed({ lowTrust: true }); + await request(appFor(agentActor(lowTrust))) + .post(`/api/issues/${lowTrust.issueId}/inbox-archive`) + .send({}) + .expect(403) + .expect(({ body }) => expect(body.code).toBe("inbox_agent_not_allowed")); + }); + + it("requires a scoped grant for cross-user archive and unarchive", async () => { + const seeded = await seed(); + const app = appFor(agentActor(seeded)); + + await request(app) + .post(`/api/issues/${seeded.issueId}/inbox-archive`) + .send({ userId: seeded.targetUserId }) + .expect(403) + .expect(({ body }) => expect(body.code).toBe("inbox_cross_user_grant_required")); + + await db.insert(companyMemberships).values({ + companyId: seeded.companyId, + principalType: "agent", + principalId: seeded.agentId, + status: "active", + membershipRole: "member", + }); + await db.insert(principalPermissionGrants).values({ + companyId: seeded.companyId, + principalType: "agent", + principalId: seeded.agentId, + permissionKey: "inbox:manage", + scope: { userIds: [seeded.targetUserId] }, + }); + await db.insert(userInboxAgentPolicies).values({ + companyId: seeded.companyId, + userId: seeded.targetUserId, + mode: "disabled", + }); + + await request(app) + .post(`/api/issues/${seeded.issueId}/inbox-archive`) + .send({ userId: seeded.targetUserId }) + .expect(200) + .expect(({ body }) => expect(body).toMatchObject({ userId: seeded.targetUserId })); + await request(app) + .delete(`/api/issues/${seeded.issueId}/inbox-archive`) + .send({ userId: seeded.targetUserId }) + .expect(200) + .expect(({ body }) => expect(body).toMatchObject({ userId: seeded.targetUserId })); + + const auditRows = await db.select().from(activityLog); + expect(auditRows.find((row) => row.action === "issue.inbox_unarchived")).toMatchObject({ + actorType: "agent", + agentId: seeded.agentId, + runId: seeded.runId, + }); + expect(auditRows.map((row) => row.details)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + userId: seeded.targetUserId, + targetResolvedFrom: "explicit", + policyMode: "grant_override", + }), + ])); + }); +}); diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index 14a011886b..819fa4cbc8 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -14,6 +14,7 @@ const mockIssueService = vi.hoisted(() => ({ listBlockerAttention: vi.fn(), listProductivityReviews: vi.fn(), getCurrentScheduledRetry: vi.fn(), + getActiveInboxArchiveFields: vi.fn(), listAttachments: vi.fn(), })); @@ -210,6 +211,7 @@ describe.sequential("issue goal context routes", () => { mockIssueService.listBlockerAttention.mockResolvedValue(new Map()); mockIssueService.listProductivityReviews.mockResolvedValue(new Map()); mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null); + mockIssueService.getActiveInboxArchiveFields.mockResolvedValue({}); mockIssueService.listAttachments.mockResolvedValue([]); mockDocumentsService.getIssueDocumentPayload.mockResolvedValue({}); mockDocumentsService.getIssueDocumentByKey.mockResolvedValue(null); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 33b3e1b218..726907c78e 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -33,6 +33,7 @@ const apiPrefixes: Record = { "folders.ts": "/api", "goals.ts": "/api", "health.ts": "/api/health", + "inbox-agent-policy.ts": "/api", "inbox-dismissals.ts": "/api", "instance-database-backups.ts": "/api", "instance-settings.ts": "/api", diff --git a/server/src/app.ts b/server/src/app.ts index 1af6fb930b..19086ea530 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -15,6 +15,7 @@ import { healthRoutes } from "./routes/health.js"; import { companyRoutes } from "./routes/companies.js"; import { companySkillRoutes } from "./routes/company-skills.js"; import { companySkillPolicyRoutes } from "./routes/company-skill-policy.js"; +import { inboxAgentPolicyRoutes } from "./routes/inbox-agent-policy.js"; import { builtInAgentRoutes } from "./routes/built-in-agents.js"; import { folderRoutes } from "./routes/folders.js"; import { teamsCatalogRoutes } from "./routes/teams-catalog.js"; @@ -239,6 +240,7 @@ export async function createApp( api.use(folderRoutes(db)); api.use(companySkillRoutes(db)); api.use(companySkillPolicyRoutes(db)); + api.use(inboxAgentPolicyRoutes(db)); api.use(builtInAgentRoutes(db)); api.use(teamsCatalogRoutes(db)); api.use(agentRoutes(db, { pluginWorkerManager: workerManager })); diff --git a/server/src/routes/inbox-agent-policy.ts b/server/src/routes/inbox-agent-policy.ts new file mode 100644 index 0000000000..1a18d8c0b9 --- /dev/null +++ b/server/src/routes/inbox-agent-policy.ts @@ -0,0 +1,104 @@ +import { Router, type Request } from "express"; +import type { Db } from "@paperclipai/db"; +import { updateInboxAgentPolicySchema } from "@paperclipai/shared"; +import { forbidden, notFound, unauthorized } from "../errors.js"; +import { validate } from "../middleware/validate.js"; +import { accessService, inboxAgentPolicyService, logActivity } from "../services/index.js"; +import { assertCompanyAccess, getActorInfo } from "./authz.js"; + +export function inboxAgentPolicyRoutes(db: Db) { + const router = Router(); + const access = accessService(db); + const policies = inboxAgentPolicyService(db); + + function selfUserId(req: Request) { + if (req.actor.type !== "board" || !req.actor.userId) throw unauthorized("Board user context required"); + return req.actor.userId; + } + + async function assertAdmin(req: Request, companyId: string) { + assertCompanyAccess(req, companyId); + if (req.actor.type === "board") { + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + if (!req.actor.userId) throw unauthorized("Board user context required"); + if (await access.canUser(companyId, req.actor.userId, "users:manage_permissions")) return; + } else if ( + req.actor.type === "agent" + && req.actor.agentId + && await access.hasPermission(companyId, "agent", req.actor.agentId, "users:manage_permissions") + ) { + return; + } + throw forbidden("Inbox agent policy administration authority required", { + code: "inbox_agent_policy_admin_required", + }); + } + + async function assertActiveUserMembership(companyId: string, userId: string) { + const membership = await access.getMembership(companyId, "user", userId); + if (!membership || membership.status !== "active") { + throw notFound("Active company user membership not found"); + } + } + + async function writePolicy(req: Request, companyId: string, userId: string) { + const previous = await policies.get(companyId, userId); + const policy = await policies.update(companyId, userId, req.body); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "inbox.agent_policy_updated", + entityType: "user_inbox_agent_policy", + entityId: userId, + details: { + userId, + previousMode: previous.mode, + mode: policy.mode, + allowedAgentIds: policy.allowedAgentIds, + }, + }); + return policy; + } + + router.get("/companies/:companyId/users/me/inbox-agent-policy", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(await policies.get(companyId, selfUserId(req))); + }); + + router.put( + "/companies/:companyId/users/me/inbox-agent-policy", + validate(updateInboxAgentPolicySchema), + async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(await writePolicy(req, companyId, selfUserId(req))); + }, + ); + + router.get("/companies/:companyId/users/:userId/inbox-agent-policy", async (req, res) => { + const companyId = req.params.companyId as string; + const userId = req.params.userId as string; + await assertAdmin(req, companyId); + await assertActiveUserMembership(companyId, userId); + res.json(await policies.get(companyId, userId)); + }); + + router.put( + "/companies/:companyId/users/:userId/inbox-agent-policy", + validate(updateInboxAgentPolicySchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const userId = req.params.userId as string; + await assertAdmin(req, companyId); + await assertActiveUserMembership(companyId, userId); + res.json(await writePolicy(req, companyId, userId)); + }, + ); + + return router; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 08b28f8897..76c7441ef7 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -2,6 +2,7 @@ export { healthRoutes } from "./health.js"; export { companyRoutes } from "./companies.js"; export { companySkillRoutes } from "./company-skills.js"; export { companySkillPolicyRoutes } from "./company-skill-policy.js"; +export { inboxAgentPolicyRoutes } from "./inbox-agent-policy.js"; export { builtInAgentRoutes } from "./built-in-agents.js"; export { folderRoutes } from "./folders.js"; export { teamsCatalogRoutes } from "./teams-catalog.js"; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index b9560b4587..64046210cb 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -106,6 +106,7 @@ import { issueApprovalService, issueRecoveryActionService, issueThreadInteractionService, + inboxAgentPolicyService, ISSUE_LIST_DEFAULT_LIMIT, ISSUE_LIST_MAX_LIMIT, issueReferenceService, @@ -196,6 +197,9 @@ const updateIssueRouteSchema = updateIssueSchema.extend({ const refreshExternalObjectsSchema = z.object({ objectIds: z.array(z.string().uuid()).max(50).optional(), }).strict(); +const inboxArchiveBodySchema = z.object({ + userId: z.string().trim().min(1).optional(), +}).strict().default({}); const externalObjectSummariesSchema = z.object({ issueIds: z.array(z.string().uuid()).max(1000), }).strict(); @@ -5192,7 +5196,7 @@ export function issueRoutes( } : null, planReviewContext, - currentExecutionWorkspace, + currentExecutionWorkspace: compactIssueExecutionWorkspace(currentExecutionWorkspace), }); }); @@ -5327,6 +5331,9 @@ export function issueRoutes( const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found"); if (!issue) return; if (!(await assertIssueReadAllowed(req, res, issue))) return; + const inboxArchiveFieldsPromise = req.actor.type === "board" && req.actor.userId + ? svc.getActiveInboxArchiveFields(issue, req.actor.userId) + : Promise.resolve({}); const [ { project, goal }, ancestors, @@ -5340,6 +5347,7 @@ export function issueRoutes( scheduledRetry, activeRecoveryAction, linkedCases, + inboxArchiveFields, ] = await Promise.all([ resolveIssueProjectAndGoal(issue), svc.getAncestors(issue.id), @@ -5353,6 +5361,7 @@ export function issueRoutes( svc.getCurrentScheduledRetry(issue.id), recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id), listIssueLinkedCases(db, issue.companyId, issue.id), + inboxArchiveFieldsPromise, ]); const recoveryActionsByRelationIssue = await relationRecoveryActionMap( recoveryActionsSvc, @@ -5378,6 +5387,7 @@ export function issueRoutes( const workProducts = await workProductsSvc.listForIssue(issue.id); res.json({ ...issue, + ...inboxArchiveFields, goalId: goal?.id ?? issue.goalId, ancestors, ...(blockerAttention ? { blockerAttention } : {}), @@ -6726,20 +6736,64 @@ export function issueRoutes( res.json({ id: issue.id, removed }); }); - router.post("/issues/:id/inbox-archive", async (req, res) => { + async function resolveInboxArchiveTarget( + req: Request, + issue: { id: string; companyId: string }, + ) { + if (req.actor.type === "board") { + if (!req.actor.userId) throw forbidden("Board user context required", { code: "inbox_target_user_unresolved" }); + return { + userId: req.actor.userId, + targetResolvedFrom: "responsible_user" as const, + policyMode: null, + }; + } + if (req.actor.type !== "agent") throw unauthorized("Authentication required"); + + const explicitUserId = typeof req.body?.userId === "string" ? req.body.userId.trim() || null : null; + const responsibleUserId = req.actor.onBehalfOfUserId?.trim() || null; + const userId = explicitUserId ?? responsibleUserId; + if (!userId) { + throw forbidden("Inbox target user could not be resolved", { code: "inbox_target_user_unresolved" }); + } + + const decision = await access.decide({ + actor: req.actor, + action: "inbox:manage", + resource: { type: "issue", companyId: issue.companyId, issueId: issue.id }, + scope: { userId }, + }); + if (!decision.allowed) { + const code = decision.reason === "inbox_management_disabled" + ? "inbox_management_disabled" + : decision.reason === "inbox_agent_not_allowed" || decision.reason === "deny_low_trust_boundary" + ? "inbox_agent_not_allowed" + : decision.reason === "inbox_target_user_unresolved" + ? "inbox_target_user_unresolved" + : userId !== responsibleUserId + ? "inbox_cross_user_grant_required" + : "inbox_agent_not_allowed"; + throw forbidden(decision.explanation, { code, reason: decision.reason }); + } + + return { + userId, + targetResolvedFrom: explicitUserId ? "explicit" as const : "responsible_user" as const, + policyMode: decision.inboxPolicyMode ?? "open", + }; + } + + router.post("/issues/:id/inbox-archive", validate(inboxArchiveBodySchema), async (req, res) => { const id = req.params.id as string; const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found"); if (!issue) return; - if (req.actor.type !== "board") { - res.status(403).json({ error: "Board authentication required" }); - return; - } - if (!req.actor.userId) { - res.status(403).json({ error: "Board user context required" }); - return; - } - const archiveState = await svc.archiveInbox(issue.companyId, issue.id, req.actor.userId, new Date()); + const target = await resolveInboxArchiveTarget(req, issue); const actor = getActorInfo(req); + const archiveState = await svc.archiveInbox(issue.companyId, issue.id, target.userId, new Date(), { + archivedByActorType: req.actor.type === "agent" ? "agent" : "user", + archivedByAgentId: actor.agentId, + archivedByRunId: actor.runId, + }); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, @@ -6749,24 +6803,22 @@ export function issueRoutes( action: "issue.inbox_archived", entityType: "issue", entityId: issue.id, - details: { userId: req.actor.userId, archivedAt: archiveState.archivedAt }, + details: { + userId: target.userId, + archivedAt: archiveState.archivedAt, + targetResolvedFrom: target.targetResolvedFrom, + ...(target.policyMode ? { policyMode: target.policyMode } : {}), + }, }); res.json(archiveState); }); - router.delete("/issues/:id/inbox-archive", async (req, res) => { + router.delete("/issues/:id/inbox-archive", validate(inboxArchiveBodySchema), async (req, res) => { const id = req.params.id as string; const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found"); if (!issue) return; - if (req.actor.type !== "board") { - res.status(403).json({ error: "Board authentication required" }); - return; - } - if (!req.actor.userId) { - res.status(403).json({ error: "Board user context required" }); - return; - } - const removed = await svc.unarchiveInbox(issue.companyId, issue.id, req.actor.userId); + const target = await resolveInboxArchiveTarget(req, issue); + const removed = await svc.unarchiveInbox(issue.companyId, issue.id, target.userId); const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, @@ -6777,9 +6829,13 @@ export function issueRoutes( action: "issue.inbox_unarchived", entityType: "issue", entityId: issue.id, - details: { userId: req.actor.userId }, + details: { + userId: target.userId, + targetResolvedFrom: target.targetResolvedFrom, + ...(target.policyMode ? { policyMode: target.policyMode } : {}), + }, }); - res.json(removed ?? { ok: true }); + res.json(removed ?? { ok: true, userId: target.userId }); }); router.get("/issues/:id/approvals", async (req, res) => { diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index dc50b8095e..5a4c5c7d36 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -111,6 +111,7 @@ import { companySkillTestRunTemplateUpdateSchema, evaluateSkillPolicySchema, replaceSkillPolicySchema, + updateInboxAgentPolicySchema, // Issue tree createIssueTreeHoldSchema, previewIssueTreeControlSchema, @@ -2066,8 +2067,11 @@ registry.registerPath({ path: "/api/issues/{id}/inbox-archive", tags: ["issues"], summary: "Archive issue from inbox", - request: { params: z.object({ id: z.string() }) }, - responses: { 200: r.ok(), 401: r.unauthorized }, + request: { + params: z.object({ id: z.string() }), + body: jsonBody(z.object({ userId: z.string().min(1).optional() })), + }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, }); registry.registerPath({ @@ -2075,8 +2079,11 @@ registry.registerPath({ path: "/api/issues/{id}/inbox-archive", tags: ["issues"], summary: "Un-archive issue from inbox", - request: { params: z.object({ id: z.string() }) }, - responses: { 200: r.ok(), 401: r.unauthorized }, + request: { + params: z.object({ id: z.string() }), + body: jsonBody(z.object({ userId: z.string().min(1).optional() })), + }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, }); registry.registerPath({ @@ -4046,6 +4053,48 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, }); +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/users/me/inbox-agent-policy", + tags: ["companies"], + summary: "Get the current user's inbox agent policy", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, +}); + +registry.registerPath({ + method: "put", + path: "/api/companies/{companyId}/users/me/inbox-agent-policy", + tags: ["companies"], + summary: "Update the current user's inbox agent policy", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(updateInboxAgentPolicySchema), + }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/users/{userId}/inbox-agent-policy", + tags: ["companies"], + summary: "Get a company user's inbox agent policy", + request: { params: z.object({ companyId: z.string(), userId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, +}); + +registry.registerPath({ + method: "put", + path: "/api/companies/{companyId}/users/{userId}/inbox-agent-policy", + tags: ["companies"], + summary: "Update a company user's inbox agent policy", + request: { + params: z.object({ companyId: z.string(), userId: z.string() }), + body: jsonBody(updateInboxAgentPolicySchema), + }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable }, +}); + // ─── Execution workspaces ───────────────────────────────────────────────────── registry.registerPath({ diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index bc42b96630..a04dc81348 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -14,6 +14,7 @@ import { } from "@paperclipai/db"; import type { AgentApiKeyScope, + InboxAgentPolicyMode, PermissionKey, PrincipalType, SkillTestAgentKeyScope, @@ -90,6 +91,7 @@ export type AuthorizationDecision = { allowed: boolean; action: AuthorizationAction; explanation: string; + inboxPolicyMode?: InboxAgentPolicyMode | "grant_override"; code?: "RESPONSIBLE_USER_UNAUTHORIZED" | "RESPONSIBLE_USER_UNAVAILABLE"; reason: | "allow_low_trust_boundary" @@ -1769,6 +1771,7 @@ export function authorizationService(db: Db) { action: input.action, reason: "allow_explicit_grant", explanation: "Allowed by explicit grant inbox:manage.", + inboxPolicyMode: "grant_override", grant: { principalType: "agent", principalId: actorAgentId, @@ -1810,6 +1813,7 @@ export function authorizationService(db: Db) { return allow({ action: input.action, reason: "allow_self", + inboxPolicyMode: policy?.mode ?? "open", explanation: policy?.mode === "allowlist" ? "Allowed by the responsible user's inbox agent allowlist." : "Allowed by the responsible user's default-open inbox policy.", diff --git a/server/src/services/inbox-agent-policy.ts b/server/src/services/inbox-agent-policy.ts new file mode 100644 index 0000000000..1e025c49e3 --- /dev/null +++ b/server/src/services/inbox-agent-policy.ts @@ -0,0 +1,58 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { agents, userInboxAgentPolicies, type Db } from "@paperclipai/db"; +import type { InboxAgentPolicy, UpdateInboxAgentPolicy } from "@paperclipai/shared"; +import { unprocessable } from "../errors.js"; + +export function inboxAgentPolicyService(db: Db) { + async function get(companyId: string, userId: string): Promise { + const row = await db + .select() + .from(userInboxAgentPolicies) + .where(and( + eq(userInboxAgentPolicies.companyId, companyId), + eq(userInboxAgentPolicies.userId, userId), + )) + .then((rows) => rows[0] ?? null); + return row + ? { ...row, materialized: true } + : { + companyId, + userId, + mode: "open", + allowedAgentIds: [], + materialized: false, + createdAt: null, + updatedAt: null, + }; + } + + async function update(companyId: string, userId: string, input: UpdateInboxAgentPolicy): Promise { + const allowedAgentIds = input.mode === "allowlist" ? [...new Set(input.allowedAgentIds)] : []; + if (allowedAgentIds.length > 0) { + const companyAgentIds = await db + .select({ id: agents.id }) + .from(agents) + .where(and(eq(agents.companyId, companyId), inArray(agents.id, allowedAgentIds))) + .then((rows) => new Set(rows.map((row) => row.id))); + const invalidAgentIds = allowedAgentIds.filter((agentId) => !companyAgentIds.has(agentId)); + if (invalidAgentIds.length > 0) { + throw unprocessable("Inbox agent policy contains agents outside the company", { + code: "inbox_agent_policy_invalid_agents", + invalidAgentIds, + }); + } + } + const now = new Date(); + const [row] = await db + .insert(userInboxAgentPolicies) + .values({ companyId, userId, mode: input.mode, allowedAgentIds, updatedAt: now }) + .onConflictDoUpdate({ + target: [userInboxAgentPolicies.companyId, userInboxAgentPolicies.userId], + set: { mode: input.mode, allowedAgentIds, updatedAt: now }, + }) + .returning(); + return { ...row!, materialized: true }; + } + + return { get, update }; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 4a9977cfdd..0c43beb2f3 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -99,6 +99,7 @@ export { type PrincipalAccessCompatibilityBackfillStats, } from "./principal-access-compatibility.js"; export { authorizationService } from "./authorization.js"; +export { inboxAgentPolicyService } from "./inbox-agent-policy.js"; export type { AuthorizationAction, AuthorizationActor, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index ce72e881fd..6184683d01 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1479,6 +1479,50 @@ function latestIssueActivityAt(...values: Array { + if (issueIds.length === 0) return []; + return dbOrTx + .select({ + issueId: issueInboxArchives.issueId, + archivedAt: issueInboxArchives.archivedAt, + archivedByActorType: issueInboxArchives.archivedByActorType, + archivedByAgentId: issueInboxArchives.archivedByAgentId, + archivedByRunId: issueInboxArchives.archivedByRunId, + }) + .from(issueInboxArchives) + .where(and( + eq(issueInboxArchives.companyId, companyId), + eq(issueInboxArchives.userId, userId), + inArray(issueInboxArchives.issueId, issueIds), + )); +} + +function activeInboxArchiveFields( + archive: InboxArchiveAttributionRow | undefined, + lastActivityAt: Date, +) { + if (!archive || archive.archivedAt.getTime() < lastActivityAt.getTime()) return {}; + return { + archivedAt: archive.archivedAt, + archivedByActorType: archive.archivedByActorType, + archivedByAgentId: archive.archivedByAgentId, + archivedByRunId: archive.archivedByRunId, + }; +} + function issueListOrderBy( companyId: string, { @@ -4816,7 +4860,7 @@ export function issueService(db: Db) { } const issueIds = withRuns.map((row) => row.id); - const [statsRows, readRows, lastActivityRows, blockedByMap, liveDescendantCountByIssueId] = await Promise.all([ + const [statsRows, readRows, lastActivityRows, archiveRows, blockedByMap, liveDescendantCountByIssueId] = await Promise.all([ contextUserId ? userCommentStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), @@ -4824,6 +4868,9 @@ export function issueService(db: Db) { ? userReadStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), lastActivityStatsForIssues(db, companyId, issueIds), + contextUserId + ? inboxArchiveRowsForIssues(db, companyId, contextUserId, issueIds) + : Promise.resolve([]), includeBlockedBy ? blockedByMapForIssues(db, companyId, issueIds) : Promise.resolve(new Map()), @@ -4833,6 +4880,7 @@ export function issueService(db: Db) { ]); const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); + const archiveByIssueId = new Map(archiveRows.map((row) => [row.issueId, row])); const [ blockerAttentionByIssueId, productivityReviewByIssueId, @@ -4878,6 +4926,7 @@ export function issueService(db: Db) { ) ?? row.updatedAt; return { ...row, + ...activeInboxArchiveFields(archiveByIssueId.get(row.id), lastActivityAt), ...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}), lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), @@ -4995,7 +5044,17 @@ export function issueService(db: Db) { return deleted.length > 0; }, - archiveInbox: async (companyId: string, issueId: string, userId: string, archivedAt: Date = new Date()) => { + archiveInbox: async ( + companyId: string, + issueId: string, + userId: string, + archivedAt: Date = new Date(), + attribution?: { + archivedByActorType: "user" | "agent"; + archivedByAgentId?: string | null; + archivedByRunId?: string | null; + }, + ) => { const now = new Date(); const [row] = await db .insert(issueInboxArchives) @@ -5003,6 +5062,9 @@ export function issueService(db: Db) { companyId, issueId, userId, + archivedByActorType: attribution?.archivedByActorType ?? "user", + archivedByAgentId: attribution?.archivedByAgentId ?? null, + archivedByRunId: attribution?.archivedByRunId ?? null, archivedAt, updatedAt: now, }) @@ -5010,6 +5072,9 @@ export function issueService(db: Db) { target: [issueInboxArchives.companyId, issueInboxArchives.issueId, issueInboxArchives.userId], set: { archivedAt, + archivedByActorType: attribution?.archivedByActorType ?? "user", + archivedByAgentId: attribution?.archivedByAgentId ?? null, + archivedByRunId: attribution?.archivedByRunId ?? null, updatedAt: now, }, }) @@ -5031,6 +5096,22 @@ export function issueService(db: Db) { return row ?? null; }, + getActiveInboxArchiveFields: async ( + issue: Pick, + userId: string, + ) => { + const [[activity], [archive]] = await Promise.all([ + lastActivityStatsForIssues(db, issue.companyId, [issue.id]), + inboxArchiveRowsForIssues(db, issue.companyId, userId, [issue.id]), + ]); + const lastActivityAt = latestIssueActivityAt( + issue.updatedAt, + activity?.latestCommentAt ?? null, + activity?.latestLogAt ?? null, + ) ?? issue.updatedAt; + return activeInboxArchiveFields(archive, lastActivityAt); + }, + getById: async (raw: string) => { const id = raw.trim(); const identifier = normalizeIssueReferenceIdentifier(id); diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 9017504015..11b2107f90 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -150,6 +150,14 @@ Status values: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`, **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. +## Managing A User's Inbox + +Agents may archive an issue from a user's Mine inbox with `POST /api/issues/{issueId}/inbox-archive` and reverse it with `DELETE /api/issues/{issueId}/inbox-archive`. Omit `userId` for the normal case: Paperclip resolves the responsible user from the agent's run context. An explicit `userId` targets another user and requires a matching `inbox:manage` grant. + +Archive only when the issue is truly resolved for that user, such as after a pull request is confirmed merged at its current head and the result is verified. Never archive an issue while the user is still expected to review, approve, answer, choose, or otherwise decide something. Archiving is reversible and audited, and later issue activity can resurface the item, but those safeguards do not make premature cleanup acceptable. + +Every archive/unarchive mutation must include `X-Paperclip-Run-Id`. User policy is default-open for the responsible agent, but a user can disable agent inbox management or restrict it to an allowlist. Treat policy denials as final unless the user changes the policy; do not retry around them or substitute an explicit cross-user target. + ## Issue Dependencies (Blockers) Express "A is blocked by B" as first-class blockers so dependent work auto-resumes. diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 1b26b59aef..11b1119528 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -476,6 +476,30 @@ PATCH /api/issues/issue-200 { "comment": "Your Mine inbox has 1 unread issue: [PAP-310](/PAP/issues/PAP-310)." } ``` +### Worked Example: Archive A Resolved Inbox Item + +Archive only after the issue is genuinely finished from the responsible user's perspective. Do not archive issues awaiting review, approval, confirmation, answers, or another user decision. + +```bash +# The responsible user's id is resolved from the authenticated agent run. +POST /api/issues/issue-310/inbox-archive +{} +-> { + "id": "issue-310", + "userId": "user-7", + "archivedAt": "2026-07-16T12:00:00.000Z" + } + +# Reverse the archive if it was premature or no longer desired. +DELETE /api/issues/issue-310/inbox-archive +{} +-> { "ok": true, "userId": "user-7" } +``` + +Both mutations require `X-Paperclip-Run-Id` and write activity-log entries. Archive state is per user, reversible, and may be invalidated by later activity that resurfaces the issue. Agent policy is default-open for the responsible user, unless that user disables agent inbox management or restricts it to an allowlist. + +Pass `{ "userId": "user-9" }` only for an intentional cross-user operation. The agent must have `inbox:manage`, optionally scoped to that user. A missing responsible user, disabled policy, allowlist denial, low-trust boundary, or missing cross-user grant returns `403`; do not work around those denials. + ### Worked Example: Reviewer / Approver Heartbeat When you wake up on an issue in `in_review`, inspect `executionState` first: @@ -1178,6 +1202,8 @@ Terminal states: `done`, `cancelled` | GET | `/api/issues/:issueId/comments` | List comments | | GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID | | POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) | +| POST | `/api/issues/:issueId/inbox-archive` | Archive issue from responsible user's inbox; optional `userId` requires cross-user grant | +| DELETE | `/api/issues/:issueId/inbox-archive` | Reverse inbox archive; same target and policy rules | | GET | `/api/issues/:issueId/interactions` | List issue-thread interactions | | POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`) | | POST | `/api/issues/:issueId/interactions/:interactionId/accept` | Accept suggested tasks or confirmation (body: `selectedClientKeys` for `suggest_tasks`; `selectedOptionIds` for `request_checkbox_confirmation`) | diff --git a/ui/src/api/inbox-agent-policy.ts b/ui/src/api/inbox-agent-policy.ts new file mode 100644 index 0000000000..e44e065ef1 --- /dev/null +++ b/ui/src/api/inbox-agent-policy.ts @@ -0,0 +1,14 @@ +import type { InboxAgentPolicy, UpdateInboxAgentPolicy } from "@paperclipai/shared"; +import { api } from "./client"; + +/** + * "Let agents tidy my inbox" policy. Backed by per-user endpoints: `open` + * lets any of my agents archive from my inbox, + * `allowlist` restricts to the named agents, `disabled` turns it off. + */ +export const inboxAgentPolicyApi = { + getMine: (companyId: string) => + api.get(`/companies/${companyId}/users/me/inbox-agent-policy`), + updateMine: (companyId: string, input: UpdateInboxAgentPolicy) => + api.put(`/companies/${companyId}/users/me/inbox-agent-policy`, input), +}; diff --git a/ui/src/components/InboxAgentPolicyControl.test.tsx b/ui/src/components/InboxAgentPolicyControl.test.tsx new file mode 100644 index 0000000000..1450411e0d --- /dev/null +++ b/ui/src/components/InboxAgentPolicyControl.test.tsx @@ -0,0 +1,200 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { InboxAgentPolicy } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { InboxAgentPolicyControl } from "./InboxAgentPolicyControl"; + +const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn() })); +const mockInboxAgentPolicyApi = vi.hoisted(() => ({ getMine: vi.fn(), updateMine: vi.fn() })); + +vi.mock("@/api/agents", () => ({ agentsApi: mockAgentsApi })); +vi.mock("@/api/inbox-agent-policy", () => ({ inboxAgentPolicyApi: mockInboxAgentPolicyApi })); +vi.mock("./AgentIconPicker", () => ({ AgentIcon: () => null })); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function waitForAssertion(assertion: () => void, attempts = 20) { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await flush(); + } + } + throw lastError; +} + +function policy(overrides: Partial = {}): InboxAgentPolicy { + return { + companyId: "company-1", + userId: "user-1", + mode: "open", + allowedAgentIds: [], + materialized: false, + createdAt: null, + updatedAt: null, + ...overrides, + }; +} + +function render(container: HTMLDivElement) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const root = createRoot(container); + act(() => { + root.render( + + + , + ); + }); + return root; +} + +function optionByTitle(container: HTMLElement, title: string) { + return Array.from(container.querySelectorAll('[role="radio"]')) + .find((node) => node.textContent?.includes(title)) as HTMLButtonElement | undefined; +} + +describe("InboxAgentPolicyControl", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockAgentsApi.list.mockResolvedValue([ + { id: "agent-1", name: "Gardener", role: "gardener", status: "active", icon: null }, + { id: "agent-2", name: "Coder", role: "engineer", status: "active", icon: null }, + { id: "agent-3", name: "Retired", role: "engineer", status: "terminated", icon: null }, + ]); + mockInboxAgentPolicyApi.getMine.mockResolvedValue(policy()); + mockInboxAgentPolicyApi.updateMine.mockImplementation((_companyId: string, input) => + Promise.resolve(policy({ ...input, materialized: true })), + ); + }); + + afterEach(() => { + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("surfaces policy load failures instead of staying on loading", async () => { + mockInboxAgentPolicyApi.getMine.mockRejectedValue(new Error("Policy endpoint failed")); + const root = render(container); + await flush(); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Policy endpoint failed"); + expect(container.textContent).not.toContain("Loading inbox agent policy"); + }); + + act(() => root.unmount()); + }); + + it("renders all three policy states with the persisted mode selected", async () => { + mockInboxAgentPolicyApi.getMine.mockResolvedValue(policy({ mode: "disabled" })); + const root = render(container); + await flush(); + + await waitForAssertion(() => { + expect(optionByTitle(container, "Any of my agents")).toBeTruthy(); + expect(optionByTitle(container, "Only chosen agents")).toBeTruthy(); + expect(optionByTitle(container, "Off")).toBeTruthy(); + expect(optionByTitle(container, "Off")?.getAttribute("aria-checked")).toBe("true"); + expect(optionByTitle(container, "Any of my agents")?.getAttribute("aria-checked")).toBe("false"); + }); + + act(() => root.unmount()); + }); + + it("round-trips an allowlist selection through the PUT endpoint", async () => { + const root = render(container); + await flush(); + + // Save disabled until the draft diverges from the persisted policy. + await waitForAssertion(() => { + const save = Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes("Save")); + expect(save?.disabled).toBe(true); + }); + + // Switch to allowlist — only non-terminated agents are selectable. + await act(async () => optionByTitle(container, "Only chosen agents")!.click()); + await flush(); + await waitForAssertion(() => { + expect(container.textContent).toContain("Gardener"); + expect(container.textContent).toContain("Coder"); + expect(container.textContent).not.toContain("Retired"); + }); + + const gardenerCheckbox = container.querySelector( + 'button[aria-label="Allow Gardener to tidy my inbox"]', + ); + expect(gardenerCheckbox).toBeTruthy(); + await act(async () => gardenerCheckbox!.click()); + await flush(); + + const saveButton = Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes("Save"))!; + await waitForAssertion(() => expect(saveButton.disabled).toBe(false)); + + await act(async () => saveButton.click()); + await flush(); + + expect(mockInboxAgentPolicyApi.updateMine).toHaveBeenCalledWith("company-1", { + mode: "allowlist", + allowedAgentIds: ["agent-1"], + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Saved"); + const save = Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes("Save")); + expect(save?.disabled).toBe(true); + }); + + act(() => root.unmount()); + }); + + it("clears the allowlist when switching to Off before saving", async () => { + mockInboxAgentPolicyApi.getMine.mockResolvedValue(policy({ mode: "allowlist", allowedAgentIds: ["agent-1"], materialized: true })); + const root = render(container); + await flush(); + + await waitForAssertion(() => { + expect(optionByTitle(container, "Only chosen agents")?.getAttribute("aria-checked")).toBe("true"); + }); + + await act(async () => optionByTitle(container, "Off")!.click()); + await flush(); + + const saveButton = Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes("Save"))!; + await waitForAssertion(() => expect(saveButton.disabled).toBe(false)); + await act(async () => saveButton.click()); + await flush(); + + expect(mockInboxAgentPolicyApi.updateMine).toHaveBeenCalledWith("company-1", { + mode: "disabled", + allowedAgentIds: [], + }); + + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/InboxAgentPolicyControl.tsx b/ui/src/components/InboxAgentPolicyControl.tsx new file mode 100644 index 0000000000..5570683432 --- /dev/null +++ b/ui/src/components/InboxAgentPolicyControl.tsx @@ -0,0 +1,193 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Inbox, LoaderCircle, Save } from "lucide-react"; +import type { InboxAgentPolicy, InboxAgentPolicyMode } from "@paperclipai/shared"; +import { agentsApi } from "@/api/agents"; +import { inboxAgentPolicyApi } from "@/api/inbox-agent-policy"; +import { queryKeys } from "@/lib/queryKeys"; +import { isAgentTaskTarget } from "@/lib/company-members"; +import { AgentIcon } from "./AgentIconPicker"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { RadioCardGroup, type RadioCardOption } from "@/components/ui/radio-card"; + +const MODE_OPTIONS: RadioCardOption[] = [ + { + value: "open", + title: "Any of my agents", + description: "Let any agent you manage archive tasks out of your inbox.", + }, + { + value: "allowlist", + title: "Only chosen agents", + description: "Restrict inbox tidying to the agents you pick below.", + }, + { + value: "disabled", + title: "Off", + description: "Agents can never archive tasks from your inbox.", + }, +]; + +function policyKey(mode: InboxAgentPolicyMode, allowedAgentIds: string[]): string { + return `${mode}:${[...allowedAgentIds].sort().join(",")}`; +} + +interface Draft { + mode: InboxAgentPolicyMode; + allowedAgentIds: string[]; +} + +/** + * "Let agents tidy my inbox" user-settings control. A single + * three-state policy — `open` / `allowlist` / `disabled` — round-tripped through + * the per-user endpoints. When `allowlist` is selected the user picks which + * of their agents may archive. The one-click Undo/Unarchive affordance and the + * "Archived by …" attribution live elsewhere (inbox rows / properties pane). + */ +export function InboxAgentPolicyControl({ companyId }: { companyId: string | null | undefined }) { + const queryClient = useQueryClient(); + const [draft, setDraft] = useState(null); + const lastServerKeyRef = useRef(null); + + const policyQuery = useQuery({ + queryKey: companyId ? queryKeys.inboxAgentPolicy.mine(companyId) : ["inbox-agent-policy", "none"], + queryFn: () => inboxAgentPolicyApi.getMine(companyId!), + enabled: !!companyId, + }); + const policy = policyQuery.data; + + const agentsQuery = useQuery({ + queryKey: companyId ? queryKeys.agents.list(companyId) : ["agents", "none"], + queryFn: () => agentsApi.list(companyId!), + enabled: !!companyId, + }); + const selectableAgents = useMemo( + () => (agentsQuery.data ?? []).filter(isAgentTaskTarget), + [agentsQuery.data], + ); + + // Adopt server state on first load, or on refetch when the user has not + // diverged from the previously-synced snapshot (so a background refetch never + // clobbers pending edits). + useEffect(() => { + if (!policy) return; + const serverKey = policyKey(policy.mode, policy.allowedAgentIds); + setDraft((current) => { + if (current === null || policyKey(current.mode, current.allowedAgentIds) === lastServerKeyRef.current) { + return { mode: policy.mode, allowedAgentIds: policy.allowedAgentIds }; + } + return current; + }); + lastServerKeyRef.current = serverKey; + }, [policy]); + + const updateMutation = useMutation({ + mutationFn: (next: Draft) => + inboxAgentPolicyApi.updateMine(companyId!, { + mode: next.mode, + allowedAgentIds: next.mode === "allowlist" ? next.allowedAgentIds : [], + }), + onSuccess: (saved) => { + queryClient.setQueryData(queryKeys.inboxAgentPolicy.mine(companyId!), saved); + }, + }); + + const isDirty = Boolean( + draft && policy && policyKey(draft.mode, draft.allowedAgentIds) !== policyKey(policy.mode, policy.allowedAgentIds), + ); + + if (policyQuery.error) { + return ( +
+ {policyQuery.error instanceof Error ? policyQuery.error.message : "Failed to load inbox agent policy."} +
+ ); + } + + if (policyQuery.isLoading || !draft) { + return
Loading inbox agent policy…
; + } + + const toggleAgent = (agentId: string, checked: boolean) => { + setDraft((current) => { + if (!current) return current; + const set = new Set(current.allowedAgentIds); + if (checked) set.add(agentId); + else set.delete(agentId); + return { ...current, allowedAgentIds: [...set] }; + }); + }; + + return ( +
+
+
+ +

Let agents tidy my inbox

+
+

+ Choose whether the agents you manage may archive tasks out of your inbox on your behalf. You can + undo any archive, and every agent archive is attributed in the task's properties. +

+
+ + setDraft((current) => (current ? { ...current, mode: value as InboxAgentPolicyMode } : current))} + options={MODE_OPTIONS} + className="max-w-2xl" + /> + + {draft.mode === "allowlist" ? ( +
+
Agents allowed to tidy my inbox
+ {selectableAgents.length === 0 ? ( +

You don't manage any agents yet.

+ ) : ( +
    + {selectableAgents.map((agent) => { + const checked = draft.allowedAgentIds.includes(agent.id); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ) : null} + + {updateMutation.error ? ( +
+ {updateMutation.error instanceof Error ? updateMutation.error.message : "Failed to save inbox agent policy."} +
+ ) : null} + +
+ {updateMutation.isSuccess && !isDirty ? ( + Saved + ) : null} + +
+
+ ); +} diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 6a346577c2..f441a6ecec 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -37,6 +37,7 @@ const mockIssuesApi = vi.hoisted(() => ({ createLabel: vi.fn(), upsertWatchdog: vi.fn(), deleteWatchdog: vi.fn(), + unarchiveFromInbox: vi.fn(), })); const mockAuthApi = vi.hoisted(() => ({ @@ -441,6 +442,7 @@ describe("IssueProperties", () => { })); mockIssuesApi.upsertWatchdog.mockResolvedValue({}); mockIssuesApi.deleteWatchdog.mockResolvedValue({ ok: true }); + mockIssuesApi.unarchiveFromInbox.mockResolvedValue({ ok: true }); mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } }); mockAccessApi.listUserDirectory.mockResolvedValue({ users: [ @@ -2390,4 +2392,109 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + + it("shows agent-archive attribution and unarchive only in the properties pane", async () => { + mockAgentsApi.list.mockResolvedValue([ + { id: "agent-9", name: "Gardener", status: "active", adapterType: "codex_local", icon: null }, + ]); + const root = renderProperties(container, { + issue: createIssue({ + archivedAt: new Date("2026-04-06T12:10:00.000Z"), + archivedByActorType: "agent", + archivedByAgentId: "agent-9", + archivedByRunId: "run-1", + }), + childIssues: [], + onUpdate: vi.fn(), + inline: true, + }); + await flush(); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Archived"); + // The value shows just the agent name (the row label already says + // "Archived"), giving the name the full column width at 320px. + expect(container.textContent).toContain("Gardener"); + const unarchive = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Unarchive")); + expect(unarchive).toBeTruthy(); + }); + + // The tooltip must carry the full "Archived by ·