feat(inbox): let agents safely tidy user inboxes (#9724)

## Thinking Path

> - Paperclip is the open source control plane people use to manage
AI-agent companies and their work
> - The inbox is a per-user attention view, so archiving an item must
not alter the underlying issue, assignment, or status
> - Agents can help responsible users tidy resolved work only when the
action is company-scoped, reversible, policy-controlled, and fully
attributable
> - The database and authorization foundations landed in #9654 and
#9658, but the end-to-end archive routes, audit details, agent workflow
guidance, and operator UI still need to ship together
> - Separate stacked PRs #9659 and #9661 made the complete behavior
harder to review and land as one coherent capability
> - This pull request consolidates the remaining server,
shared-contract, documentation, skill, and UI work on top of current
master
> - The benefit is a single reviewable change that lets agents safely
archive responsible-user inbox items and lets users control or undo that
behavior

## Linked Issues or Issue Description

### Subsystem affected

Cross-cutting inbox management across shared contracts, server
authorization/routes/services, shipped agent skills, and the board UI.

### Problem or motivation

Agents may complete work whose issue remains in the responsible user's
Mine inbox. Existing board-user archive behavior does not provide the
agent-facing policy endpoints, target resolution, heartbeat-run
attribution, typed denials, conservative workflow guidance, or UI needed
for safe agent-managed cleanup.

### Proposed solution

Allow authorized agents to archive or unarchive responsible-user inbox
items under the user's open, allowlist, or disabled policy; preserve
actor/agent/run attribution in issue detail and activity records; expose
policy controls and agent archive attribution in the UI; and document
conservative cleanup rules for agents and PR gardening.

### Alternatives considered

- Reuse generic issue mutation permissions: rejected because inbox state
belongs to a target user and requires user-scoped authorization.
- Automatically archive every completed or closed item: rejected because
completion signals can still require human review or a decision.
- Keep the backend and UI as separate stacked PRs: superseded by this
consolidated PR so the complete user-visible behavior can be reviewed
and verified together.

### Related work

- Builds on merged foundations #9654 and #9658.
- Supersedes the remaining stacked changes in #9659 and #9661.
- `ROADMAP.md` has no overlapping inbox archive or inbox authorization
initiative.

## What Changed

- Added shared inbox-agent policy types and validators plus
company-scoped self-service policy routes and OpenAPI coverage.
- Enabled agent archive/unarchive mutations with responsible-user
targeting, policy enforcement, typed failures, attribution, idempotency,
and detailed activity auditing.
- Returned agent archive attribution in issue detail and documented
reversible inbox cleanup semantics in the implementation spec and
Paperclip skill.
- Added conservative PR-gardening inbox tidy guidance that keeps GitHub
access read-only and avoids archiving work that still needs human
action.
- Added the Profile settings policy control and Issue Properties
attribution/unarchive UI with focused component coverage and narrow-pane
handling.

## Verification

- `pnpm exec vitest run
server/src/__tests__/inbox-archive-routes.test.ts
server/src/__tests__/inbox-agent-policy-routes.test.ts
server/src/__tests__/authorization-service.test.ts
server/src/__tests__/openapi-routes.test.ts
ui/src/components/InboxAgentPolicyControl.test.tsx
ui/src/components/IssueProperties.test.tsx` — 110 passed.
- `pnpm --filter @paperclipai/db exec vitest run
src/inbox-archive-agent-policies-migration.test.ts` — 1 passed.
- `node --test
.agents/skills/pr-gardening/scripts/pr-gardening.test.mjs` — 9 passed.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm check:token-gates` — changed files are clean; the
repository-wide command currently reports nine unrelated pre-existing
`#9627` literals outside this PR's diff.

## Risks

- Agent inbox mutations broaden an existing endpoint path, so
authorization and target resolution must remain fail-closed; focused
route and authorization tests cover allowed and denied paths.
- Archive state affects only the responsible user's inbox presentation
and remains reversible; it does not mutate issue status, assignment, or
visibility.
- The UI policy defaults to the existing open behavior, while allowlist
and disabled modes can reduce agent access.
- This PR intentionally builds on #9654 and #9658 and contains no new
migration number or modification to an already-applied migration.

> 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

- OpenAI Codex using GPT-5.4, medium reasoning, repository tool use,
shell execution, code review, and test execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change
(`feat/inbox-agent-archive-complete`) and contains no internal Paperclip
ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-07-16 16:49:18 -05:00 committed by GitHub
parent 89af58b6dc
commit 59fb27ff79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1734 additions and 38 deletions

View File

@ -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 <N>`: issue activity window, default `30`.
- `--repo <owner/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 <N>`: repeat-comment cooldown, default `48`.
- `--max-rounds <N>`: 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
<!-- pr-gardening:paperclipai/paperclip#1234 -->
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.

View File

@ -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.

View File

@ -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,

View File

@ -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;
}

View File

@ -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;
};

View File

@ -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<typeof updateInboxAgentPolicySchema>;

View File

@ -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",

View File

@ -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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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"));
});
});

View File

@ -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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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<ReturnType<typeof seed>>): 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",
}),
]));
});
});

View File

@ -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);

View File

@ -33,6 +33,7 @@ const apiPrefixes: Record<string, string> = {
"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",

View File

@ -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 }));

View File

@ -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;
}

View File

@ -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";

View File

@ -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) => {

View File

@ -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({

View File

@ -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.",

View File

@ -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<InboxAgentPolicy> {
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<InboxAgentPolicy> {
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 };
}

View File

@ -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,

View File

@ -1479,6 +1479,50 @@ function latestIssueActivityAt(...values: Array<Date | string | null | undefined
return normalized[0] ?? null;
}
type InboxArchiveAttributionRow = {
issueId: string;
archivedAt: Date;
archivedByActorType: "user" | "agent";
archivedByAgentId: string | null;
archivedByRunId: string | null;
};
async function inboxArchiveRowsForIssues(
dbOrTx: Db,
companyId: string,
userId: string,
issueIds: string[],
): Promise<InboxArchiveAttributionRow[]> {
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<string, IssueRelationIssueSummary[]>()),
@ -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<IssueRow, "id" | "companyId" | "updatedAt">,
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);

View File

@ -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.

View File

@ -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`) |

View File

@ -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<InboxAgentPolicy>(`/companies/${companyId}/users/me/inbox-agent-policy`),
updateMine: (companyId: string, input: UpdateInboxAgentPolicy) =>
api.put<InboxAgentPolicy>(`/companies/${companyId}/users/me/inbox-agent-policy`, input),
};

View File

@ -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<void>) {
let result: void | Promise<void> = 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> = {}): 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(
<QueryClientProvider client={queryClient}>
<InboxAgentPolicyControl companyId="company-1" />
</QueryClientProvider>,
);
});
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<HTMLButtonElement>(
'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());
});
});

View File

@ -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<Draft | null>(null);
const lastServerKeyRef = useRef<string | null>(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<InboxAgentPolicy>(queryKeys.inboxAgentPolicy.mine(companyId!), saved);
},
});
const isDirty = Boolean(
draft && policy && policyKey(draft.mode, draft.allowedAgentIds) !== policyKey(policy.mode, policy.allowedAgentIds),
);
if (policyQuery.error) {
return (
<div className="text-sm text-destructive">
{policyQuery.error instanceof Error ? policyQuery.error.message : "Failed to load inbox agent policy."}
</div>
);
}
if (policyQuery.isLoading || !draft) {
return <div className="text-sm text-muted-foreground">Loading inbox agent policy</div>;
}
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 (
<section className="space-y-4" aria-label="Let agents tidy my inbox">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Inbox className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">Let agents tidy my inbox</h2>
</div>
<p className="max-w-2xl text-sm text-muted-foreground">
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&apos;s properties.
</p>
</div>
<RadioCardGroup
ariaLabel="Inbox agent archiving policy"
value={draft.mode}
onValueChange={(value) => setDraft((current) => (current ? { ...current, mode: value as InboxAgentPolicyMode } : current))}
options={MODE_OPTIONS}
className="max-w-2xl"
/>
{draft.mode === "allowlist" ? (
<div className="max-w-2xl space-y-2 rounded-md border border-border p-3">
<div className="text-sm font-medium">Agents allowed to tidy my inbox</div>
{selectableAgents.length === 0 ? (
<p className="text-xs text-muted-foreground">You don&apos;t manage any agents yet.</p>
) : (
<ul className="space-y-1.5">
{selectableAgents.map((agent) => {
const checked = draft.allowedAgentIds.includes(agent.id);
return (
<li key={agent.id}>
<label className="flex cursor-pointer items-center gap-2.5 rounded-md px-1.5 py-1 transition-colors hover:bg-accent/40">
<Checkbox
checked={checked}
onCheckedChange={(next) => toggleAgent(agent.id, next === true)}
aria-label={`Allow ${agent.name} to tidy my inbox`}
/>
<AgentIcon icon={agent.icon} className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-sm">{agent.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{agent.role}</span>
</label>
</li>
);
})}
</ul>
)}
</div>
) : null}
{updateMutation.error ? (
<div className="max-w-2xl rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{updateMutation.error instanceof Error ? updateMutation.error.message : "Failed to save inbox agent policy."}
</div>
) : null}
<div className="flex max-w-2xl items-center justify-end gap-3">
{updateMutation.isSuccess && !isDirty ? (
<span className="text-xs text-muted-foreground" role="status">Saved</span>
) : null}
<Button
type="button"
disabled={!isDirty || updateMutation.isPending}
onClick={() => draft && updateMutation.mutate(draft)}
>
{updateMutation.isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
{updateMutation.isPending ? "Saving…" : "Save"}
</Button>
</div>
</section>
);
}

View File

@ -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 <name> · <time>" phrasing so
// the attribution is recoverable if a long name truncates at the 320px pane
// width (PAP-14182 review fix).
const attribution = Array.from(container.querySelectorAll("span"))
.find((span) => span.getAttribute("title")?.startsWith("Archived by Gardener"));
expect(attribution).toBeTruthy();
const unarchiveButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Unarchive"))!;
await act(async () => {
unarchiveButton.click();
});
await flush();
expect(mockIssuesApi.unarchiveFromInbox).toHaveBeenCalledWith("issue-1");
act(() => root.unmount());
});
it("surfaces unarchive failures inline", async () => {
mockAgentsApi.list.mockResolvedValue([
{ id: "agent-9", name: "Gardener", status: "active", adapterType: "codex_local", icon: null },
]);
mockIssuesApi.unarchiveFromInbox.mockRejectedValue(new Error("Archive policy denied"));
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();
let unarchiveButton: HTMLButtonElement | undefined;
await waitForAssertion(() => {
unarchiveButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Unarchive"));
expect(unarchiveButton).toBeTruthy();
});
await act(async () => {
unarchiveButton!.click();
});
await flush();
await waitForAssertion(() => {
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Archive policy denied");
});
act(() => root.unmount());
});
it("does not render archive attribution for user (manual) archives", async () => {
const root = renderProperties(container, {
issue: createIssue({
archivedAt: new Date("2026-04-06T12:10:00.000Z"),
archivedByActorType: "user",
archivedByAgentId: null,
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
await waitForAssertion(() => {
expect(container.textContent).toContain("Updated");
});
expect(container.textContent).not.toContain("Archived by");
expect(
Array.from(container.querySelectorAll("button")).some((button) => button.textContent?.includes("Unarchive")),
).toBe(false);
act(() => root.unmount());
});
});

View File

@ -38,12 +38,13 @@ import { IssueReferencePill } from "../IssueReferencePill";
import { formatDate, formatDateTime, cn, projectUrl } from "../../lib/utils";
import type { IssueExternalObjectGroup } from "../../hooks/useIssueExternalObjects";
import { timeAgo } from "../../lib/timeAgo";
import { invalidateInboxIssueQueries } from "../../lib/inboxArchiveCache";
import { Button } from "@/components/ui/button";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { User, ArrowUpRight, Plus, GitBranch, FolderOpen, HardDrive, Check, Clock, RotateCcw, Loader2, CheckCircle2 } from "lucide-react";
import { User, ArrowUpRight, Plus, GitBranch, FolderOpen, HardDrive, Check, Clock, RotateCcw, Loader2, CheckCircle2, ArchiveRestore } from "lucide-react";
import { AgentIcon } from "../AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "../InlineEntitySelector";
import {
@ -187,6 +188,7 @@ export function IssueProperties({
const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? "");
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState<string | null>(null);
const [unarchiveErrorMessage, setUnarchiveErrorMessage] = useState<string | null>(null);
const [watchdogOpen, setWatchdogOpen] = useState(false);
const [watchdogAgentInput, setWatchdogAgentInput] = useState(issue.watchdog?.watchdogAgentId ?? "");
const [watchdogInstructionsInput, setWatchdogInstructionsInput] = useState(issue.watchdog?.instructions ?? "");
@ -270,6 +272,26 @@ export function IssueProperties({
},
});
const unarchiveFromInbox = useMutation({
mutationFn: () => issuesApi.unarchiveFromInbox(issue.id),
onMutate: () => {
setUnarchiveErrorMessage(null);
},
onSuccess: () => {
setUnarchiveErrorMessage(null);
queryClient.setQueryData<Issue>(queryKeys.issues.detail(issue.id), (current) =>
current ? { ...current, archivedAt: null, archivedByActorType: null, archivedByAgentId: null, archivedByRunId: null } : current,
);
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
if (companyId) invalidateInboxIssueQueries(queryClient, companyId);
},
onError: (error) => {
setUnarchiveErrorMessage(error instanceof Error && error.message.trim().length > 0
? error.message
: "Failed to unarchive this issue. Please try again.");
},
});
const toggleLabel = (labelId: string) => {
const ids = issue.labelIds ?? [];
const next = ids.includes(labelId)
@ -2302,6 +2324,52 @@ export function IssueProperties({
<PropertyRow label="Updated">
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
</PropertyRow>
{issue.archivedAt && issue.archivedByActorType === "agent" && issue.archivedByAgentId ? (
(() => {
const archivedByAgent = (agents ?? []).find((candidate) => candidate.id === issue.archivedByAgentId);
const archivedByName = agentName(issue.archivedByAgentId);
return (
<PropertyRow label="Archived">
<div className="flex min-w-0 max-w-full flex-col items-start gap-1">
{/* The row label already reads "Archived", so the value shows just
the attributing agent (icon + name) this gives the name the
full ~164px value column at the real 320px pane width, where an
"Archived by …" prefix would clip even short names. The full
phrasing + timestamp live in the tooltip so any residual
truncation on genuinely long names is recoverable. */}
<span
className="flex min-w-0 max-w-full items-center gap-1.5 text-sm"
title={`Archived by ${archivedByName} · ${formatDateTime(issue.archivedAt)}`}
>
{archivedByAgent
? <AgentIcon icon={archivedByAgent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
: null}
<span className="min-w-0 truncate">
{archivedByName}
</span>
</span>
<div className="flex min-w-0 max-w-full items-center gap-2">
<span className="shrink-0 text-xs text-muted-foreground">{timeAgo(issue.archivedAt)}</span>
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
onClick={() => unarchiveFromInbox.mutate()}
disabled={unarchiveFromInbox.isPending}
>
<ArchiveRestore className="h-3 w-3" />
{unarchiveFromInbox.isPending ? "Unarchiving…" : "Unarchive"}
</button>
</div>
{unarchiveErrorMessage ? (
<p className="text-xs text-destructive" role="alert">
{unarchiveErrorMessage}
</p>
) : null}
</div>
</PropertyRow>
);
})()
) : null}
{issue.requestDepth > 0 && (
<PropertyRow label="Depth">
<span className="text-sm font-mono">{issue.requestDepth}</span>

View File

@ -304,6 +304,9 @@ export const queryKeys = {
auth: {
session: ["auth", "session"] as const,
},
inboxAgentPolicy: {
mine: (companyId: string) => ["inbox-agent-policy", companyId, "me"] as const,
},
sidebarPreferences: {
companyOrder: (userId: string) => ["sidebar-preferences", "company-order", userId] as const,
projectOrder: (companyId: string, userId: string) =>

View File

@ -7,6 +7,7 @@ import { assetsApi } from "@/api/assets";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { InboxAgentPolicyControl } from "@/components/InboxAgentPolicyControl";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@ -269,6 +270,10 @@ export function ProfileSettings() {
</Button>
</div>
</form>
<Card className="rounded-(--rad-28) border-border/70 p-6">
<InboxAgentPolicyControl companyId={selectedCompanyId} />
</Card>
</section>
</div>
);